diff --git a/.aws-architecture b/.aws-architecture new file mode 100644 index 0000000..11a8121 --- /dev/null +++ b/.aws-architecture @@ -0,0 +1 @@ +linux/amd64 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2e755e5..8d84f9d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,17 @@ +# No ENV files .env + +# No VSCode files +.vscode + +# No special macOS files **/.DS_Store -temp/ \ No newline at end of file + +# No temp directory +temp/ + +# No asdf files +.tool-versions + +# No cpu architecture temp file +.arch_tag diff --git a/Dockerfile b/Dockerfile index eeb4b06..123ea44 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,28 @@ -FROM matomo:5.10.1 +FROM matomo:5.12.0 # checkov:skip=CKV_DOCKER_2:Skipping HEALTHCHECK configuration for now # checkov:skip=CKV_DOCKER_3:The container actually runs as www-data user +## begin Free plugins # Add the EnvironmentVariables plugin COPY ./files/plugin-EnvironmentVariables-5.0.3/ /var/www/html/plugins/EnvironmentVariables # Add the CustomVariables plugin COPY ./files/plugin-CustomVariables-5.0.6/ /var/www/html/plugins/CustomVariables +## end Free plugins -# Add the HeatmapSessionRecording plugin -COPY ./files/plugin-HeatmapSessionRecording-5.3.5/ /var/www/html/plugins/HeatmapSessionRecording +## begin Premium (e.g., paid) plugins linked to our license code +# Add the CustomReports plugin +COPY ./files/plugin-CustomReports-5.5.0/ /var/www/html/plugins/CustomReports -# Add the UsersFlow plugin -COPY ./files/plugin-UsersFlow-5.0.9/ /var/www/html/plugins/UsersFlow +# Add the HeatmapSessionRecording plugin +COPY ./files/plugin-HeatmapSessionRecording-5.6.0/ /var/www/html/plugins/HeatmapSessionRecording # Add the SearchEngineKeywordsPerformance plugin -COPY ./files/plugin-SearchEngineKeywordsPerformance-5.0.29/ /var/www/html/plugins/SearchEngineKeywordsPerformance +COPY ./files/plugin-SearchEngineKeywordsPerformance-5.2.1/ /var/www/html/plugins/SearchEngineKeywordsPerformance -# Add the CustomReports plugin -COPY ./files/plugin-CustomReports-5.4.10/ /var/www/html/plugins/CustomReports +# Add the UsersFlow plugin +COPY ./files/plugin-UsersFlow-5.1.1/ /var/www/html/plugins/UsersFlow +## end Premium plugins # Our custom configuration settings. COPY ./files/config.ini.php /var/www/html/config/config.ini.php diff --git a/Makefile b/Makefile index 045dd33..2354c8a 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ SHELL=/bin/bash ### and review the other commented lines in the document. ### ECR_NAME_DEV:=docker-matomo-dev ECR_URL_DEV:=222053980223.dkr.ecr.us-east-1.amazonaws.com/docker-matomo-dev +CPU_ARCH ?= $(shell cat .aws-architecture 2>/dev/null || echo "linux/amd64") # FUNCTION_DEV:= ### End of Terraform-generated header ### @@ -13,14 +14,44 @@ help: ## Print this message /^[-_[:alpha:]]+:.?*##/ { printf " %-15s%s\n", $$1, $$2 }' $(MAKEFILE_LIST) ### Terraform-generated Developer Deploy Commands for Dev environment ### -dist-dev: ## Build docker container (intended for developer-based manual build) - docker build \ - --platform linux/amd64 \ - -t $(ECR_URL_DEV):latest \ - -t $(ECR_URL_DEV):`git describe --always` \ - -t $(ECR_NAME_DEV):latest . +check-arch: + @ARCH_FILE=".aws-architecture"; \ + if [[ "$(CPU_ARCH)" != "linux/amd64" && "$(CPU_ARCH)" != "linux/arm64" ]]; then \ + echo "Invalid CPU_ARCH: $(CPU_ARCH)"; exit 1; \ + fi; \ + if [[ -f $$ARCH_FILE ]]; then \ + echo "latest-$(shell echo $(CPU_ARCH) | cut -d'/' -f2)" > .arch_tag; \ + else \ + echo "latest" > .arch_tag; \ + fi -publish-dev: dist-dev ## Build, tag and push (intended for developer-based manual publish) - docker login -u AWS -p $$(aws ecr get-login-password --region us-east-1) $(ECR_URL_DEV) - docker push $(ECR_URL_DEV):latest - docker push $(ECR_URL_DEV):`git describe --always` +dist-dev: check-arch ## Build docker container (intended for developer-based manual build) + @ARCH_TAG=$$(cat .arch_tag); \ + docker buildx inspect $(ECR_NAME_DEV) >/dev/null 2>&1 || docker buildx create --name $(ECR_NAME_DEV) --use; \ + docker buildx use $(ECR_NAME_DEV); \ + docker buildx build --platform $(CPU_ARCH) \ + --load \ + --tag $(ECR_URL_DEV):$$ARCH_TAG \ + --tag $(ECR_URL_DEV):make-$$ARCH_TAG \ + --tag $(ECR_URL_DEV):make-$(shell git describe --always) \ + --tag $(ECR_NAME_DEV):$$ARCH_TAG \ + . + +publish-dev: dist-dev # Build, tag and push (intended for developer-based manual publish) + @ARCH_TAG=$$(cat .arch_tag); \ + aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin $(ECR_URL_DEV); \ + docker push $(ECR_URL_DEV):$$ARCH_TAG; \ + docker push $(ECR_URL_DEV):make-$$ARCH_TAG; \ + docker push $(ECR_URL_DEV):make-$(shell git describe --always); \ + echo "Cleaning up dangling Docker images..."; \ + docker image prune -f --filter "dangling=true" + +docker-clean: # Clean up Docker detritus + @ARCH_TAG=$$(cat .arch_tag); \ + echo "Cleaning up Docker leftovers (containers, images, builders)"; \ + docker rmi -f $(ECR_URL_DEV):$$ARCH_TAG; \ + docker rmi -f $(ECR_URL_DEV):make-$$ARCH_TAG; \ + docker rmi -f $(ECR_URL_DEV):make-$(shell git describe --always) || true; \ + docker rmi -f $(ECR_NAME_DEV):$$ARCH_TAG || true; \ + docker buildx rm $(ECR_NAME_DEV) || true + @rm -rf .arch_tag diff --git a/docs/HowTos/HOWTO-matomo-upgrade.md b/docs/HowTos/HOWTO-matomo-upgrade.md index d513a55..3561bee 100644 --- a/docs/HowTos/HOWTO-matomo-upgrade.md +++ b/docs/HowTos/HOWTO-matomo-upgrade.md @@ -47,7 +47,7 @@ These instructions assume you are working in the **dev** environment. Change to Often, an update to the version of Matomo will require an update to a plugin version. See below for an overview of the plugin update process. 1. Ensure that an out-of-band backup of the current `config/config.ini.php` exists - * SSH (via AWSCLI + Session Manager) to the container(see the [Troubleshooting](./HOWTO-miscellaneous.md) section for the AWS CLI connection command). + * SSH (via AWSCLI + Session Manager) to the container (see the [Troubleshooting](./HOWTO-miscellaneous.md) section for the AWS CLI connection command). * Run `cp /var/www/html/config/* /mnt/efs/backup/config` 1. Visit [Matomo plugins](https://plugins.matomo.org), select the correct version of Matomo, and then search for the plugins that are currently in use in our instance of Matomo. * If there is a newer version, download it from the site, unzip it, and store the unzipped folder in the [files/](../../files/) directory, following the naming convention in place (e.g., `plugin--`) diff --git a/files/plugin-CustomReports-5.5.0/API.php b/files/plugin-CustomReports-5.5.0/API.php new file mode 100644 index 0000000..1f33235 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/API.php @@ -0,0 +1,1029 @@ +metricsList = MetricsList::get(); + $this->columnsProvider = $columnsProvider; + $this->model = $model; + $this->validator = $validator; + $this->logTablesProvider = $logTablesProvider; + $this->configuration = $configuration; + $this->archiveInvalidator = $archiveInvalidator; + } + + /** + * Duplicates a custom report to one or more websites. + * If the name already exists on a destination site, the copied report is renamed automatically. + * + * @param int $idSite The numeric ID of the website that owns the source custom report. + * @param int $idCustomReport The ID of the custom report to duplicate. + * @param int[] $idDestinationSites Website IDs to copy the custom report to. Defaults to [$idSite] when omitted. + * @return array Response indicating whether the duplication succeeded and, on success, the new report ID. + */ + public function duplicateCustomReport(int $idSite, int $idCustomReport, array $idDestinationSites = []): array + { + if (!class_exists('\Piwik\Plugins\CoreHome\EntityDuplicator\DuplicateRequestResponse')) { + throw new BadRequestException('This endpoint is not available until Matomo 5.4.0'); + } + + // Define data array before any alterations to the variables + $additionalData = [ + 'idSite' => $idSite, + 'idDestinationSites' => $idDestinationSites, + 'idCustomReport' => $idCustomReport, + ]; + + $idDestinationSites = count($idDestinationSites) > 0 ? $idDestinationSites : [$idSite]; + $idSitesToCheck = array_unique(array_merge([$idSite], $idDestinationSites)); + $this->validator->checkSitesDuplicationPermission($idSitesToCheck); + + // Initialise the common response values + $duplicateRequestResponse = new DuplicateRequestResponse(); + + $customReport = null; + try { + $customReport = $this->getConfiguredReport($idSite, $idCustomReport); + } catch (\Throwable $e) { + // Log the error, but continue for the proper response to be built later + $this->logError('Uncaught exception looking up custom report to duplicate: {exception}', $e); + } + // Name and atleast 1 dimension and 1 metric is needed to create a custom report + if (empty($customReport['name'])) { + $duplicateRequestResponse->setSuccess(false); + $duplicateRequestResponse->setMessage(Piwik::translate('CustomReports_SourceCustomReportLookupError')); + $duplicateRequestResponse->setAdditionalData($additionalData); + + return $duplicateRequestResponse->getResponseArray(); + } + + $newName = $customReport['name']; + $customReportNames = []; + foreach ($idDestinationSites as $idDestinationSite) { + $customReports = $this->model->getAllCustomReportsForSite($idDestinationSite, true, true); + // It can only be a duplicate name if some custom reports were found for the site. + if (is_array($customReports) && count($customReports) > 0) { + $customReportNames = array_merge($customReportNames, array_column($customReports, 'name')); + } + } + // It can only be a duplicate name if some custom reports were found for the site. + if (count($customReportNames) > 0) { + $newName = EntityDuplicatorHelper::getUniqueNameComparedToList($newName, $customReportNames, 50); + } + $category = ''; + $subCategory = ''; + if ($idDestinationSites[0] === $idSite) { + $category = !empty($customReport['category']['id']) ? $customReport['category']['id'] : ''; + $subCategory = !empty($customReport['subcategory']['id']) ? $customReport['subcategory']['id'] : ''; + } + try { + $response = $this->addCustomReport( + $idDestinationSites[0], + $newName, + $customReport['report_type'], + $customReport['metrics'], + $category, + $customReport['dimensions'], + $subCategory, + $customReport['description'], + $customReport['segment_filter'], + (count($idDestinationSites) > 1 ? $idDestinationSites : []) // Add only if more than 1 destination site is passed + ); + } catch (\Exception $e) { + $response = false; + $this->logError('Uncaught exception duplicating custom report: {exception}', $e); + } + + if (!is_int($response) || $response < 1) { + $duplicateRequestResponse->setSuccess(false); + $duplicateRequestResponse->setMessage(Piwik::translate('CustomReports_CustomReportDuplicationError')); + } else { + // Set the values for success response + $duplicateRequestResponse->setSuccess(true); + $duplicateRequestResponse->setMessage(Piwik::translate('CustomReports_CustomReportCopied')); + $additionalData['newIds'] = $response; + $duplicateRequestResponse->setAdditionalData($additionalData); + } + + // Make sure to record the activity for the report being copied + if (class_exists('\Piwik\Plugins\ActivityLog\ActivityParamObject\EntityDuplicatedData')) { + // TODO - Remove this if/else and always use the setRequestDataForActivity method for Matomo 6.x + if (method_exists($duplicateRequestResponse, 'setRequestDataForEvent')) { + $duplicateRequestResponse->setRequestDataForEvent( + 'CustomReports_CustomReport', + $customReport['name'], + $idCustomReport, + $idSite, + $idDestinationSites, + $additionalData + ); + } else { + ( + new \Piwik\Plugins\ActivityLog\ActivityParamObject\EntityDuplicatedData( + 'CustomReports_CustomReport', + $customReport['name'], + $idCustomReport, + $idSite, + $idDestinationSites, + $additionalData + ) + )->postActivityEvent(); + } + } + + return $duplicateRequestResponse->getResponseArray(); + } + + private function logError(string $message, \Throwable $e): void + { + StaticContainer::get(\Piwik\Log\LoggerInterface::class)->error( + $message, + [ + 'exception' => $e, + 'ignoreInScreenWriter' => true, + ] + ); + } + + /** + * Creates a new custom report definition. + * + * @param int|string $idSite Website ID that owns the report, or "all"/0 when creating a shared report for multiple websites. + * @param string $name The report name. + * @param string $reportType Report type to create, for example "table", "evolution" or "kpicard". + * @param string[] $metricIds Metric IDs to include in the report. + * @param string|false $categoryId Category ID to place the report in. Uses the default custom reports category when omitted. + * @param string[] $dimensionIds Dimension IDs to include in the report. + * @param string|false $subcategoryId Subcategory ID to place the report in. Creates a new reporting page when omitted. + * @param string $description Optional report description shown in the report help tooltip. + * @param string $segmentFilter Optional URL-encoded segment applied to the report data. + * @param int[]|string[] $multipleIdSites Optional website IDs to assign the report to when creating a shared report. + * @return int The ID of the newly created custom report. + */ + public function addCustomReport($idSite, $name, $reportType, $metricIds, $categoryId = false, $dimensionIds = array(), $subcategoryId = false, $description = '', $segmentFilter = '', $multipleIdSites = []) + { + if (!empty($multipleIdSites) && $idSite != 'all' && $idSite != '0') { + $multipleIdSites = $this->normalizeMultipleIdSites($multipleIdSites); + foreach ($multipleIdSites as $multipleIdSite) { + $this->validator->checkWritePermission($multipleIdSite); + } + if (!in_array((int) $idSite, $multipleIdSites, true)) { + throw new \Exception(Piwik::translate('CustomReports_ErrorInvalidMultipleIdSite', [$idSite])); + } + } else { + $this->validator->checkWritePermission($idSite); + // prevent creating reports for sites that do not yet exist but might in the future + $this->validator->checkSiteExists($idSite); + } + + if (empty($categoryId)) { + $categoryId = CustomReportsDao::DEFAULT_CATEGORY; + } + + $createdDate = Date::now()->getDatetime(); + if (!empty($segmentFilter)) { + $segmentFilter = Common::unsanitizeInputValue($segmentFilter); + $segmentFilter = urldecode($segmentFilter); + } + + // If there's a Product Revenue metric without a Product Quantity metric, throw an exception + if ( + (in_array('sum_product_revenue', $metricIds) || in_array('avg_product_revenue', $metricIds)) + && !in_array('sum_ecommerce_productquantity', $metricIds) + && !in_array('avg_ecommerce_productquantity', $metricIds) + ) { + throw new \Exception(Piwik::translate('CustomReports_ErrorProductRevenueMetricDependency')); + } + + $idReport = $this->model->createCustomReport($idSite, $name, $description, $reportType, $dimensionIds, $metricIds, $segmentFilter, $categoryId, $subcategoryId, $createdDate, $multipleIdSites); + $report = $this->model->getCustomReportById($idReport, $idSite); + + $config = StaticContainer::get(Configuration::class); + $startDate = null; + $subMonth = $config->getReArchiveReportsInPastLastNMonths(); + if (!empty($subMonth)) { + $startDate = Date::yesterday()->subMonth($subMonth)->setDay(1); + } + + $this->scheduleReArchiving($idSite, $multipleIdSites, $report, $startDate); + + $this->clearCache(); + return $idReport; + } + + private function clearCache() + { + // we need to delete possibly cached values. especially ReportsProvider + try { + Cache::getLazyCache()->flushAll(); + } catch (\Exception $e) { + } + // we need to delete possibly cached values. especially ReportsProvider + try { + Cache::getEagerCache()->flushAll(); + } catch (\Exception $e) { + } + // we need to delete possibly cached values. especially ReportsProvider + try { + Cache::getTransientCache()->flushAll(); + } catch (\Exception $e) { + } + } + + /** + * Updates an existing custom report definition. + * Changing metrics, dimensions, report type, or segment may require the report to be archived again. + * + * @param int|string $idSite Website ID that owns the report, or "all"/0 when updating a shared report. + * @param int $idCustomReport The ID of the custom report to update. + * @param string $name The report name. + * @param string $reportType Report type to store, for example "table", "evolution" or "kpicard". + * @param string[] $metricIds Metric IDs to include in the report. + * @param string|false $categoryId Category ID to place the report in. Uses the default custom reports category when omitted. + * @param string[] $dimensionIds Dimension IDs to include in the report. + * @param string|false $subcategoryId Subcategory ID to place the report in. Creates a new reporting page when omitted. + * @param string $description Optional report description shown in the report help tooltip. + * @param string $segmentFilter Optional URL-encoded segment applied to the report data. + * @param int[] $subCategoryReportIds Child report IDs mapped to this report. + * @param int[]|string[] $multipleIdSites Optional website IDs to assign the report to when updating a shared report. + * @return void + */ + public function updateCustomReport( + $idSite, + $idCustomReport, + $name, + $reportType, + $metricIds, + $categoryId = false, + $dimensionIds = array(), + $subcategoryId = false, + $description = '', + $segmentFilter = '', + $subCategoryReportIds = [], + $multipleIdSites = [] + ): void { + if (!empty($multipleIdSites) && $idSite != 'all' && $idSite != '0') { + $multipleIdSites = $this->normalizeMultipleIdSites($multipleIdSites); + foreach ($multipleIdSites as $multipleIdSite) { + $this->validator->checkWritePermission($multipleIdSite); + } + if (!in_array((int) $idSite, $multipleIdSites, true)) { + throw new \Exception(Piwik::translate('CustomReports_ErrorInvalidMultipleIdSite', [$idSite])); + } + } else { + $this->validator->checkWritePermission($idSite); + // prevent creating reports for sites that do not yet exist but might in the future + $this->validator->checkSiteExists($idSite); + } + + // we cannot get report by idSite, idCustomReport since the idSite may change! + $report = $this->model->getCustomReportById($idCustomReport, $idSite); + + if (empty($report)) { + throw new \Exception(Piwik::translate('CustomReports_ErrorReportDoesNotExist')); + } + + foreach ($this->getCurrentOwnerSites($report) as $currentSite) { + $this->validator->checkWritePermission($currentSite); + } + + if (empty($categoryId)) { + $categoryId = CustomReportsDao::DEFAULT_CATEGORY; + } + + if (!empty($segmentFilter)) { + $segmentFilter = Common::unsanitizeInputValue($segmentFilter); + $segmentFilter = urldecode($segmentFilter); + } + + $updatedDate = Date::now()->getDatetime(); + + $shouldReArchive = false; + if ( + ( + isset($report['report_type']) && + $report['report_type'] != $reportType + ) || + ( + isset($report['dimensions']) && + $report['dimensions'] != $dimensionIds + ) || + ( + isset($report['metrics']) && + $report['metrics'] != $metricIds + ) || + ( + isset($report['segment_filter']) && + $report['segment_filter'] != $segmentFilter + ) + ) { + $shouldReArchive = true; + } + + // If there's a Product Revenue metric without a Product Quantity metric, throw an exception + if ( + (in_array('sum_product_revenue', $metricIds) || in_array('avg_product_revenue', $metricIds)) + && !in_array('sum_ecommerce_productquantity', $metricIds) + && !in_array('avg_ecommerce_productquantity', $metricIds) + ) { + throw new \Exception(Piwik::translate('CustomReports_ErrorProductRevenueMetricDependency')); + } + + $this->model->updateCustomReport($idSite, $idCustomReport, $name, $description, $reportType, $dimensionIds, $metricIds, $segmentFilter, $categoryId, $subcategoryId, $updatedDate, $subCategoryReportIds, $multipleIdSites); + + if ($shouldReArchive) { + $updatedReport = $this->model->getCustomReportById($idCustomReport, $idSite); + $config = StaticContainer::get(Configuration::class); + $startDate = null; + $subMonth = $config->getReArchiveReportsInPastLastNMonths(); + if (!empty($subMonth)) { + $startDate = Date::yesterday()->subMonth($subMonth)->setDay(1); + } + + $this->scheduleReArchiving($idSite, $multipleIdSites, $updatedReport, $startDate); + } + + $this->clearCache(); + } + + /** + * Normalize submitted website IDs to unique positive integers, rejecting any value + * that is not an exact integer (e.g. padded, signed, decimal or exponent forms). + */ + private function normalizeMultipleIdSites(array $multipleIdSites): array + { + $normalized = []; + foreach ($multipleIdSites as $candidate) { + $id = (int) $candidate; + if ($id > 0 && (string) $id === (string) $candidate) { + $normalized[] = $id; + } else { + throw new \Exception(Piwik::translate('CustomReports_ErrorInvalidIdSite', [$candidate])); + } + } + + return array_values(array_unique($normalized, SORT_NUMERIC)); + } + + private function scheduleReArchiving($idSite, $multipleIdSites, array $report, ?Date $startDate): void + { + $idSites = $idSite === 0 || $idSite === '0' || $idSite == 'all' ? 'all' : (!empty($multipleIdSites) ? $multipleIdSites : [$idSite]); + + $this->archiveInvalidator->scheduleReArchiving( + $idSites, + 'CustomReports', + Archiver::makeRecordName($report['idcustomreport'], $report['revision'] ?? 0), + $startDate + ); + + if (ReportType::factory($report['report_type'])->usesNumericRecords()) { + foreach ($this->model->getArchivableMetricsInReport($report) as $metric) { + $this->archiveInvalidator->scheduleReArchiving( + $idSites, + 'CustomReports', + Archiver::makeEvolutionRecordName($report['idcustomreport'], $report['revision'], $metric->getName()), + $startDate + ); + } + } + } + + /** + * Returns all configured custom reports for a website. + * + * @param int|string $idSite The website ID to query, or "all" for reports shared across all websites. + * @param bool $skipCategoryMetadata Whether to omit category metadata from each returned report. + * @return array The configured custom reports for the requested website. + */ + public function getConfiguredReports($idSite, $skipCategoryMetadata = false) + { + $this->validator->checkReportViewPermission($idSite); + $this->validator->checkSiteExists($idSite); + + if ($idSite === 'all') { + $idSite = 0; + } + + $reports = $this->model->getAllCustomReportsForSite($idSite, $skipCategoryMetadata == '1'); + usort($reports, function ($a, $b) { + if ($a['idcustomreport'] > $b['idcustomreport']) { + return 1; // no need to check for === because two reports won't have same ID + } + return -1; + }); + + foreach ($reports as &$report) { + $this->addAllowedToEditStatus($report); + } + + return $reports; + } + + /** + * Returns a specific custom report configuration. + * + * @param int|string $idSite The website ID to query, or "all" for a report shared across all websites. + * @param int $idCustomReport The ID of the custom report. [@example=1] + * @return array The configured custom report details. + */ + public function getConfiguredReport($idSite, $idCustomReport) + { + $this->validator->checkReportViewPermission($idSite); + $this->validator->checkSiteExists($idSite); + + if ($idSite === 'all') { + $idSite = 0; + } + + $this->model->checkReportExists($idSite, $idCustomReport); + + $report = $this->model->getCustomReport($idSite, $idCustomReport); + $this->addAllowedToEditStatus($report); + + return $report; + } + + /** + * Deletes a custom report definition. + * Deleted reports are no longer available through the API, and their archived data may be removed later. + * + * @param int|string $idSite The website ID that owns the custom report, or "all" for a shared report. + * @param int $idCustomReport The ID of the custom report to delete. + * @return void + */ + public function deleteCustomReport($idSite, $idCustomReport): void + { + $this->validator->checkWritePermission($idSite); + + if ($idSite === 'all') { + $idSite = 0; + } + + $report = $this->getCustomReportInfo($idSite, $idCustomReport, 'Delete'); + + $multipleIDSites = $report['multiple_idsites'] ? explode(',', $report['multiple_idsites']) : []; + if ($multipleIDSites) { + foreach ($multipleIDSites as $multipleIdSite) { + $this->validator->checkWritePermission($multipleIdSite); + } + } + $this->archiveInvalidator->removeInvalidationsSafely( + $multipleIDSites ? $multipleIDSites : [$idSite], + 'CustomReports', + Archiver::makeRecordName($idCustomReport, $report['revision']) + ); + + $this->model->deactivateReport($multipleIDSites ? -1 : $idSite, $idCustomReport, $report['name']); + Piwik::postEvent('CustomReports.deleteCustomReport.end', array($idSite, $idCustomReport)); + $this->clearCache(); + } + + /** + * Pauses a custom report. + * Paused reports stop being archived until they are resumed. + * + * @param int|string $idSite The website ID that owns the custom report, or "all" for a shared report. + * @param int $idCustomReport The ID of the custom report to pause. + * @return void + */ + public function pauseCustomReport($idSite, $idCustomReport): void + { + $this->validator->checkWritePermission($idSite); + + if ($idSite === 'all') { + $idSite = 0; + } + + $report = $this->getCustomReportInfo($idSite, $idCustomReport, 'Pause'); + + $multipleIDSites = $report['multiple_idsites'] ? explode(',', $report['multiple_idsites']) : []; + if ($multipleIDSites) { + foreach ($multipleIDSites as $multipleIdSite) { + $this->validator->checkWritePermission($multipleIdSite); + } + } + $this->archiveInvalidator->removeInvalidationsSafely( + $multipleIDSites ? $multipleIDSites : [$idSite], + 'CustomReports', + Archiver::makeRecordName($idCustomReport, $report['revision']) + ); + + $this->model->pauseReport($multipleIDSites ? -1 : $idSite, $idCustomReport, $report['name']); + $this->clearCache(); + } + + /** + * Resumes a paused custom report. + * Resumed reports are archived again for future requests. + * + * @param int|string $idSite The website ID that owns the custom report, or "all" for a shared report. + * @param int $idCustomReport The ID of the custom report to resume. + * @return void + */ + public function resumeCustomReport($idSite, $idCustomReport): void + { + $this->validator->checkWritePermission($idSite); + + if ($idSite === 'all') { + $idSite = 0; + } + + $report = $this->getCustomReportInfo($idSite, $idCustomReport, 'Resume'); + + $multipleIDSites = $report['multiple_idsites'] ? explode(',', $report['multiple_idsites']) : []; + if ($multipleIDSites) { + foreach ($multipleIDSites as $multipleIdSite) { + $this->validator->checkWritePermission($multipleIdSite); + } + } + + $this->model->resumeReport($multipleIDSites ? -1 : $idSite, $idCustomReport, $report['name']); + $this->clearCache(); + } + + /** + * Returns report categories and subcategories that can contain custom reports. + * + * @param int|string $idSite The website ID used to resolve available report pages. + * @return array Available categories and subcategories for custom report placement. + */ + public function getAvailableCategories($idSite) + { + $this->validator->checkReportViewPermission($idSite); + + $reportPages = Request::processRequest('API.getReportPagesMetadata', array('idSite' => $idSite, 'filter_limit' => -1)); + + $categories = array(); + foreach ($reportPages as $reportPage) { + if (!empty($reportPage['category']['id'])) { + $categoryId = $reportPage['category']['id']; + + if ($categoryId === 'Dashboard_Dashboard') { + continue; + } + + $subcategoryId = $reportPage['subcategory']['id']; + if (strpos($subcategoryId, '_Manage') !== false) { + continue; // we do not want to be able to add reports to manage pages + } + + if (isset($categories[$categoryId])) { + $categories[$categoryId]['subcategories'][] = array( + 'uniqueId' => $reportPage['subcategory']['id'], + 'name' => $reportPage['subcategory']['name'] + ); + } else { + $categories[$categoryId] = array( + 'uniqueId' => $categoryId, + 'name' => $reportPage['category']['name'], + 'subcategories' => array( + array( + 'uniqueId' => $reportPage['subcategory']['id'], + 'name' => $reportPage['subcategory']['name'] + ) + ), + ); + } + } + } + + if (!isset($categories['CustomReports_CustomReports'])) { + $categories['CustomReports_CustomReports'] = array( + 'uniqueId' => 'CustomReports_CustomReports', + 'name' => Piwik::translate('CustomReports_CustomReports'), + 'subcategories' => array() + ); + } + + return array_values($categories); + } + + /** + * Returns report types that can be used for custom reports. + * + * @return array Available custom report types keyed by identifier and translated name. + */ + public function getAvailableReportTypes() + { + $this->validator->checkHasSomeWritePermission(); + + $rows = array(); + foreach (ReportType::getAll() as $reportType) { + $rows[] = array('key' => $reportType::ID, 'value' => $reportType->getName()); + } + + return $rows; + } + + private function isTableJoinable($tableName) + { + $logTable = $this->logTablesProvider->getLogTable($tableName); + if ($logTable && ($logTable->getColumnToJoinOnIdAction() || $logTable->getColumnToJoinOnIdVisit())) { + if ($logTable->getPrimaryKey()) { + // without primary key we would not group the data correctly + return true; + } + } elseif ($logTable && $logTable->getWaysToJoinToOtherLogTables()) { + $tables = new JoinTables($this->logTablesProvider, [$tableName]); + return $tables->isTableJoinableOnVisit($tableName) || $tables->isTableJoinableOnAction($tableName); + } + + return false; + } + + /** + * Returns dimensions that can be used in custom reports. + * + * @param int|string $idSite The website ID used to resolve available dimensions. + * @return array Available dimensions grouped by category. + */ + public function getAvailableDimensions($idSite) + { + Piwik::checkUserIsNotAnonymous(); + Piwik::checkUserHasSomeViewAccess(); + + $dimensions = $this->columnsProvider->getAllDimensions(); + + $rows = array(); + + $dimensionsToIgnore = array( + 'Actions.IdPageview', 'CoreHome.VisitId', + 'CoreHome.LinkVisitActionId', 'CoreHome.LinkVisitActionIdPages', 'UserCountry.Provider' + ); + + $dimensionsToRename = [ + 'CoreHome.IdSite' => Piwik::translate('CustomReports_WebsiteName') + ]; + + $config = StaticContainer::get(Configuration::class); + foreach ($config->getDisabledDimensions() as $dimensionDisabled) { + $dimensionsToIgnore[] = $dimensionDisabled; + } + + /** + * Adds the possibility to other plugins to ignore more dimensions + */ + Piwik::postEvent('CustomReports.addDimensionsToIgnore', array(&$dimensionsToIgnore)); + + $categoryList = CategoryList::get(); + + foreach ($dimensions as $dimension) { + $categoryId = $dimension->getCategoryId(); + $dimensionName = $dimension->getName(); + $table = $dimension->getDbTableName(); + $dimensionId = $dimension->getId(); + + if (!$table) { + // without table we cannot join it + continue; + } + + if (!$this->isTableJoinable($table)) { + // archiving this dimension would not work + continue; + } + + if (in_array($dimensionId, $dimensionsToIgnore)) { + continue; + } + + if (key_exists($dimensionId, $dimensionsToRename)) { + $dimensionName = $dimensionsToRename[$dimensionId]; + } + + if ($dimension->getColumnName() && $dimensionName) { + if (!isset($rows[$categoryId])) { + $category = $categoryList->getCategory($categoryId); + $orderId = 999; + if (!empty($category)) { + $orderId = $category->getOrder(); + } + + $categoryName = Piwik::translate($categoryId); + if (!is_null($category) && method_exists($category, 'getDisplayName')) { + $categoryName = $category->getDisplayName(); + } + + $rows[$categoryId] = array( + 'category' => $categoryName, + 'dimensions' => array(), + 'orderId' => $orderId + ); + } + $rows[$categoryId]['dimensions'][] = array( + 'uniqueId' => $dimension->getId(), + 'name' => ucwords($dimensionName), + 'sqlSegment' => $dimension->getSqlSegment(), + ); + } + } + + usort($rows, function ($rowA, $rowB) { + if ((int)$rowA['orderId'] > (int)$rowB['orderId']) { + return 1; + } + if ((int)$rowA['orderId'] === (int)$rowB['orderId']) { + return 0; + } + return -1; + }); + + foreach ($rows as $categoryId => $row) { + $dimensions = $row['dimensions']; + usort($dimensions, function ($dimA, $dimB) { + return strcmp($dimA['name'], $dimB['name']); + }); + $rows[$categoryId]['dimensions'] = $dimensions; + } + + return array_values($rows); + } + + /** + * Returns metrics that can be used in custom reports. + * + * @param int|string $idSite The website ID used to resolve available metrics. + * @return array Available metrics grouped by category. + */ + public function getAvailableMetrics($idSite) + { + Piwik::checkUserIsNotAnonymous(); + Piwik::checkUserHasSomeViewAccess(); + + $metrics = MetricsList::get(); + $categoryList = CategoryList::get(); + + $rows = array(); + $period = Common::getRequestVar('period', '', 'string'); + foreach ($metrics->getMetrics() as $metric) { + if (!$metric) { + continue; + } + if ($metric instanceof ProcessedMetric && !$this->canGenerateMetricAutomatically($metric)) { + // we do not have all the dependent metrics to generate this processed metric automatically + continue; + } + + $categoryId = $metric->getCategoryId(); + $name = $metric->getName(); + $translatedName = $metric->getTranslatedName(); + + if (($metric instanceof ProcessedMetric || $metric instanceof ArchivedMetric) && $name && $translatedName) { + if (method_exists($metric, 'getQuery') && !$metric->getQuery()) { + // archiving this metric would not work! + continue; + } + + if (method_exists($metric, 'getDbTableName') && $metric->getDbTableName() && !$this->isTableJoinable($metric->getDbTableName())) { + // archiving this metric would not work! + continue; + } + + if (method_exists($metric, 'getDimension') && $metric->getDimension()) { + $dimension = $metric->getDimension(); + $dbDiscriminator = $dimension->getDbDiscriminator(); + if ($dbDiscriminator) { + $dbDiscriminatorValue = $dbDiscriminator->getValue(); + if (!isset($dbDiscriminatorValue) || !is_numeric($dbDiscriminatorValue)) { + continue; + } + } + } + + if (!isset($rows[$categoryId])) { + $category = $categoryList->getCategory($categoryId); + $orderId = 999; + if (!empty($category)) { + $orderId = $category->getOrder(); + } + + $categoryName = Piwik::translate($categoryId); + if (!is_null($category) && method_exists($category, 'getDisplayName')) { + $categoryName = $category->getDisplayName(); + } + + $rows[$categoryId] = array( + 'category' => $categoryName, + 'metrics' => array(), + 'orderId' => $orderId + ); + } + + $description = $metric->getDocumentation(); + if (stripos($translatedName, 'unique') === 0) { + $description = Piwik::translate('CustomReports_CommonUniqueMetricDescription', array($description, ucwords($translatedName), str_replace('Unique ', '', ucwords($translatedName)))); + } + + $rows[$categoryId]['metrics'][] = array( + 'uniqueId' => $name, 'name' => ucwords($translatedName), 'description' => $description + ); + } + } + + usort($rows, function ($rowA, $rowB) { + if ((int)$rowA['orderId'] > (int)$rowB['orderId']) { + return 1; + } + if ((int)$rowA['orderId'] === (int)$rowB['orderId']) { + return 0; + } + return -1; + }); + + foreach ($rows as $category => $row) { + $dimensions = $row['metrics']; + usort($dimensions, function ($dimA, $dimB) { + return strcasecmp($dimA['name'], $dimB['name']); + }); + $rows[$category]['metrics'] = $dimensions; + } + + return array_values($rows); + } + + private function canGenerateMetricAutomatically(ProcessedMetric $metric) + { + foreach ($metric->getDependentMetrics() as $dependentMetric) { + $depMetric = $this->metricsList->getMetric($dependentMetric); + if (!$depMetric) { + // we cannot generate this metric directly + return false; + } + + if ($depMetric instanceof ProcessedMetric && !$this->canGenerateMetricAutomatically($depMetric)) { + return false; + } + } + + return true; + } + + /** + * Returns archived data for a configured custom report. + * + * @param int|string $idSite The website ID 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 int $idCustomReport The ID of the custom report to look up data for. + * @param string|null|false $segment Custom segment to filter the report. + * Example: "referrerName==example.com" + * Supports AND (;) and OR (,) operators. + * @param bool $expanded Whether to expand recursive subtables in the response. + * @param bool $flat Whether to flatten the report rows into a single level. + * @param int|false $idSubtable The subtable ID to fetch instead of the root table. + * @param string|false $columns Comma-separated list of columns to include in the response. + * @return DataTable\DataTableInterface The archived data for the requested custom report. + */ + public function getCustomReport($idSite, $period, $date, $idCustomReport, $segment = false, $expanded = false, $flat = false, $idSubtable = false, $columns = false) + { + $this->validator->checkReportViewPermission($idSite); + $this->validator->checkSiteExists($idSite); // lets not return any reports from eg deleted sites if for some reason report still exists + $this->model->checkReportExists($idSite, $idCustomReport); + + $report = $this->model->getCustomReport($idSite, $idCustomReport); + + $reportType = ReportType::factory($report['report_type']); + + $table = $reportType->fetchApi($idSite, $idCustomReport, $period, $date, $segment, $expanded, $flat, $idSubtable, $columns); + + return $table; + } + + private function addAllowedToEditStatus(&$report) + { + $idSite = $report['idsite']; + $multipleIdSites = $report['multiple_idsites'] ? explode(',', $report['multiple_idsites']) : array(); + if (Piwik::hasUserSuperUserAccess()) { + $allowedToEdit = true; + } elseif ($idSite == 'all' || $idSite == '0') { + $allowedToEdit = false; + } else { + $allowedToEdit = $multipleIdSites ? Piwik::isUserHasWriteAccess($multipleIdSites) : Piwik::isUserHasWriteAccess($idSite); + } + + $report['allowedToEdit'] = $allowedToEdit; + } + + /** + * @param array $report + * @return int[] + */ + private function getCurrentOwnerSites(array $report): array + { + $currentSites = !empty($report['multiple_idsites']) + ? explode(',', $report['multiple_idsites']) + : [$report['idsite']]; + + $currentSites = array_filter($currentSites, static function ($site) { + return $site !== '' && $site !== null && $site !== false; + }); + + return array_values(array_map('intval', $currentSites)); + } + + private function getCustomReportInfo($idSite, $idCustomReport, $action) + { + $report = $this->model->getCustomReport($idSite, $idCustomReport); + + if (empty($report)) { + throw new \Exception(Piwik::translate('CustomReports_ErrorCustomReportDoesNotExist')); + } elseif ($report['idsite'] != $idSite) { + // prevent a possible hack that someone passes a different site than the report has and then we accidentally + // still delete the report because we match with `idsite = 0 or idsite = ?`. We don't do this here right now + // and wouldn't need this code but it is to prevent any possible future security bugs. + throw new \Exception(Piwik::translate('CustomReports_' . $action . 'ExceptionMessage')); + } + + return $report; + } +} diff --git a/files/plugin-CustomReports-5.5.0/Activity/BaseActivity.php b/files/plugin-CustomReports-5.5.0/Activity/BaseActivity.php new file mode 100644 index 0000000..75a03a6 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Activity/BaseActivity.php @@ -0,0 +1,113 @@ + $this->getSiteData($idSite), + 'version' => 'v1', + 'report' => $this->getReportData($idSite, $idCustomReport), + ); + } + + private function getSiteData($idSite) + { + if ($idSite == 0) { + return array( + 'site_id' => 'all', + 'site_name' => 'all' + ); + } + + return array( + 'site_id' => $idSite, + 'site_name' => Site::getNameFor($idSite) + ); + } + + private function getReportData($idSite, $idCustomReport) + { + $report = $this->getDao()->getCustomReport($idSite, $idCustomReport); + + if (!empty($report['name'])) { + $reportName = $report['name']; + } else { + // report name might not be set when we are handling ReportDeleted activity + $reportName = 'ID: ' . (int) $idCustomReport; + } + + return array( + 'id' => $idCustomReport, + 'name' => $reportName + ); + } + + public function getPerformingUser($eventData = null) + { + $login = Piwik::getCurrentUserLogin(); + + if ($login === self::USER_ANONYMOUS || empty($login)) { + // anonymous cannot change a report, in this case the system changed it + return self::USER_SYSTEM; + } + + return $login; + } + + private function getDao() + { + return StaticContainer::get('Piwik\Plugins\CustomReports\Dao\CustomReportsDao'); + } +} diff --git a/files/plugin-CustomReports-5.5.0/Activity/ReportAdded.php b/files/plugin-CustomReports-5.5.0/Activity/ReportAdded.php new file mode 100644 index 0000000..59db137 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Activity/ReportAdded.php @@ -0,0 +1,41 @@ +formatActivityData($idSite, $idCustomReport); + } + + public function getTranslatedDescription($activityData, $performingUser) + { + $siteName = $this->getSiteNameFromActivityData($activityData); + $reportName = $this->getReportNameFromActivityData($activityData); + + return Piwik::translate('CustomReports_ActivityReportAddedDescription', [$reportName, $siteName]); + } +} diff --git a/files/plugin-CustomReports-5.5.0/Activity/ReportDeleted.php b/files/plugin-CustomReports-5.5.0/Activity/ReportDeleted.php new file mode 100644 index 0000000..3a145af --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Activity/ReportDeleted.php @@ -0,0 +1,44 @@ +formatActivityData($idSite, $idCustomReport); + } + + public function getTranslatedDescription($activityData, $performingUser) + { + $siteName = $this->getSiteNameFromActivityData($activityData); + $reportName = $this->getReportNameFromActivityData($activityData); + + return Piwik::translate('CustomReports_ActivityReportDeletedDescription', [$reportName, $siteName]); + } +} diff --git a/files/plugin-CustomReports-5.5.0/Activity/ReportPaused.php b/files/plugin-CustomReports-5.5.0/Activity/ReportPaused.php new file mode 100644 index 0000000..3658592 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Activity/ReportPaused.php @@ -0,0 +1,42 @@ +formatActivityData($idSite, $idCustomReport); + } + + public function getTranslatedDescription($activityData, $performingUser) + { + $siteName = $this->getSiteNameFromActivityData($activityData); + $reportName = $this->getReportNameFromActivityData($activityData); + + return Piwik::translate('CustomReports_ActivityReportPausedDescription', [$reportName, $siteName]); + } +} diff --git a/files/plugin-CustomReports-5.5.0/Activity/ReportResumed.php b/files/plugin-CustomReports-5.5.0/Activity/ReportResumed.php new file mode 100644 index 0000000..b883659 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Activity/ReportResumed.php @@ -0,0 +1,42 @@ +formatActivityData($idSite, $idCustomReport); + } + + public function getTranslatedDescription($activityData, $performingUser) + { + $siteName = $this->getSiteNameFromActivityData($activityData); + $reportName = $this->getReportNameFromActivityData($activityData); + + return Piwik::translate('CustomReports_ActivityReportResumedDescription', [$reportName, $siteName]); + } +} diff --git a/files/plugin-CustomReports-5.5.0/Activity/ReportUpdated.php b/files/plugin-CustomReports-5.5.0/Activity/ReportUpdated.php new file mode 100644 index 0000000..77b6b8b --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Activity/ReportUpdated.php @@ -0,0 +1,42 @@ +formatActivityData($idSite, $idCustomReport); + } + + public function getTranslatedDescription($activityData, $performingUser) + { + $siteName = $this->getSiteNameFromActivityData($activityData); + $reportName = $this->getReportNameFromActivityData($activityData); + + return Piwik::translate('CustomReports_ActivityReportUpdatedDescription', [$reportName, $siteName]); + } +} diff --git a/files/plugin-CustomReports-5.5.0/Archiver.php b/files/plugin-CustomReports-5.5.0/Archiver.php new file mode 100644 index 0000000..1db375c --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Archiver.php @@ -0,0 +1,39 @@ +dimensions = array_values($dimensions); + $this->metrics = $metrics; + } + + /** + * @return Dimension[][][] + */ + public function getDimensionsPlan() + { + $numDimensions = count($this->dimensions); + + $plan = array(); + + for ($i = 0; $i < $numDimensions; $i++) { + $dimension = $this->dimensions[$i]; + + $currentTable = $dimension->getDbTableName(); + + $group = array('left' => array(), 'right' => array()); + + if ($i >= 1) { + for ($j = 0; $j < $i; $j++) { + $group['left'][] = $this->dimensions[$j]; + } + } + + $group['left'][] = $dimension; + + // No need for right join if there are 2 dimensions on left, as adding the right join can cause some data to be filtered out and there we be no data for second dimension, #PG-3138 + if (isset($this->dimensions[$i + 1]) && count($group['left']) !== 2) { + $group['right'][] = $this->dimensions[$i + 1]; + } + + $plan[] = $group; + } + + if (empty($plan)) { + // for evolution graph... we need to make sure to still iterate at least over one "fake group" + $plan[] = array('left' => array(), 'right' => array()); + } + + return $plan; + } + + /** + * @return ArchivedMetric[][] + */ + public function getMetricsPlanForGroup() + { + $metricsToResolve = $this->metrics; + + $plan = array(); + + foreach ($metricsToResolve as $index => $metric) { + if (!$metric->getDimension()) { + continue; + } + + $discriminator = $metric->getDimension()->getDbDiscriminator(); + // create a separate run for each metric that has a discriminator + if (!empty($discriminator)) { + $id = $metric->getDbTableName() . '_' . $discriminator->getTable() . '_' . $discriminator->getColumn() . '_' . $discriminator->getValue(); + if (!isset($plan[$id])) { + // improve performance by grouping metrics with the very same discriminator together!! + $plan[$id] = array(); + } + $plan[$id][] = $metric; + unset($metricsToResolve[$index]); + } else { + if (!isset($plan[$metric->getDbTableName()])) { + $plan[$metric->getDbTableName()] = array(); + } + + // we try to put all metrics that go on same table in one run + $plan[$metric->getDbTableName()][] = $metric; + } + } + + return $plan; + } +} diff --git a/files/plugin-CustomReports-5.5.0/Archiver/NotJoinableException.php b/files/plugin-CustomReports-5.5.0/Archiver/NotJoinableException.php new file mode 100644 index 0000000..bbe7b9f --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Archiver/NotJoinableException.php @@ -0,0 +1,21 @@ +reportQuery = StaticContainer::getContainer()->make('Piwik\Plugins\CustomReports\Archiver\ReportQuery'); + $this->configuration = $configuration; + + $this->rankingQuery = new RankingQuery(50000); + if (version_compare(Version::VERSION, '3.12.0-b2', '<')) { + $this->rankingQuery->setOthersLabel(DataTable::LABEL_SUMMARY_ROW); + } + + $this->logTablesProvider = StaticContainer::get('Piwik\Plugin\LogTablesProvider'); + + $this->logAggregator = $logAggregator; + $this->parameters = $parameters; + Piwik::postEvent('Plugin.shouldLowerCampaignCase', [$pluginName = 'CustomReports', &$this->shouldLowerCampaignCase]); + } + + public function isValid() + { + $selects = $this->reportQuery->getSelect(); + return !empty($selects) && $this->hasMetric; + } + + public function addDimension($dimension, $useRightJoin = false) + { + if (!$dimension instanceof Dimension) { + return; + } + + $join = $dimension->getDbColumnJoin(); + $dbTable = $dimension->getDbTableName(); + $dbColumn = $dimension->getColumnName(); + + $dbDiscriminator = $dimension->getDbDiscriminator(); + + // if other ways to join the table are defined (and it's joinable to visit or action) + // ensure to add log_visit/log_action first, otherwise it might fail when a custom table is added first + $logTable = $this->logTablesProvider->getLogTable($dbTable); + $tables = new JoinTables($this->logTablesProvider, [$dbTable]); + + if ( + !$logTable->getColumnToJoinOnIdVisit() && !$logTable->getColumnToJoinOnIdAction() && + !$logTable->getLinkTableToBeAbleToJoinOnVisit() && $logTable->getWaysToJoinToOtherLogTables() + ) { + if ($tables->isTableJoinableOnVisit($dbTable)) { + $this->reportQuery->addFrom('log_visit'); + } + if ($tables->isTableJoinableOnAction($dbTable)) { + $this->reportQuery->addFrom('log_action'); + } + } + + // Special handling for ActionType dimension + if ($dimension instanceof ActionType) { + if (!$this->reportQuery->hasFrom('log_link_visit_action')) { + $this->reportQuery->addFrom('log_link_visit_action'); + } + if (!$this->reportQuery->hasFrom('log_visit')) { + $this->reportQuery->addFrom('log_visit'); + } + } + + if ($useRightJoin) { + $tableArray = [ + 'table' => $dbTable, + 'join' => 'RIGHT JOIN', + ]; + $this->reportQuery->addFrom($tableArray); + } else { + $this->rankingQuery->addLabelColumn($dimension->getId()); + $this->reportQuery->addFrom($dbTable); + } + + if ($dbTable && $dbColumn) { + if (!empty($join)) { + $tableAlias = $join->getTable() . '_' . $dbColumn; + + + $this->reportQuery->addFrom([ + 'table' => $join->getTable(), + 'tableAlias' => $tableAlias, + 'joinOn' => $dbTable . '.' . $dbColumn . ' = ' . $tableAlias . '.' . $join->getColumn() . + $this->addExtraJoinConditions($join, $tableAlias) + ]); + + if (!$useRightJoin) { + $tableColumn = $tableAlias . '.' . $join->getTargetColumn(); + $this->reportQuery->addSelect($tableColumn . " AS '" . $dimension->getId() . "'"); + $this->reportQuery->addGroupBy($tableColumn); + + // prevent "XYZ not defined" + // we cannot do something like " and $tableColumn != ''" as it could break dimension like visitor type + $this->reportQuery->addWhere($tableColumn . ' is not null'); + } + + if ($dbDiscriminator && $dbDiscriminator->isValid()) { + // we make sure discriminator is valid and has only allowed values to prevent injections + + $dbDiscriminatorTable = $dbDiscriminator->getTable(); + + // table might be joined under an alias + $actualTableName = $this->reportQuery->getFromAlias($dbDiscriminatorTable); + + if ($dbDiscriminatorTable === $join->getTable()) { + $actualTableName = $tableAlias; // we need to make sure to apply the where condition on this joined table + } elseif (empty($actualTableName)) { + // not joined yet + $actualTableName = $dbDiscriminatorTable; + $this->reportQuery->addFrom($dbDiscriminatorTable); + } + + // we support for now only numeric values because we cannot use bind here as it would not be possible to position bind correctly + $this->reportQuery->addWhere(($actualTableName === $join->getTable() ? $tableAlias : $actualTableName) . '.' . $dbDiscriminator->getColumn() . ' = "' . $dbDiscriminator->getValue() . '"'); + } + } else { + if (!$useRightJoin) { + if ($dimension->getSegmentName() === 'regionCode') { + $this->reportQuery->addSelect("CONCAT(log_visit.location_region, '|', log_visit.location_country) AS '" . $dimension->getId() . "'"); + $this->reportQuery->addGroupBy($dimension->getSqlSegment()); + $this->reportQuery->addGroupBy('log_visit.location_country'); + } elseif ($dimension->getSegmentName() === 'operatingSystemVersion') { + $this->reportQuery->addSelect("CONCAT(log_visit.config_os_version, '|', log_visit.config_os) AS '" . $dimension->getId() . "'"); + $this->reportQuery->addGroupBy($dimension->getSqlSegment()); + $this->reportQuery->addGroupBy('log_visit.config_os'); + } else { + $select = $dimension->getSqlSegment(); + if ($this->shouldLowerCampaignCase && stripos($dimension->getSegmentName(), 'campaign') === 0) { + $select = 'LOWER(' . $select . ')'; + } + $this->reportQuery->addSelect($select . " AS '" . $dimension->getId() . "'"); + $this->reportQuery->addGroupBy($dimension->getSqlSegment()); + } + } + + if ($dbDiscriminator) { + $dbDiscriminatorTable = $dbDiscriminator->getTable(); + + if ($dbDiscriminator->isValid()) { + // we make sure discriminator is valid and has only allowed values to prevent injections + + // table might be joined under an alias + $actualTableName = $this->reportQuery->getFromAlias($dbDiscriminatorTable); + if (empty($actualTableName)) { + // not joined yet + $actualTableName = $dbDiscriminatorTable; + $this->reportQuery->addFrom($dbDiscriminatorTable); + } + + // we support for now only numeric values because we cannot use bind here as it would not be possible to position bind correctly + $where = $actualTableName . '.' . $dbDiscriminator->getColumn() . ' = "' . $dbDiscriminator->getValue() . '"'; + + $this->reportQuery->addWhere($where); + } + } + + if (!$useRightJoin) { + $tableColumn = $dimension->getDbTableName() . '.' . $dimension->getColumnName(); + if ($tableColumn === $dimension->getSqlSegment() && !in_array($dimension->getSegmentName(), ['regionCode', 'operatingSystemVersion'])) { + // when the segment goes on the sql segment, we do not fetch any values with NULL otherwise we often see "XYZ is not defined" + // we cannot do something like " and $tableColumn != ''" as it could break dimension like visitor type + $this->reportQuery->addWhere($tableColumn . ' is not null'); + } + } + } + } + } + + private function addExtraJoinConditions(Join $join, $tableAlias) + { + if ($join->getTable() === 'goal') { + $idSites = $this->parameters->getIdSites(); + if (!empty($idSites)) { + $idSites = array_map('intval', $idSites); + $idSites = implode(',', $idSites); + return ' AND ' . $tableAlias . '.idsite IN (' . $idSites . ')'; + } + } + } + + public function addMetric($metric) + { + if (!$metric instanceof ArchivedMetric || !$metric->getDimension() || !$metric->getQuery()) { + return; + } + + $metricName = $metric->getName(); + $dimension = $metric->getDimension(); + + $tableName = $dimension->getDbTableName(); + $dbDiscriminator = $dimension->getDbDiscriminator(); + + $this->reportQuery->addFrom($tableName); + + if ($dbDiscriminator) { + // we need to add a join for this table to make sure to calc correct results when eg a goal metric or action metric is selected (to be able to measure different values for eg clicked urls vs page urls) + $join = $dimension->getDbColumnJoin(); + $dbDiscriminatorValue = $dbDiscriminator->getValue(); + $dbDiscriminatorTable = $dbDiscriminator->getTable(); + + if (!$dbDiscriminator->isValid()) { + // we make sure discriminator is valid and has only allowed values to prevent injections + Log::debug(sprintf('Ignored metric %s because dbDiscriminator does not valid value', $metricName)); + return; + } + + if ($join && $join->getTable() === $dbDiscriminatorTable) { + // we need to use that join only when a discriminator is actually on the joined table, otherwise ignore it as not needed + + $tableAlias = $join->getTable() . '_' . $metricName; + $dbColumn = $dimension->getColumnName(); + $this->reportQuery->addFrom([ + 'table' => $join->getTable(), + 'tableAlias' => $tableAlias, + 'joinOn' => $tableName . '.' . $dbColumn . ' = ' . $tableAlias . '.' . $join->getColumn() . $this->addExtraJoinConditions($join, $tableAlias) + ]); + + // we need to make sure that the query uses the newly joined table and the correct column... + $metricQuery = str_replace($dbColumn, $join->getTargetColumn(), $metric->getQuery()); + $select = str_replace($tableName . '.', $tableAlias . '.', $metricQuery) . " AS '" . $metricName . "'"; + $this->reportQuery->addSelect($select); + $where = $tableAlias . '.' . $dbDiscriminator->getColumn() . ' = "' . $dbDiscriminatorValue . '"'; + $this->reportQuery->addWhere($where); + } elseif ($tableName === $dbDiscriminatorTable || $this->reportQuery->hasFrom($dbDiscriminatorTable)) { + $actualTableName = $this->reportQuery->getFromAlias($dbDiscriminatorTable); + + // we support for now only numeric values because we cannot use bind here as it would not be possible to position bind correctly + $where = $actualTableName . '.' . $dbDiscriminator->getColumn() . ' = "' . $dbDiscriminatorValue . '"'; + $this->reportQuery->addWhere($where); + $this->reportQuery->addSelect($metric->getQuery() . " AS '" . $metricName . "'"); + } elseif ($this->reportQuery->isTableJoinable($dbDiscriminatorTable)) { + $actualTableName = $this->reportQuery->getFromAlias($dbDiscriminatorTable); + + if (empty($actualTableName)) { + $this->reportQuery->addFrom($dbDiscriminatorTable); + $actualTableName = $dbDiscriminatorTable; + } + + // we support for now only numeric values because we cannot use bind here as it would not be possible to position bind correctly + $where = $actualTableName . '.' . $dbDiscriminator->getColumn() . ' = "' . $dbDiscriminatorValue . '"'; + $this->reportQuery->addWhere($where); + $this->reportQuery->addSelect($metric->getQuery() . " AS '" . $metricName . "'"); + } else { + Log::debug(sprintf('Cannot select metric %s because not supported discriminator table?!?', $metricName)); + return; + } + } else { + $this->reportQuery->addSelect($metric->getQuery() . " AS '" . $metricName . "'"); + } + + $this->hasMetric = true; + $this->setMetricGroupBy($tableName, $tableName); + $this->rankingQuery->addColumn($metricName); + $this->reportQuery->setSortBy($metricName); + } + + private function setMetricGroupBy($tableName, $tableAlias) + { + if (empty($this->metricGroupBy)) { + $this->metricGroupBy = str_replace($tableName, $tableAlias, $this->reportQuery->getTableColumnId($tableName)); + } + } + + public function addSegmentFilter($segmentFilter, $idSite) + { + if (empty($segmentFilter)) { + return; + } + + $from = $this->reportQuery->getFrom(); + + $segment = new Segment($segmentFilter, [$idSite]); + $segmentExpression = $segment->getSegmentExpression(); + $segmentExpression->parseSubExpressionsIntoSqlExpressions($from); + $sql = $segmentExpression->getSql(); + + $this->reportQuery->setFrom($from); + $this->reportQuery->addExtraWhere($sql['where']); + $this->reportQuery->addExtraBind($sql['bind']); + } + + public function getOrderBy() + { + return $this->reportQuery->getSortBy(); + } + + public function getReportQuery() + { + return $this->reportQuery; + } + + private function getMaxExecutionTimeMySQLHint() + { + $maxExecutionTime = $this->configuration->getMaxExecutionTime(); + if (Piwik::getAction() == 'previewReport' && Piwik::getModule() == 'CustomReports') { + $maxExecutionTime = Config::getInstance()->General['live_query_max_execution_time']; + } + $maxExecutionTimeHint = ''; + if (is_numeric($maxExecutionTime) && $maxExecutionTime > 0) { + $timeInMs = $maxExecutionTime * 1000; + $timeInMs = (int) $timeInMs; + $maxExecutionTimeHint = ' /*+ MAX_EXECUTION_TIME(' . $timeInMs . ') */ '; + } + return $maxExecutionTimeHint; + } + + public function buildQuery() + { + // needed because we generate the where condition based on this + $this->reportQuery->addFrom('log_visit'); + $condition = $this->logAggregator->getWhereStatement('log_visit', 'visit_last_action_time'); + + // If joining other log tables then if possible add extra default binds to limit the query scope + // These may be removed by LogAggregator later on if deemed unnecessary + $joinConditions = []; + $joinBinds = 0; + $logTablesProvider = StaticContainer::get(LogTablesProvider::class); + foreach ($logTablesProvider->getAllLogTables() as $logTable) { + foreach ($this->reportQuery->getFrom() as $table) { + if (!is_array($table) || !array_key_exists('join', $table) || strtolower($table['join']) != 'right join') { + continue; + } + $tableName = (array_key_exists('tableAlias', $table) ? $table['tableAlias'] : $table['table']); + if ($tableName !== 'log_visit' && $tableName === $logTable->getName()) { + $dateTimeColumn = $logTable->getDateTimeColumn(); + if ($dateTimeColumn) { + $joinConditions[$tableName] = $this->logAggregator->getWhereStatement($tableName, $dateTimeColumn); + $joinBinds++; + } + } + } + } + + $select = $this->reportQuery->getSelect(); + $groupBy = $this->reportQuery->getGroupBy(); + $where = $this->reportQuery->getWhere(); + $from = $this->reportQuery->getFrom(); + $orderBy = $this->reportQuery->getSortBy(); + + foreach ($joinConditions as $joinCondition) { + $condition .= ' AND ' . $joinCondition; + } + + if (!empty($where)) { + $condition .= ' AND (' . $where . ') '; + } + + /** @var \Piwik\DataAccess\LogQueryBuilder $segmentQueryBuilder */ + $segmentQueryBuilder = StaticContainer::getContainer()->make('Piwik\DataAccess\LogQueryBuilder'); + + if ($this->metricGroupBy && !empty($groupBy)) { + // there is no groupBy eg for evolution reports + $segmentQueryBuilder->forceInnerGroupBySubselect($groupBy . ', ' . $this->metricGroupBy); + } + + $defaultBindCount = count($this->logAggregator->getGeneralQueryBindParams()); + + try { + $query = $this->logAggregator->generateQuery($select, $from, $condition, $groupBy, $orderBy); + } catch (\Exception $e) { + $segmentQueryBuilder->forceInnerGroupBySubselect(''); + throw $e; + } + + // Add extra default bind parameters for any right join conditions + $bindCount = $defaultBindCount; + $defaultBinds = $this->logAggregator->getGeneralQueryBindParams(); + for ($i = 0; $i < $joinBinds; $i++) { + $query['bind'] = array_merge($query['bind'], $defaultBinds); + $bindCount += $defaultBindCount; + } + + $segmentQueryBuilder->forceInnerGroupBySubselect(''); + + $select = 'SELECT'; + if (is_array($query) && 0 === strpos(trim($query['sql']), $select)) { + $query['sql'] = trim($query['sql']); + $query['sql'] = 'SELECT /* CustomReports */' . substr($query['sql'], strlen($select)); + } + + $bind = $this->reportQuery->getExtraBind(); + + if (!empty($bind)) { + // we need to add bind parameters from applied "report segment filter" at correct position right after the general + // query parameters but before any Matomo segment. + $newBind = $query['bind']; + array_splice($newBind, $bindCount, 0, $bind); + $bind = $newBind; + } else { + $bind = $query['bind']; + } + + if (!empty($this->rankingQuery->getLabelColumns())) { + // we only do this when there are dimensions, not for evolution graph queries + $query['sql'] = $this->rankingQuery->generateRankingQuery($query['sql']); + } + + $maxExecutionTimeHint = $this->getMaxExecutionTimeMySQLHint(); + if ($maxExecutionTimeHint) { + $query['sql'] = trim($query['sql']); + $pos = stripos($query['sql'], 'SELECT'); + if ($pos !== false) { + $query['sql'] = substr_replace($query['sql'], 'SELECT ' . $maxExecutionTimeHint, $pos, strlen('SELECT')); + } + } + + return [ + 'sql' => $query['sql'], + 'bind' => $bind + ]; + } +} diff --git a/files/plugin-CustomReports-5.5.0/Archiver/ReportQuery.php b/files/plugin-CustomReports-5.5.0/Archiver/ReportQuery.php new file mode 100644 index 0000000..e9e48f1 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Archiver/ReportQuery.php @@ -0,0 +1,284 @@ +logTablesProvider = $logTablesProvider; + } + + public function addSelect($select) + { + $this->selects[] = $select; + } + + public function hasFrom($table) + { + $has = in_array($table, $this->from, $strict = true); + + if ($has) { + return true; + } + + foreach ($this->from as $from) { + if (is_array($from)) { + if (!isset($from['tableAlias']) && $from['table'] === $table) { + return true; + } elseif (isset($from['tableAlias']) && $from['tableAlias'] === $table) { + return true; + } + } + } + + if (is_array($table) && isset($table['table']) && !isset($table['tableAlias'])) { + return $this->hasFrom($table['table']); + } + + if (is_array($table) && isset($table['table']) && isset($table['tableAlias'])) { + return $this->hasFrom($table['tableAlias']); + } + + return false; + } + + public function getFromAlias($table) + { + if (is_array($table) && isset($table['table']) && !isset($table['tableAlias'])) { + // todo... we might only be able to re-use that table if there is no custom "joinOn"... to be tested at some point + return $this->getFromAlias($table['table']); + } + + if (is_array($table) && isset($table['table']) && isset($table['tableAlias'])) { + // todo... we might only be able to re-use that table if there is no custom "joinOn"... to be tested at some point + return $this->getFromAlias($table['tableAlias']); + } + + if (is_string($table) && in_array($table, $this->from, $strict = true)) { + return $table; + } + + foreach ($this->from as $from) { + if (is_array($from)) { + if (!isset($from['tableAlias']) && isset($from['table']) && $from['table'] === $table) { + return $table; + } elseif (isset($from['tableAlias']) && ($from['tableAlias'] === $table || ($from['table'] === $table && !isset($from['joinOn'])))) { + return $from['tableAlias']; + } + } + } + } + + public function addFrom($table) + { + if (!$this->hasFrom($table)) { + if (is_string($table) && !$this->isTableJoinable($table)) { + throw new NotJoinableException("Table $table cannot be joined"); + } + + if (is_array($table) && isset($table['table']) && !isset($table['tableAlias']) && !isset($table['joinOn']) && !$this->isTableJoinable($table['table'])) { + throw new NotJoinableException("Table " . $table['table'] . " cannot be joined"); + } + + // if this table is being *explicitly joined* (ie, is an array instead of just a string table name), core + // Matomo code will not automatically take into account dependent tables when determining join ordering. + // in this case we have to make sure that information is present ourselves. + if (is_array($table) && empty($table['joinOn'])) { + $tableName = $table['table']; + $logTable = $this->logTablesProvider->getLogTable($tableName); + + if ($logTable && !$logTable->getColumnToJoinOnIdVisit()) { + // if there is a preferred "link" table specified, use that one. otherwise we look for the + // first table that is joinable (directly or indirectly) to log_visit. + $preferTable = $logTable->getLinkTableToBeAbleToJoinOnVisit(); + + // if this is not directly joinable to log_visit, look for another way to join it to log_visit + $tables = new JoinTables($this->logTablesProvider, [$tableName]); + foreach ($logTable->getWaysToJoinToOtherLogTables() as $otherLogTable => $column) { + if ( + $preferTable == $otherLogTable + || (!$preferTable && $tables->isTableJoinableOnVisit($otherLogTable)) + ) { + $this->addFrom($otherLogTable); // make sure the other table is present in the from list + break; + } + } + } + } + + $this->from[] = $table; + } + } + + public function addWhere($where) + { + if (!in_array($where, $this->where, $strict = true)) { + // make sure we do not add it twice, happens eg if two metrics have same discriminator + $this->where[] = $where; + } + } + + public function addGroupBy($groupBy) + { + if (!in_array($groupBy, $this->groupBy)) { + $this->groupBy[] = $groupBy; + } + } + + public function setSortBy($sortBy) + { + if (empty($this->sortBy)) { + $this->sortBy = $sortBy; + } + } + + public function addExtraWhere($where) + { + if (empty($this->extraWhere)) { + $this->extraWhere = $where; + } else { + $this->extraWhere = sprintf("( %s ) AND (%s)", $this->extraWhere, $where); + } + } + + public function getExtraBind() + { + return $this->extraBind; + } + + public function addExtraBind($bind) + { + if (empty($this->extraBind)) { + $this->extraBind = $bind; + } else { + foreach ($bind as $val) { + $this->extraBind[] = $val; + } + } + } + + public function getTableColumnId($tableName) + { + $logTable = $this->logTablesProvider->getLogTable($tableName); + + $primaryKey = $logTable->getPrimaryKey(); + + if (count($primaryKey) === 1) { + return '`' . $tableName . '`.`' . array_shift($primaryKey) . '`'; + } + + $glue = "`, '_', `" . $tableName . '`.`'; + return sprintf("CONCAT(`%s`.`%s`)", $tableName, implode($glue, $primaryKey)); + } + + public function isTableJoinable($tableName) + { + try { + $logTable = $this->logTablesProvider->getLogTable($tableName); + $tables = new JoinTables($this->logTablesProvider, [$tableName]); + if ($logTable && ($tables->isTableJoinableOnAction($tableName) || $tables->isTableJoinableOnVisit($tableName))) { + if ($logTable->getPrimaryKey()) { + // without primary key we would not group the data correctly + return true; + } + } + } catch (\Exception $e) { + // intentionally left blank + } + + return false; + } + + public function getSelect() + { + return implode(', ', $this->selects); + } + + public function getFrom() + { + return $this->from; + } + + public function setFrom($from) + { + $this->from = $from; + } + + public function getGroupBy() + { + if (empty($this->groupBy)) { + return false; + } + + return implode(', ', $this->groupBy); + } + + public function getWhere() + { + $where = implode(' AND ', $this->where); + + if (!empty($this->extraWhere) && !empty($where)) { + $where = sprintf("( %s ) AND (%s)", $where, $this->extraWhere); + } elseif (!empty($this->extraWhere)) { + $where = $this->extraWhere; + } + + return $where; + } + + /** + * @return false|string + */ + public function getSortBy() + { + return $this->sortBy; + } +} diff --git a/files/plugin-CustomReports-5.5.0/CHANGELOG.md b/files/plugin-CustomReports-5.5.0/CHANGELOG.md new file mode 100644 index 0000000..86516cb --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/CHANGELOG.md @@ -0,0 +1,360 @@ +## Changelog + +5.5.0 - 2026-08-03 +- Added new "KPI card" report type showing a single metric with a sparkline and a comparison to the previous period +- Added code to allow using country and continent dimensions together + +5.4.14 - 2026-07-21 +- Updated Marketplace description +- Updated check for existing mapped idSites scope before updating +- Normalized website IDs for reports mapped to multiple sites + +5.4.13 - 2026-06-29 +- Added code to check existing mapped idSites scope before updating +- Fixed dark mode colours for metric and dimension dropdowns in the report editor + +5.4.12 - 2026-05-19 +- Updated custom report creation inline helptexts +- Improved visual consistency of creation form layout +- Fixed dark mode incompability with older Matomo versions + +5.4.11 - 2026-05-11 +- Updated custom report ordering to use a Vue draggable list +- Added code to show current site when editing and creating custom reports + +5.4.10 - 2026-04-27 +- Updated API documentation + +5.4.9 - 2026-01-05 +- Preview screenshot updated for higher quality + +5.4.8 - 2025-12-08 +- Fixed CHANGELOG.md versioning +- Preview screenshot updated to show higher dimensions + +5.4.7 - 2025-11-24 +- README.md updated + +5.4.6 - 2025-11-10 +- Added operating system version dimension + +5.4.5 - 2025-10-13 +- Added triggering of event when reports are copied + +5.4.4 - 2025-09-15 +- Added ability to copy a custom report when Matomo 5.4.0-b4 or later is installed +- Fixed bug when creating an evolution report which prevented historical archiving of weekly/monthly/yearly data + +5.4.3 - 2025-07-21 +- Stopped filtering empty values for regionCode dimension + +5.4.2 - 2025-06-09 +- Added custom_reports_max_dimensions config setting if not present in config.ini.php + +5.4.1 - 2025-05-15 +- Fixed array reference error + +5.4.0 - 2025-05-12 +- Added the ability to use more than 3 dimensions +- No longer show Insights visualization option when it's not available + +5.3.4 - 2025-04-15 +- Made preview report timeframes as a config value + +5.3.3 - 2025-03-17 +- Improved preview report performance to limit by timeframe + +5.3.2 - 2025-03-03 +- Added new `getNthLevelTableDimension` method to support new Matomo core changes +- Added query validation check before add, update or preview of report + +5.3.1 - 2025-02-17 +- Improved paused state message + +5.3.0 - 2025-02-03 +- Added code to pause/resume a custom report + +5.2.3 - 2025-01-20 +- Fixes add and update report not working when rearchive_reports_in_past_last_n_months & custom_reports_rearchive_reports_in_past_last_n_months config is set as 0 + +5.2.2 - 2025-01-15 +- Lower campaign values based on a event to honor keep campaign parameter capitalisation setting + +5.2.1 - 2025-01-06 +- Added code to allow admins to add/edit multiple websites if access to all websites in a report + +5.2.0 - 2024-12-09 +- Added option to assign multiple idSites to a single report +- Added action type dimension +- Updated region dimension query to group by country and show region name instead of region code + +5.1.1 - 2024-12-02 +- Added region and action type dimension + +5.1.0 - 2024-11-18 +- Added an option to sort subcategory reports + +5.0.19 - 2024-11-05 +- Updated README.md + +5.0.18 - 2024-10-21 +- Added config for setting custom reports historical archiving period + +5.0.17 - 2024-09-26 +- Added some new metrics specifically for this plugin: + - Average Product Quantity + - Average Product Price + - Product Revenue (Total & avg) + - Total Click Outlinks (Clicked Outlinks) + - Content Impressions + - Content Interactions + - Content Interaction Rate + - Product Category (Dimension) + +5.0.16 - 2024-08-26 +- Pricing updated + +5.0.15 +- Improved validation for dimensions being added to look for allowed dimensions + +5.0.14 +- Added cover image for marketplace + +5.0.13 +- Fixed error when changing number of rows in evolution report + +5.0.12 +- Added a fix to show 2nd dimension when 3rd dimension has no data + +5.0.11 +- Added missing translations for glossary + +5.0.10 +- Improved performance for reports that use dimensions accessed by a visit action via right join + +5.0.9 +- Updated README.md + +5.0.8 +- Fixed evolution graph refresh issue after metric selection + +5.0.7 +- Fixed regression in archiving when a new report is created + +5.0.6 +- Changes to hide the delete button when user doesn't have permission to delete report + +5.0.5 +- Fix archiving error due to labels not defined +- Added code to allow creating report with same name. + +5.0.4 +- Fixed error when no metric to sort available in getMetricToSortMultiPeriod() + +5.0.3 +- Fixed warnings during archive process + +5.0.2 +- Fixed undefined key notice during previewReport + +5.0.1 +- Compatibility with Matomo 5.0.0-b4 + +5.0.0 +- Compatibility with Matomo 5 + +4.1.7 +- Fixes regression from previous version where Insights wouldn't load + +4.1.6 +- Fixes issue where report view preferences do not persist + +4.1.5 +- Fixes for PHP 8.1 + +4.1.4 +- Fix report links not maintaining the selected date and/or segment + +4.1.3 +- Fix possible XSS if segment definition contains valid angularjs syntax + +4.1.2 +- Fixed Unsupported operand error for preview report. + +4.1.1 +- Fixed Unsupported operand error due to wrong dimension selection for subtable, #PG-1329 + +4.1.0 +- Migrate AngularJS code to Vue. + +4.0.15 +- Added check to limit live queries for previewReport action if live_query_max_execution_time is set + +4.0.14 +- Fixed sub data table warning +- Fixed code to show maximum of 3 metrics when type evolution graph for email report + +4.0.13 +- Changed dimension name to UTC time for Server Time dimensions + +4.0.12 +- Started supporting 3 dimensions in preview report + +4.0.11 +- Fixed namespace when previewing a report + +4.0.10 +- Started re-archiving only when reportType/dimensionIds/metricIds/segmentFilter are changed +- Show empty datatable for no new rows found to avoid fatal error +- Disabled segmented visitor log got download dimension + +4.0.9 +- Fixed preview report to work even if not metrics provided by other plugins. + +4.0.8 +- Updated README.md to highlight benefit of custom reports and mentioned simpler reports + +4.0.7 +- Add config to aggregate unique metrics instead of raw data for periods specified in config + +4.0.6 +- Fix archiving of unique metrics in evolution graphs for week, month and year periods + +4.0.5 +- Add category help text + +4.0.4 +- Tweak message when editing a custom report and browser archiving is disabled + +4.0.3 +- Add back the archive reports command + +4.0.2 +- Ability to rearchive reports on custom report creation/update + +4.0.1 +- Compatibility with Matomo 4.X + +4.0.0 +- Compatibility with Matomo 4.X + +3.1.27 +- Improve archive command + +3.1.26 +- List dimension IDs in glossary + +3.1.25 +- Add new config setting `custom_reports_disabled_dimensions` to disable dimensions + +3.1.24 +- Ignore new region dimension as it won't work + +3.1.23 +- Fix view link in manage custom reports may not work when report is configured for all websites +- Fix goalId archiving + +3.1.22 +- Fix archiver + +3.1.21 +- Add possibility to set max execution time + +3.1.20 +- Better segment filter check + +3.1.19 +- Apply segment filter in segmented visitor log +- Better support for Matomo 3.12 + +3.1.18 +- Sort aggregated reports before generating the report (week, month, year, range) +- Compatibility with Matomo 3.12 + +3.1.17 +- Add more options to archive command + +3.1.16 +- Support new segmentation in Matomo 3.12 + +3.1.15 +- Compatibility with Matomo 3.12 + +3.1.14 +- Show search box for entities +- Support usage of a reader DB when configured + +3.1.13 +- Enable more dimensions (visitorId, geolocation) + +3.1.12 +- Add more translations +- Make sure a report can be moved to its own page after it was assigned to another page + +3.1.11 +- Add Turkish translation +- Enable Order ID dimension + +3.1.10 +- Improve report generation for some combination of dimensions + +3.1.9 +- Fix report preview unter circumstances doesn't show column names when no report is configured yet + +3.1.8 +- Add config setting to always show unique visitors in all periods + +3.1.7 +- Improve handling of unique visitors and users + +3.1.6 +- Use correct category names +- Calculate unique visitors and users from raw data for periods != day if enabled in config in evolution graphs when only these metrics are used + +3.1.5 +- Support more languages +- Added command to archive reports in past + +3.1.4 +- Support new languages +- Use new brand colors +- Ensure segment definition is shown correctly + +3.1.3 +- Fix possible combination with event name and event value may not return a result + +3.1.2 +- Add dimensions and metrics information to glossary +- Support new "Write" role + +3.1.1 +- Make sure pie and bar graphs show available columns + +3.1.0 +- Support [Roll-Up Reporting](https://plugins.matomo.org/RollUpReporting). Create custom reports across multiple sites. + +3.0.6 +- Prevent possible fatal error when opening manage screen for all websites +- New config setting `custom_reports_validate_report_content_all_websites` which, when enabled under the `[CustomReports]` section, allows the creation of Custom Reports on "All websites", even those that contain "Custom dimensions" or other entities which may not be present on all websites. This is useful when you have many (or all) websites with the exact same dimensions Ids and/or Goals Ids across all websites. + + +3.0.5 +- Renamed Piwik to Matomo + +3.0.4 +- Prevent possible error when putting a custom report to another custom report page + +3.0.3 +- Prevent possible problems with custom dimensions in custom reports when also using roll-ups. + +3.0.2 +- Added German translation +- When generating report data and data needs to be truncated, make sure to sort the data by the first column of the report +- Make number of rows within a datatable configurable +- Make sure aggregated reports are truncated if needed + +3.0.1 +- Make sure custom reports category can be always selected when creating a new custom report + +3.0.0 +- Custom Reports for Piwik 3 diff --git a/files/plugin-CustomReports-5.5.0/Categories/CustomReportsCategory.php b/files/plugin-CustomReports-5.5.0/Categories/CustomReportsCategory.php new file mode 100644 index 0000000..434fbd7 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Categories/CustomReportsCategory.php @@ -0,0 +1,27 @@ +' . Piwik::translate('CustomReports_ManageReportsSubcategoryHelp') . '

'; + } +} diff --git a/files/plugin-CustomReports-5.5.0/Columns/CustomMetricHelper.php b/files/plugin-CustomReports-5.5.0/Columns/CustomMetricHelper.php new file mode 100644 index 0000000..df93df7 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Columns/CustomMetricHelper.php @@ -0,0 +1,112 @@ +addCustomMetricsToList($metrics); + + return $metrics; + } + + public function addCustomMetricsToList(MetricsList $metricsList) + { + if (Manager::getInstance()->isPluginActivated('Actions')) { + $dimensionMetricFactory = new DimensionMetricFactory(new \Piwik\Plugins\Actions\Columns\ClickedUrl()); + $metric1 = $dimensionMetricFactory->createMetric(ArchivedMetric::AGGREGATION_COUNT); + $metricsList->addMetric($metric1); + } + + if (Manager::getInstance()->isPluginActivated('Contents')) { + $contentInteractionDimension = new \Piwik\Plugins\Contents\Columns\ContentInteraction(); + $dimensionMetricFactory = new DimensionMetricFactory($contentInteractionDimension); + $metric2 = $dimensionMetricFactory->createMetric(ArchivedMetric::AGGREGATION_COUNT); + $metricsList->addMetric($metric2); + + // This is necessary to make sure that the result set isn't restricted to only rows with a value + // This also makes sure that the metric name is correct + $impressionDimension = new class () extends \Piwik\Plugins\Contents\Columns\ContentInteraction { + protected $nameSingular = 'Contents_ContentImpression'; + protected $namePlural = 'CustomReports_ContentImpressions'; + + public function getDbDiscriminator() + { + return null; + } + }; + $dimensionMetricFactory = new DimensionMetricFactory($impressionDimension); + $contentImporessionsTranslation = Piwik::translate('CustomReports_ContentImpressions'); + $metric3 = $dimensionMetricFactory->createCustomMetric('nb_content_impressions', $contentImporessionsTranslation, 'sum(case when %s IS NOT NULL then 0 else 1 end)'); + $metric3->setDocumentation(Piwik::translate('General_ComputedMetricCountDocumentation', $contentImporessionsTranslation)); + $metricsList->addMetric($metric3); + + $dimensionMetricFactory = new DimensionMetricFactory($contentInteractionDimension); + $metric4 = $dimensionMetricFactory->createComputedMetric($metric2->getName(), $metric3->getName(), ComputedMetric::AGGREGATION_RATE); + $metric4->setName($contentInteractionDimension->getMetricId() . '_' . ComputedMetric::AGGREGATION_RATE); + $metric4->setTranslatedName(Piwik::translate('General_ComputedMetricRate', $contentInteractionDimension->getName())); + $metricsList->addMetric($metric4); + } + + if (!Manager::getInstance()->isPluginActivated('Ecommerce')) { + return; + } + + $this->buildProductDimensionMetrics($metricsList, new \Piwik\Plugins\Ecommerce\Columns\ProductPrice(), 'General_Price'); + $this->buildProductDimensionMetrics($metricsList, new \Piwik\Plugins\Ecommerce\Columns\ProductQuantity(), 'General_Quantity'); + } + + protected function buildProductDimensionMetrics(MetricsList $metricsList, Dimension $dimension, string $baseProductSingular) + { + $dimensionMetricFactory = new DimensionMetricFactory($dimension); + + $metric1 = $dimensionMetricFactory->createMetric(ArchivedMetric::AGGREGATION_COUNT_WITH_NUMERIC_VALUE); + $metric1->setName('conversion_items_with_' . $dimension->getMetricId()); + $metric1->setTranslatedName(Piwik::translate('CustomReports_ProductsWithX', Piwik::translate($baseProductSingular))); + $metricsList->addMetric($metric1); + + $metric2 = $dimensionMetricFactory->createComputedMetric('sum_' . $dimension->getMetricId(), $metric1->getName(), ComputedMetric::AGGREGATION_AVG); + $metric2->setName(ComputedMetric::AGGREGATION_AVG . '_' . $dimension->getMetricId()); + $metric2->setTranslatedName(Piwik::translate('General_AverageX', $dimension->getName())); + $metricsList->addMetric($metric2); + + if ($dimension->getMetricId() === 'ecommerce_productprice') { + $productRevenueTranslation = Piwik::translate('General_ProductRevenue'); + $translatedName = Piwik::translate('General_ComputedMetricSum', $productRevenueTranslation); + $metric3 = $dimensionMetricFactory->createCustomMetric('sum_product_revenue', $translatedName, 'sum(case when %s > 0 then `price` * `quantity` else 0 end)'); + $metric3->setDocumentation(Piwik::translate('General_ComputedMetricSumDocumentation', $productRevenueTranslation)); + $metricsList->addMetric($metric3); + + $metric4 = $dimensionMetricFactory->createComputedMetric($metric3->getName(), 'conversion_items_with_ecommerce_productprice', ComputedMetric::AGGREGATION_AVG); + $metric4->setName('avg_product_revenue'); + $metric4->setTranslatedName(Piwik::translate('General_AverageX', $productRevenueTranslation)); + $productWithPriceTranslation = Piwik::translate('CustomReports_ProductsWithX', Piwik::translate('General_Price')); + $metric4->setDocumentation(Piwik::translate('General_ComputedMetricAverageDocumentation', [$productRevenueTranslation, $productWithPriceTranslation])); + $metricsList->addMetric($metric4); + } + } +} diff --git a/files/plugin-CustomReports-5.5.0/Columns/ProductCategory.php b/files/plugin-CustomReports-5.5.0/Columns/ProductCategory.php new file mode 100644 index 0000000..3ced25e --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Columns/ProductCategory.php @@ -0,0 +1,33 @@ +setName('customreports:archive'); + $this->setDescription('Let\'s you trigger custom reports archiving for given site and date range'); + $this->addRequiredValueOption('idsites', null, 'The ids of the sites you want to archive custom reports for', 'all'); + $this->addRequiredValueOption('date', null, 'The date or date range you want to archive custom reports for'); + $this->addRequiredValueOption('idreport', null, 'If set, only a specific report will be archived'); + $this->addNoValueOption('disable-segments', null, 'Disables archiving of pre-archived segments'); + $this->addRequiredValueOption('periods', null, 'Specify which periods should be archived. A comma separated list will archive multiple periods', 'all'); + $this->addRequiredValueOption( + 'segment', + null, + 'List of segments to invalidate report data for. This can be the segment string itself, the segment name from the UI or the ID of the segment.' + . ' If specifying the segment definition, make sure it is encoded properly (it should be the same as the segment parameter in the URL.', + null + ); + } + + public function isEnabled() + { + // Archives can't be overwritten in Matomo before 3.0.3 + return version_compare(Version::VERSION, '3.0.3', '>='); + } + + /** + * @return int + */ + protected function doExecute(): int + { + $input = $this->getInput(); + $output = $this->getOutput(); + $idSites = Site::getIdSitesFromIdSitesString($input->getOption('idsites')); + $date = $input->getOption('date'); + $idReport = $input->getOption('idreport'); + $segmentOption = $input->getOption('segment'); + if (!is_array($segmentOption) && !empty($segmentOption)) { + $segmentOption = explode(',', $segmentOption); + } + $segmentOptionValues = !$segmentOption ? [] : array_map('trim', $segmentOption); + $segmentOptionValues = array_unique($segmentOptionValues); + + Period::checkDateFormat($date); + + if (Period::isMultiplePeriod($date, 'day')) { + $period = Period\Factory::build('range', $date); + $datesToComplete = $this->getDaysFromPeriod($period); + } else { + $datesToComplete = [$date]; + } + + $periods = $this->getPeriodsToArchive($datesToComplete, $input->getOption('periods')); + + foreach ($idSites as $idSite) { + $siteName = Site::getNameFor($idSite); + $output->writeln('Starting to archive custom reports for Site ' . $siteName); + + // Check if the report even exists for the site + $reports = StaticContainer::get(CustomReportsModel::class)->getAllCustomReportsForSite($idSite); + $reportIds = count($reports) ? array_column($reports, 'idcustomreport') : []; + if (!empty($idReport) && !in_array($idReport, $reportIds)) { + $output->writeln("Report {$idReport} not found for Site {$siteName}"); + continue; + } + + if ($input->getOption('disable-segments')) { + $segments = ['']; // Only archive data without segments + } elseif (count($segmentOptionValues)) { + $segments = $this->getSegmentsToProcess([$idSite], $segmentOptionValues); // Only archive the specified segments + } else { + $segments = ArchiveProcessor\Rules::getSegmentsToProcess([$idSite]); + array_unshift($segments, ''); // Archive data without segment as well as all segments + } + + foreach ($segments as $segment) { + if ('' !== $segment) { + $output->writeln('Archiving segment ' . $segment); + } + + // Check if the segment is valid before progressing any further + try { + StaticContainer::getContainer()->make(Segment::class, ['segmentCondition' => $segment, 'idSites' => [$idSite]]); + } catch (\Exception $e) { + $errorMsg = $e->getMessage(); + if (strpos($errorMsg, 'is not a supported segment') !== false || preg_match('/The segment condition \'.*\' is not valid\./', $errorMsg)) { + // eg a plugin was uninstalled etc + $output->writeln('The segment is not supported. The associated plugin was likely disabled.'); + continue; + } + throw $e; + } + + $this->initProgressBar(count($periods)); + + $periodsNotArchived = []; + foreach ($periods as $period) { + if (!$this->archiveCustomReports($idSite, $period, $reports, $segment, $idReport)) { + $periodsNotArchived[] = $period instanceof Day ? $period->toString() : $period->getRangeString(); + } + $this->advanceProgressBar(); + } + + $this->finishProgressBar(); + $output->writeln(''); + + if (!empty($periodsNotArchived)) { + $output->writeln('Archiving has been skipped for following periods, as a full archiving has not yet been done: "' . implode('", "', $periodsNotArchived) . '"'); + } + } + } + + return self::SUCCESS; + } + + /** + * @param Period $period + * @return array + */ + protected function getDaysFromPeriod(Period $period) + { + $dates = []; + + if ($period instanceof Day) { + return [$period->getDateStart()->toString()]; + } + + $subperiods = $period->getSubperiods(); + + foreach ($subperiods as $subperiod) { + if ($subperiod instanceof Day) { + if ($subperiod->getDateStart()->isLater(Date::today())) { + continue; // discard days in the future + } + $dates[] = $subperiod->getDateStart()->toString(); + } else { + $dates = array_merge($dates, $this->getDaysFromPeriod($subperiod)); + } + } + + return $dates; + } + + /** + * @param array $dates + * @return Period[] + */ + protected function getPeriodsToArchive($dates, $periods) + { + $days = $weeks = $months = $years = []; + + sort($dates); + + if (empty($periods) || $periods === 'all') { + $periods = array('day', 'week', 'month', 'year'); + } else { + $periods = Common::mb_strtolower($periods); + $periods = explode(',', $periods); + } + + foreach ($dates as $date) { + $date = Date::factory($date); + if (in_array('day', $periods)) { + $day = new Day($date); + $days[$day->toString()] = $day; + } + if (in_array('week', $periods)) { + $week = new Period\Week($date); + $weeks[$week->getRangeString()] = $week; + } + if (in_array('month', $periods)) { + $month = new Period\Month($date); + $months[$month->getRangeString()] = $month; + } + if (in_array('year', $periods)) { + $year = new Period\Year($date); + $years[$year->getRangeString()] = $year; + } + } + + return $days + $weeks + $months + $years; + } + + /** + * Runs the Archiving for CustomReports plugin if an archive for the given period already exists + * + * @param int $idSite + * @param \Piwik\Period $period + * @return bool + * @throws \Piwik\Exception\UnexpectedWebsiteFoundException + */ + protected function archiveCustomReports($idSite, $period, array $reports, $segmentCondition = '', $idReport = null) + { + $_GET['idSite'] = $idSite; + + $parameters = new Parameters(new Site($idSite), $period, new Segment($segmentCondition, [$idSite])); + $parameters->setRequestedPlugin('CustomReports'); + + $result = ArchiveSelector::getArchiveIdAndVisits($parameters, $period->getDateStart()->getDateStartUTC(), false); + $idArchive = $result ? array_shift($result) : null; + + if (empty($idArchive)) { + return false; // ignore periods if full archiving hadn't run before + } + + if (is_array($idArchive)) { + // there might now be multiple archives + $idArchive = array_shift($idArchive); + } + + $archiveWriter = new ArchiveWriter($parameters); + $archiveWriter->idArchive = $idArchive; + + $logAggregator = new LogAggregator($parameters); + if (method_exists($logAggregator, 'allowUsageSegmentCache')) { + $logAggregator->allowUsageSegmentCache(); + } + $archiveProcessor = new ArchiveProcessor($parameters, $archiveWriter, $logAggregator); + + $archiveProcessor->setNumberOfVisits(1, 1); + + foreach ($reports as $report) { + if ($idReport && isset($report['idcustomreport']) && $report['idcustomreport'] != $idReport) { + continue; + } + + $recordBuilder = StaticContainer::getContainer()->make(CustomReport::class, ['report' => $report]); + + if ($period instanceof Day) { + $recordBuilder->buildFromLogs($archiveProcessor); + } else { + $recordBuilder->buildForNonDayPeriod($archiveProcessor); + } + + $recordBuilder->finalize($archiveProcessor); + + DataTableManager::getInstance()->deleteAll(); + } + + return true; + } + + /** + * @param array $idSites + * @param array $segmentOptionValues + * + * @return array + */ + protected function getSegmentsToProcess(array $idSites, array $segmentOptionValues): array + { + $result = []; + + foreach ($segmentOptionValues as $segmentOptionValue) { + $segmentDefinition = $this->findSegment($segmentOptionValue, $idSites); + + if (empty($segmentDefinition)) { + continue; + } + + $result[] = $segmentDefinition; + } + + return $result; + } + + /** + * Find a segment based on ID, name, or definition. + * + * This method was copied from plugins/CoreAdminHome/Commands/InvalidateReportData.php + * + * @param array $idSites + */ + private function findSegment(string $segmentOptionValue, array $idSites) + { + $logger = StaticContainer::get(LoggerInterface::class); + $allSegments = $this->getAllSegments($idSites); + + foreach ($allSegments as $segment) { + if ( + !empty($segment['enable_only_idsite']) + && !in_array($segment['enable_only_idsite'], $idSites) + ) { + continue; + } + + if ($segmentOptionValue == $segment['idsegment']) { + $logger->debug("Matching '$segmentOptionValue' by idsegment with segment {segment}.", ['segment' => json_encode($segment)]); + return $segment['definition']; + } + + if (strtolower($segmentOptionValue) == strtolower($segment['name'])) { + $logger->debug("Matching '$segmentOptionValue' by name with segment {segment}.", ['segment' => json_encode($segment)]); + return $segment['definition']; + } + + if ( + $segment['definition'] == $segmentOptionValue + || $segment['definition'] == urldecode($segmentOptionValue) + ) { + $logger->debug("Matching '{value}' by definition with segment {segment}.", ['value' => $segmentOptionValue, 'segment' => json_encode($segment)]); + return $segment['definition']; + } + } + + $logger->warning("'$segmentOptionValue' did not match any stored segment, but invalidating it anyway."); + return $segmentOptionValue; + } + + /** + * Get all the segments applicable to a specific collection of sites. + * + * This method was copied from plugins/CoreAdminHome/Commands/InvalidateReportData.php + * + * @param array $idSites + * + * @return array + */ + private function getAllSegments(array $idSites): array + { + $segmentsByDefinition = []; + + if ([] === $idSites) { + $idSites = [false]; + } + + foreach ($idSites as $idSite) { + // Check if we've looked up the segments for this site before making the API call. + $siteSegments = $this->segmentsBySite[$idSite] ?? []; + if (empty($siteSegments)) { + $siteSegments = API::getInstance()->getAll($idSite); + $this->segmentsBySite[$idSite] = $siteSegments; + } + + $siteSegmentsByDefinition = array_combine( + array_column($siteSegments, 'definition'), + $siteSegments + ); + + $segmentsByDefinition = array_merge( + $segmentsByDefinition, + $siteSegmentsByDefinition + ); + } + + return array_values($segmentsByDefinition); + } +} diff --git a/files/plugin-CustomReports-5.5.0/Commands/GenerateReports.php b/files/plugin-CustomReports-5.5.0/Commands/GenerateReports.php new file mode 100644 index 0000000..cc0c980 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Commands/GenerateReports.php @@ -0,0 +1,95 @@ +setName('customreports:generate-reports'); + $this->setDescription('GenerateReports'); + $this->addRequiredValueOption('idsite', null, 'The id of the site you want to add custom reports to', '1'); + } + + public function isEnabled() + { + return Development::isEnabled(); + } + + /** + * @return int + */ + protected function doExecute(): int + { + $input = $this->getInput(); + $idsite = $input->getOption('idsite'); + + if (empty($idsite) || !is_numeric($idsite)) { + throw new InvalidArgumentException('The idSite must be set and numeric'); + } + + $idsite = (int) $idsite; + + if (!$this->confirmChange($idsite)) { + $this->getOutput()->writeln('Not created any report'); + return self::FAILURE; + } + + $usedNames = []; + $dimensions = API::getInstance()->getAvailableDimensions(1); + foreach ($dimensions as $dimension) { + foreach ($dimension['dimensions'] as $dim) { + $name = $dim['name']; + if (in_array($name, $usedNames)) { + $name = $dimension['category'] . ' ' . $name; + } + $usedNames[] = $name; + + API::getInstance()->addCustomReport(1, $name, Table::ID, ['nb_uniq_visitors'], false, [$dim['uniqueId']]); + } + } + + $metrics = API::getInstance()->getAvailableMetrics(1); + $allMetr = array(); + foreach ($metrics as $metric) { + foreach ($metric['metrics'] as $met) { + $allMetr[] = $met['uniqueId']; + } + } + + API::getInstance()->addCustomReport(1, 'visit all metrics', Table::ID, $allMetr, false, ["DevicesDetection.BrowserName"]); + API::getInstance()->addCustomReport(1, 'action all metrics', Table::ID, $allMetr, false, ["Actions.PageUrl"]); + API::getInstance()->addCustomReport(1, 'media all metrics', Table::ID, $allMetr, false, ["MediaAnalytics.MediaLength"]); + API::getInstance()->addCustomReport(1, 'conversion all metrics', Table::ID, $allMetr, false, ["Goals.Revenue"]); + + return self::SUCCESS; + } + + private function confirmChange($idsite): bool + { + return $this->askForConfirmation( + 'Are you sure you want to generate lots of custom reports for idSite ' . $idsite . '? (y/N)', + false + ); + } +} diff --git a/files/plugin-CustomReports-5.5.0/Configuration.php b/files/plugin-CustomReports-5.5.0/Configuration.php new file mode 100644 index 0000000..c659060 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Configuration.php @@ -0,0 +1,361 @@ +getConfig(); + + $reports = $config->CustomReports; + if (empty($reports)) { + $reports = array(); + } + + // we make sure to set a value only if none has been configured yet, eg in common config. + if (empty($reports[self::KEY_VALIDATE_REPORT_CONTENT_ALL_WEBSITES])) { + $reports[self::KEY_VALIDATE_REPORT_CONTENT_ALL_WEBSITES] = self::DEFAULT_VALIDATE_REPORT_CONTENT_ALL_WEBSITES; + } + if (empty($reports[self::KEY_ALWAYS_SHOW_UNIQUE_VISITORS])) { + $reports[self::KEY_ALWAYS_SHOW_UNIQUE_VISITORS] = self::DEFAULT_ALWAYS_SHOW_UNIQUE_VISITORS; + } + if (empty($reports[self::KEY_MAX_EXECUTION_TIME])) { + $reports[self::KEY_MAX_EXECUTION_TIME] = self::DEFAULT_MAX_EXECUTION_TIME; + } + if (empty($reports[self::KEY_DISABLED_DIMENSIONS])) { + $reports[self::KEY_DISABLED_DIMENSIONS] = self::DEFAULT_DISABLED_DIMENSIONS; + } + if (empty($reports[self::KEY_EVOLUTION_UNIQUE_FORCE_AGGREGATION])) { + $reports[self::KEY_EVOLUTION_UNIQUE_FORCE_AGGREGATION] = self::DEFAULT_EVOLUTION_UNIQUE_FORCE_AGGREGATION; + } + if (empty($reports[self::KEY_MAX_DIMENSIONS])) { + $reports[self::KEY_MAX_DIMENSIONS] = self::DEFAULT_MAX_DIMENSIONS; + } + if (empty($reports[self::KEY_ARCHIVE_MAXIMUM_ROWS_CUSTOM_DIMENSIONS_DEFAULT])) { + $reports[self::KEY_ARCHIVE_MAXIMUM_ROWS_CUSTOM_DIMENSIONS_DEFAULT] = self::DEFAULT_ARCHIVE_MAXIMUM_ROWS_CUSTOM_DIMENSIONS_DEFAULT; + } + if (empty($reports[self::KEY_ARCHIVE_MAXIMUM_ROWS_SUB_TABLE_CUSTOM_DIMENSIONS_DEFAULT])) { + $reports[self::KEY_ARCHIVE_MAXIMUM_ROWS_SUB_TABLE_CUSTOM_DIMENSIONS_DEFAULT] = self::DEFAULT_ARCHIVE_MAXIMUM_ROWS_SUB_TABLE_CUSTOM_DIMENSIONS_DEFAULT; + } + for ($i = 1; $i <= $reports[self::KEY_MAX_DIMENSIONS]; $i++) { + if (empty($reports[self::KEY_ARCHIVE_MAXIMUM_ROWS_CUSTOM_DIMENSIONS . $i])) { + $reports[self::KEY_ARCHIVE_MAXIMUM_ROWS_CUSTOM_DIMENSIONS . $i] = $reports[self::KEY_ARCHIVE_MAXIMUM_ROWS_CUSTOM_DIMENSIONS_DEFAULT]; + $reports[self::KEY_ARCHIVE_MAXIMUM_ROWS_SUB_TABLE_CUSTOM_DIMENSIONS . $i] = $reports[self::KEY_ARCHIVE_MAXIMUM_ROWS_SUB_TABLE_CUSTOM_DIMENSIONS_DEFAULT]; + } + } + if (empty($reports[self::KEY_PREVIEW_REPORT_ITERATIONS])) { + $reports[self::KEY_PREVIEW_REPORT_ITERATIONS] = self::DEFAULT_PREVIEW_REPORT_ITERATIONS; + } + if (empty($reports[self::KEY_PREVIEW_REPORT_MIN_ROWS_REQUIRED])) { + $reports[self::KEY_PREVIEW_REPORT_MIN_ROWS_REQUIRED] = self::DEFAULT_PREVIEW_REPORT_MIN_ROWS_REQUIRED; + } + + $config->CustomReports = $reports; + + $config->forceSave(); + } + + public function uninstall() + { + $config = $this->getConfig(); + $config->CustomReports = array(); + $config->forceSave(); + } + + /** + * @return array + */ + public function getDisabledDimensions() + { + $value = $this->getConfigValue(self::KEY_DISABLED_DIMENSIONS, self::KEY_DISABLED_DIMENSIONS); + if (is_string($value)) { + $value = trim($value); + } + + if (empty($value) || !is_string($value)) { + return array(); + } + $values = explode(',', $value); + $values = array_map('trim', $values); + + return $values; + } + + /** + * @return int + */ + public function getMaxExecutionTime() + { + $value = $this->getConfigValue(self::KEY_MAX_EXECUTION_TIME, self::DEFAULT_MAX_EXECUTION_TIME); + + if ($value === false || $value === '' || $value === null || !is_numeric($value)) { + $value = self::DEFAULT_MAX_EXECUTION_TIME; + } + + return (int) $value; + } + + /** + * For some periods we may want to rather aggregate unique metrics from reports rather than raw data as it can be quite + * resource intensive to aggregate the data from raw data. It's not used in GetCustomReport::supportsUniqueMetric() + * because then the metric might disappear in the report vs in this case if it is mentioned here we just want to + * change how it is calculated. Can reduce load on DB quite a bit. + * @param string $periodLabel + * @return bool + */ + public function forceAggregateUniqueMetricsFromReportsInsteadOfRawDataInEvolutionReport($periodLabel) + { + $value = $this->getConfigValue(self::KEY_EVOLUTION_UNIQUE_FORCE_AGGREGATION, self::DEFAULT_EVOLUTION_UNIQUE_FORCE_AGGREGATION); + + if (empty($value)) { + return false; + } + + $periodsToSkip = explode(',', $value); + $periodsToSkip = array_map('trim', $periodsToSkip); + $periodsToSkip = array_map('strtolower', $periodsToSkip); + $periodLabel = strtolower($periodLabel); + + return in_array($periodLabel, $periodsToSkip); + } + + /** + * @param int $noOfDimensions + * @return int + */ + public function getArchiveMaxRowsInMainTable(?int $noOfDimensions = 3): int + { + $defaultValue = $this->getArchiveMaxRowsDefault(); + $value = $this->getConfigValue(self::KEY_ARCHIVE_MAXIMUM_ROWS_CUSTOM_DIMENSIONS . $noOfDimensions, $defaultValue); + + if ($value === false || $value === '' || $value === null) { + $value = $defaultValue; + } + + return (int) $value; + } + + /** + * @param int $noOfDimensions + * @return int + */ + public function getArchiveMaxRowsInSubTable(?int $noOfDimensions = 3): int + { + $defaultValue = $this->getArchiveMaxRowsSubTableDefault(); + $value = $this->getConfigValue(self::KEY_ARCHIVE_MAXIMUM_ROWS_SUB_TABLE_CUSTOM_DIMENSIONS . $noOfDimensions, $defaultValue); + + if ($value === false || $value === '' || $value === null) { + $value = $defaultValue; + } + + return (int) $value; + } + + /** + * @return int + */ + public function getArchiveMaxRowsDefault() + { + $value = $this->getConfigValue(self::KEY_ARCHIVE_MAXIMUM_ROWS_CUSTOM_DIMENSIONS_DEFAULT, self::DEFAULT_ARCHIVE_MAXIMUM_ROWS_CUSTOM_DIMENSIONS_DEFAULT); + + if ($value === false || $value === '' || $value === null) { + $value = self::DEFAULT_ARCHIVE_MAXIMUM_ROWS_CUSTOM_DIMENSIONS_DEFAULT; + } + + return (int) $value; + } + + /** + * @return int + */ + public function getArchiveMaxRowsSubTableDefault() + { + $value = $this->getConfigValue(self::KEY_ARCHIVE_MAXIMUM_ROWS_SUB_TABLE_CUSTOM_DIMENSIONS_DEFAULT, self::DEFAULT_ARCHIVE_MAXIMUM_ROWS_SUB_TABLE_CUSTOM_DIMENSIONS_DEFAULT); + + if ($value === false || $value === '' || $value === null) { + $value = self::DEFAULT_ARCHIVE_MAXIMUM_ROWS_SUB_TABLE_CUSTOM_DIMENSIONS_DEFAULT; + } + + return (int) $value; + } + + /** + * @return int + */ + public function shouldAlwaysShowUniqueVisitors() + { + $value = $this->getConfigValue(self::KEY_ALWAYS_SHOW_UNIQUE_VISITORS, self::DEFAULT_ALWAYS_SHOW_UNIQUE_VISITORS); + + if ($value === false || $value === '' || $value === null) { + $value = self::DEFAULT_ALWAYS_SHOW_UNIQUE_VISITORS; + } + + return !empty($value); + } + + /** + * @return int + */ + public function shouldValidateReportContentWhenAllSites() + { + $value = $this->getConfigValue(self::KEY_VALIDATE_REPORT_CONTENT_ALL_WEBSITES, self::DEFAULT_VALIDATE_REPORT_CONTENT_ALL_WEBSITES); + + if ($value === false || $value === '' || $value === null) { + $value = self::DEFAULT_VALIDATE_REPORT_CONTENT_ALL_WEBSITES; + } + + return (bool) $value; + } + + /** + * @return int|string|null + */ + public function getReArchiveReportsInPastLastNMonths() + { + $config = $this->getConfig(); + $reArchiveLastN = null; + if (isset($config->CustomReports[self::KEY_REARCHIVE_REPORTS_IN_PAST_LAST_N_MONTHS])) { + $reArchiveLastN = $config->CustomReports[self::KEY_REARCHIVE_REPORTS_IN_PAST_LAST_N_MONTHS]; + } elseif (isset($config->General['rearchive_reports_in_past_last_n_months'])) { + $reArchiveLastN = $config->General['rearchive_reports_in_past_last_n_months']; + } + + if (!is_null($reArchiveLastN) && !is_numeric($reArchiveLastN)) { + $reArchiveLastN = (int)str_replace('last', '', $reArchiveLastN); + } + + if ($reArchiveLastN < 0) { + $reArchiveLastN = null; + } + + return $reArchiveLastN; + } + + /** + * @return array + */ + public function getPreviewReportIterations() + { + $config = $this->getConfig(); + $iterations = !empty($config->CustomReports[self::KEY_PREVIEW_REPORT_ITERATIONS]) ? $config->CustomReports[self::KEY_PREVIEW_REPORT_ITERATIONS] : self::DEFAULT_PREVIEW_REPORT_ITERATIONS; + $minimumRowsRequired = $this->getPreviewReportMinRowsRequired(); + $iterationValues = explode(',', $iterations); + $allowedIterations = []; + $count = count($iterationValues); + $maxDateAllowed = new \DateTime('-1 day'); + + foreach ($iterationValues as $iterationKey => $iterationValue) { + $iterationValue = trim($iterationValue); + if ($count === ($iterationKey + 1)) { + $minimumRowsRequired = 0; + } + $dateTimeObject = new \DateTime($iterationValue); + + if (empty($iterationValue) || (stripos($iterationValue, 'second') === false && stripos($iterationValue, 'hour') === false && stripos($iterationValue, 'minute') === false && stripos($iterationValue, 'day') === false)) { + throw new \Exception(Piwik::translate('CustomReports_PreviewReportInvalidTimeFrameValues')); + } elseif ($dateTimeObject->getTimestamp() < $maxDateAllowed->getTimestamp()) { + throw new \Exception(Piwik::translate('CustomReports_PreviewReportExceedsMaximumTimeFrameValueAllowed')); + } + + $allowedIterations[] = ['startDate' => $iterationValue, 'minRowsRequired' => $minimumRowsRequired]; + } + + return $allowedIterations; + } + + /** + * @return int + */ + public function getPreviewReportMinRowsRequired() + { + $config = $this->getConfig(); + $minRows = !empty($config->CustomReports[self::KEY_PREVIEW_REPORT_MIN_ROWS_REQUIRED]) ? $config->CustomReports[self::KEY_PREVIEW_REPORT_MIN_ROWS_REQUIRED] : self::DEFAULT_PREVIEW_REPORT_MIN_ROWS_REQUIRED; + + if (empty($minRows) || !is_numeric($minRows) || $minRows < 1) { + $minRows = self::DEFAULT_PREVIEW_REPORT_MIN_ROWS_REQUIRED; + } + + return ((int) $minRows); + } + + /** + * If a valid int value is configured, that will be returned. If not, the setting default will be returned. + * + * @return int + */ + public function getMaxDimensions(): int + { + $config = $this->getConfig(); + if (!isset($config->CustomReports[self::KEY_MAX_DIMENSIONS])) { + return self::DEFAULT_MAX_DIMENSIONS; + } + + $maxDimensions = $config->CustomReports[self::KEY_MAX_DIMENSIONS]; + if ( + empty($maxDimensions) || intval($maxDimensions) < 1 + || (is_string($maxDimensions) && !ctype_digit($maxDimensions)) + ) { + return self::DEFAULT_MAX_DIMENSIONS; + } + + return intval($maxDimensions); + } + + private function getConfig() + { + return Config::getInstance(); + } + + private function getConfigValue($name, $default) + { + $config = $this->getConfig(); + $attribution = $config->CustomReports; + if (isset($attribution[$name])) { + return $attribution[$name]; + } + return $default; + } +} diff --git a/files/plugin-CustomReports-5.5.0/Controller.php b/files/plugin-CustomReports-5.5.0/Controller.php new file mode 100644 index 0000000..6577fe3 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Controller.php @@ -0,0 +1,457 @@ +validator = $validator; + + parent::__construct(); + } + + /** + * Manage custom reports in the admin area. + * @return string + */ + public function manage() + { + $idSite = Common::getRequestVar('idSite'); + + if (strtolower($idSite) === 'all') { + // prevent fatal error... redirect to a specific site as it is not possible to manage for all sites + $this->validator->checkHasSomeWritePermission(); + $this->redirectToIndex('CustomReports', 'manage'); + exit; + } + + $this->checkSitePermission(); + $this->validator->checkWritePermission($this->idSite); + + $configuration = StaticContainer::get(Configuration::class); + + return $this->renderTemplate('manage', [ + 'browserArchivingDisabled' => !ArchiveProcessor\Rules::isBrowserTriggerEnabled(), + 'reArchiveLastN' => $configuration->getReArchiveReportsInPastLastNMonths(), + 'maxDimensions' => $configuration->getMaxDimensions(), + 'isCloud' => \Piwik\Plugin\Manager::getInstance()->isPluginLoaded('Cloud'), + ]); + } + + /** + * Preview a custom reports report. Only supports table visualization so far. + * @return string + * @throws \Exception + */ + public function previewReport() + { + $this->checkSitePermission(); + $this->validator->checkWritePermission($this->idSite); + + $report = null; + Piwik::postEvent('CustomReports.buildPreviewReport', array(&$report)); + + /** @var array|null $report the event handler populates the report by reference */ + if (empty($report) || !is_array($report)) { + throw new \Exception(Piwik::translate('CustomReports_ErrorReportDoesNotExist')); + } + + $date = Common::getRequestVar('date', null, 'string'); + $period = defined('PIWIK_TEST_MODE') && PIWIK_TEST_MODE ? 'day' : 'range'; + $configuration = StaticContainer::get(Configuration::class); + try { + $iterations = $configuration->getPreviewReportIterations(); + } catch (\Exception $e) { + $message = $e->getMessage(); + $view = Factory::build(); + $view->setDataTable(new DataTable()); + $view->config->show_header_message = '
' . $message . '
'; + $view->config->no_data_message = ' '; + return $view->render(); + } + + try { + // only for UI tests + if (StaticContainer::get('test.vars.previewDate')) { + $date = StaticContainer::get('test.vars.previewDate'); + $period = 'day'; + } + } catch (\Exception $e) { + // ignore any possible error + } + // for now we only support day period as weekly or monthly doesn't really work and would take way too long + // so it can also not be misused to fetch not yet archived reports + + $site = new Site($this->idSite); + if ($period === 'day') { + $iterations = [ + ['startDate' => '-1 day', 'minRowsRequired' => 0], + ]; + } + + $startDateObj = null; + $endDateObj = null; + $dataTable = new DataTable(); + $periodObject = null; + $timezone = $site->getTimezone(); + + foreach ($iterations as $iteration) { + $periodObject = null; + if ($period === 'range') { + // hack for PeriodFactory::build('range'); to work, as it throws an error for same day, our archives are date based i.e 2024-02-01, sending 2 same dates will create an issue + // Since we are sending $startDateObj and $endDateObj, start and end date will be overwritten with the help of CustomLogAggregator for range period + $date = date('Y-m-d', strtotime('-1 day')) . ',' . date('Y-m-d'); + $startDateObj = new \DateTime($iteration['startDate'], new \DateTimeZone($timezone)); + $endDateObj = new \DateTime('now', new \DateTimeZone($timezone)); + } + try { + $dataTable = $this->getReportDataTable($report, $date, $site, $period, $periodObject, $startDateObj, $endDateObj); + } catch (\Exception $e) { + $message = $e->getMessage(); + if (stripos($message, 'be joined for segmentation') !== false) { + $faqLink = Url::addCampaignParametersToMatomoLink('https://matomo.org/faq/custom-reports/how-to-resolve-errors-when-using-different-dimension-combinations-in-matomo/'); + $message = Piwik::translate('CustomReports_NoSegmentationJoinExceptionMessage', ['', '']); + $type = ReportType::factory($report['report_type']); + $view = Factory::build($type->getDefaultViewDataTable(), 'CustomReports.getCustomReport', 'CustomReports.previewReport', true); + $view->setDataTable(new DataTable()); + $view->config->show_header_message = '
' . $message . '
'; + $view->config->no_data_message = ' '; + return $view->render(); + } + + throw $e; + } + + $count = $dataTable->getRowsCount(); + if ($count >= $iteration['minRowsRequired']) { + break; + } + } + + $idSubTable = Request::fromRequest()->getIntegerParameter('idSubtable', 0); + if (!empty($idSubTable)) { + $row = $dataTable->getRowFromIdSubDataTable($idSubTable); + if ($row !== false) { + $dataTable = $row->getSubTable(); + } else { + $searchResult = $this->searchDescendentsForSubtable($dataTable, $idSubTable); + if ($searchResult !== null) { + $dataTable = $searchResult; + } + } + } + + $showFooterMessage = true; + + if ($report['report_type'] === Table::ID) { + $factory = StaticContainer::get('Piwik\Columns\DimensionsProvider'); + if (empty($idSubTable)) { + $dimension = $factory->factory(reset($report['dimensions'])); + } else { + $showFooterMessage = false; // do not show message again for subtables + $firstRow = $dataTable ? $dataTable->getFirstRow() : false; + $level = $firstRow ? $firstRow->getColumn('level') : 0; + if (!empty($level) && !empty($report['dimensions'][$level - 1])) { + $dimension = $factory->factory($report['dimensions'][$level - 1]); + } + } + if (!empty($dimension)) { + $dataTable->filter('Piwik\Plugins\CustomReports\DataTable\Filter\ReportTypeTableFilter', [$this->idSite, $dimension, array()]); + } + } + + $type = ReportType::factory($report['report_type']); + $view = Factory::build($type->getDefaultViewDataTable(), 'CustomReports.getCustomReport', 'CustomReports.previewReport', true); + $view->setDataTable($dataTable); + + $reportInstance = ReportsProvider::factory('CustomReports', 'getCustomReport'); + if (!$reportInstance) { + // user has not configured any custom reports yet, we need to configure manually otherwise it won't work + // because reports are cached + $reportInstance = new GetCustomReport(); + $reportInstance->initCustomReport($report); + + $subtable = $reportInstance->getActionToLoadSubTables(); + if (!empty($subtable)) { + $view->config->subtable_controller_action = $subtable; + } + + $metrics = $reportInstance->getMetrics(); + if (!empty($metrics)) { + $view->config->addTranslations($metrics); + } + + $processedMetrics = $reportInstance->getProcessedMetrics(); + if (!empty($processedMetrics)) { + $view->config->addTranslations($processedMetrics); + } + + $view->config->title = $reportInstance->getName(); + $reportInstance->configureView($view); + } + + // make sure any action will be correctly forwarded + if (property_exists($view->config, 'disable_row_evolution')) { + $view->config->disable_row_evolution = true; + } + $view->config->custom_parameters['module'] = 'CustomReports'; + $view->config->custom_parameters['action'] = 'previewReport'; + $view->config->subtable_controller_action = 'previewReport'; + + if ($showFooterMessage) { + $dateString = $periodObject->getLocalizedLongString(); + if (!empty($startDateObj) && !empty($endDateObj) && $period === 'range') { + $dateString = $startDateObj->format('M d Y h:i:s A T') . ' - ' . $endDateObj->format('M d Y h:i:s A T'); + } + $view->config->show_footer_message = Piwik::translate('CustomReports_PreviewDate', $dateString); + } + + $view->config->show_flatten_table = false; + $view->config->show_pivot_by_subtable = false; + $view->config->show_insights = false; + $view->config->show_pie_chart = false; + $view->config->show_bar_chart = false; + $view->config->show_tag_cloud = false; + $view->config->show_all_views_icons = false; + $view->config->show_search = false; + $view->config->show_title = false; + $view->config->show_limit_control = false; + $view->config->show_export_as_image_icon = false; + $view->config->show_export_as_rss_feed = false; + $view->config->show_export = false; + $view->config->show_pagination_control = false; + $view->config->show_offset_information = false; + $view->requestConfig->filter_limit = 10; + $view->requestConfig->request_parameters_to_modify['report_type'] = $report['report_type']; + $view->requestConfig->request_parameters_to_modify['metrics'] = $report['metrics']; + $view->requestConfig->request_parameters_to_modify['totals'] = 0; + if (!empty($report['dimensions'])) { + $view->requestConfig->request_parameters_to_modify['dimensions'] = $report['dimensions']; + } + + return $view->render(); + } + + private function getReportDataTable(?array $report, string $date, Site $site, string $period, ?string &$periodObject, ?\DateTime $startDateObj, ?\DateTime $endDateObj): DataTable + { + if ($period === 'range') { + $periodObject = PeriodFactory::build('range', $date, $site->getTimezone()); + } else { + $periodObject = PeriodFactory::makePeriodFromQueryParams($site->getTimezone(), $period, $date); + } + $parameters = new ArchiveProcessor\Parameters($site, $periodObject, new Segment('', [$this->idSite])); + $archiveWriter = new ArchiveWriter($parameters); + $logAggregator = new CustomLogAggregator($parameters); + if ($startDateObj && $endDateObj && $period === 'range') { + $logAggregator->setDateStart(Date::factory($startDateObj->getTimestamp())); + $logAggregator->setDateEnd(Date::factory($endDateObj->getTimestamp())); + } + + $processor = new ArchiveProcessor($parameters, $archiveWriter, $logAggregator); + + $dataTable = null; + if (!empty($report)) { + $recordBuilder = new CustomReport($report, StaticContainer::get(DimensionsProvider::class), StaticContainer::get(Configuration::class)); + $dataTables = $recordBuilder->aggregate($processor); + $dataTable = reset($dataTables); + } + + if (!$dataTable) { + throw new \Exception('Invalid report information'); + } + + return $dataTable; + } + + private function searchDescendentsForSubtable(DataTable $dataTable, int $idSubtable): ?DataTable + { + foreach ($dataTable->getRows() as $row) { + $subTable = $row->getSubtable(); + if ($subTable) { + $row = $subTable->getRowFromIdSubDataTable($idSubtable); + if ($row) { + return $row->getSubtable(); + } + + // Recursively search further descendents + $searchResult = $this->searchDescendentsForSubtable($subTable, $idSubtable); + if ($searchResult !== null) { + return $searchResult; + } + } + } + + return null; + } + + /** + * Renders an evolution graph visualization. + * @return string|void + */ + public function getEvolutionGraph() + { + $this->checkSitePermission(); + $this->validator->checkReportViewPermission($this->idSite); + + $columns = Common::getRequestVar('columns', false); + if (false !== $columns) { + $columns = Piwik::getArrayFromApiParameter($columns); + } + + $report = new GetCustomReport(); + $documentation = $report->getDocumentation(); + + $selectableColumns = array_keys($report->getMetrics()); + + if (empty($columns) && !empty($selectableColumns) && count($selectableColumns) <= 4) { + // pre-select all columns when there are only 4 columns + $columns = $selectableColumns; + } elseif (empty($columns) && !empty($selectableColumns)) { + // pre-select only first column when there are more than 5 columns + $columns = array(reset($selectableColumns)); + } + + // Use getCustomReport as the controller action so that the apiMethod and currentControllerAction match + // This is important so that the stored viewDataTableParameters values for the report are used + $view = $this->getLastUnitGraphAcrossPlugins( + $this->pluginName, + 'getCustomReport', + $columns, + $selectableColumns, + $documentation, + 'CustomReports.getCustomReport' + ); + + if (empty($view->config->columns_to_display) && $report->getDefaultSortColumn()) { + $view->config->columns_to_display = array($report->getDefaultSortColumn()); + } + + return $this->renderView($view); + } + + /** + * Renders a KPI card for a single metric custom report (metric value, sparkline and + * comparison with the previous period). + * @return string + * @throws \Exception + */ + public function getSingleMetricView() + { + $this->checkSitePermission(); + $this->validator->checkReportViewPermission($this->idSite); + + $idCustomReport = Common::getRequestVar('idCustomReport', null, 'int'); + + $model = StaticContainer::get('Piwik\Plugins\CustomReports\Model\CustomReportsModel'); + $report = $model->getCustomReport($this->idSite, $idCustomReport); + + if (empty($report) || $report['report_type'] !== KpiCard::ID) { + throw new \Exception(Piwik::translate('CustomReports_ErrorReportDoesNotExist')); + } + + $metric = reset($report['metrics']); + + $reportMetadata = \Piwik\API\Request::processRequest('API.getMetadata', array( + 'idSite' => $this->idSite, + 'apiModule' => 'CustomReports', + 'apiAction' => 'getCustomReport', + 'apiParameters' => array('idCustomReport' => $idCustomReport), + )); + $reportMetadata = reset($reportMetadata); + + $metricTranslations = array(); + $metricDocumentations = array(); + if (!empty($reportMetadata)) { + $metricTranslations = array_merge( + !empty($reportMetadata['metrics']) ? $reportMetadata['metrics'] : array(), + !empty($reportMetadata['processedMetrics']) ? $reportMetadata['processedMetrics'] : array() + ); + if (!empty($reportMetadata['metricsDocumentation'])) { + $metricDocumentations = $reportMetadata['metricsDocumentation']; + } + } + + $output = '
'; + + $isWidget = Common::getRequestVar('widget', 0, 'int'); + $showTitle = Common::getRequestVar('showtitle', 0, 'int'); + + if (!$isWidget && $showTitle) { + // on reporting pages (which request showtitle=1) the widget needs to render the same + // card and title markup as regular reports do (see _dataTable.twig); on dashboards + // and in widgetize the surrounding widget provides the chrome + $editUrl = ''; + if ($this->validator->canWrite($this->idSite)) { + $editUrl = 'index.php' . Url::getCurrentQueryStringWithParametersModified(array( + 'module' => 'CustomReports', + 'action' => 'manage' + )) . '#?idCustomReport=' . (int) $idCustomReport; + } + + $title = '

'; + + $output = '
' . $title . $output . '
'; + } + + return $output; + } + + private function getVueEntryValue($value) + { + return Common::sanitizeInputValue(json_encode($value)); + } +} diff --git a/files/plugin-CustomReports-5.5.0/CustomLogAggregator.php b/files/plugin-CustomReports-5.5.0/CustomLogAggregator.php new file mode 100644 index 0000000..4d06495 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/CustomLogAggregator.php @@ -0,0 +1,32 @@ +dateStart = $dateStart; + } + + public function setDateEnd($dateEnd) + { + $this->dateEnd = $dateEnd; + } +} diff --git a/files/plugin-CustomReports-5.5.0/CustomReports.php b/files/plugin-CustomReports-5.5.0/CustomReports.php new file mode 100644 index 0000000..f1cc2c9 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/CustomReports.php @@ -0,0 +1,432 @@ + 'getStylesheetFiles', + 'Translate.getClientSideTranslationKeys' => 'getClientSideTranslationKeys', + 'Report.addReports' => 'addCustomReports', + 'SitesManager.deleteSite.end' => 'onDeleteSite', + 'System.addSystemSummaryItems' => 'addSystemSummaryItems', + 'Category.addSubcategories' => 'addSubcategories', + 'CustomReports.buildPreviewReport' => 'buildPreviewReport', + 'API.addGlossaryItems' => 'addGlossaryItems', + 'API.getPagesComparisonsDisabledFor' => 'getPagesComparisonsDisabledFor', + 'Db.getTablesInstalled' => 'getTablesInstalled', + 'ScheduledReports.processReports' => 'processReports', + 'Archiver.addRecordBuilders' => 'addRecordBuilders', + 'Metric.addMetrics' => 'addMetrics', + ); + } + + public function addMetrics(MetricsList $metricsList) + { + StaticContainer::get(CustomMetricHelper::class)->addCustomMetricsToList($metricsList); + } + + public function addRecordBuilders(array &$recordBuilders): void + { + $idSite = \Piwik\Request::fromRequest()->getIntegerParameter('idSite', 0); + if (!$idSite) { + return; + } + + $reports = StaticContainer::get(CustomReportsModel::class)->getAllCustomReportsForSite($idSite); + foreach ($reports as $report) { + if ($report['status'] === CustomReportsModel::STATUS_ACTIVE) { + $recordBuilders[] = StaticContainer::getContainer()->make(CustomReport::class, ['report' => $report]); + } + } + } + + /** + * Register the new tables, so Matomo knows about them. + * + * @param array $allTablesInstalled + */ + public function getTablesInstalled(&$allTablesInstalled) + { + $allTablesInstalled[] = Common::prefixTable('custom_reports'); + } + + public function getPagesComparisonsDisabledFor(&$pages) + { + $pages[] = 'CustomReports_CustomReports.CustomReports_ManageReports'; + } + + public function buildPreviewReport(&$report) + { + $dimensions = Common::getRequestVar('dimensions', '', 'string'); + $metrics = Common::getRequestVar('metrics', '', 'string'); + $idSite = Common::getRequestVar('idSite', 0, 'string'); + if (empty($metrics)) { + // not for this plugin + return; + } + + if (!empty($dimensions)) { + $dimensions = array_unique(explode(',', $dimensions)); + $dimensionsCheck = new Dimensions($dimensions, $idSite); + $dimensionsCheck->check(); + } else { + $dimensions = array(); + } + + $metrics = array_unique(explode(',', $metrics)); + $metricsCheck = new Metrics($metrics, $idSite); + $metricsCheck->check(); + + $reportType = Common::getRequestVar('report_type', null, 'string'); + $segment = Request::getRawSegmentFromRequest(); + + $type = new ReportType($reportType); + $type->check(); + + $report = array( + 'idcustomreport' => 0, + 'report_type' => $reportType, + 'dimensions' => $dimensions, + 'metrics' => $metrics, + 'segment_filter' => $segment, + 'category' => array('id' => CustomReportsDao::DEFAULT_CATEGORY, + 'name' => Piwik::translate(CustomReportsDao::DEFAULT_CATEGORY), + 'order' => 999, + 'icon' => ''), + 'subcategory' => null, + 'name' => Piwik::translate('CustomReports_Preview'), + 'description' => null, + 'created_date' => Date::now()->getDatetime(), + 'updated_date' => Date::now()->getDatetime(), + 'revision' => 0, + ); + } + + public function addSystemSummaryItems(&$systemSummary) + { + $dao = $this->getDao(); + $numForms = $dao->getNumReportsTotal(); + + $systemSummary[] = new SystemSummary\Item($key = 'customreports', Piwik::translate('CustomReports_NCustomReports', $numForms), $value = null, array('module' => 'CustomReports', 'action' => 'manage'), self::MENU_ICON, $order = 8); + } + + public function install() + { + $dao = new CustomReportsDao(); + $dao->install(); + + $config = new Configuration(); + $config->install(); + } + + public function uninstall() + { + $dao = new CustomReportsDao(); + $dao->uninstall(); + + $config = new Configuration(); + $config->uninstall(); + } + + private function getModel() + { + return StaticContainer::get('Piwik\Plugins\CustomReports\Model\CustomReportsModel'); + } + + private function getDao() + { + return StaticContainer::get('Piwik\Plugins\CustomReports\Dao\CustomReportsDao'); + } + + public function onDeleteSite($idSite) + { + $model = $this->getModel(); + $model->deactivateReportsForSite($idSite); + } + + public function addCustomReports(&$instances) + { + $idSite = Common::getRequestVar('idSite', 0, 'int'); + + if (empty($idSite) || $idSite < 1) { + // fallback for eg API.getReportMetadata which uses idSites + $idSite = Common::getRequestVar('idSites', 0, 'int'); + + if (empty($idSite) || $idSite < 1) { + return; + } + } + + $reports = $this->getConfiguredReports($idSite); + + foreach ($reports as $report) { + $instance = new GetCustomReport(); + $instance->initCustomReport($report); + $instances[] = $instance; + } + } + + public function addSubcategories(&$subcategories) + { + $idSite = Common::getRequestVar('idSite', 0, 'int'); + + if (empty($idSite)) { + // fallback for eg API.getReportMetadata which uses idSites + $idSite = Common::getRequestVar('idSites', 0, 'int'); + + if (empty($idSite)) { + return; + } + } + + $reports = $this->getConfiguredReports($idSite, $skipCategoryMetadata = true); // skipping metadata since it will lead to infinite recursion + usort($reports, function ($a, $b) { + return strcasecmp($a['name'], $b['name']); + }); + + $addedNames = array(); + $addedCategories = array(); + + $order = 100; + + foreach ($reports as $report) { + if (!empty($report['category']) && $report['category'] === CustomReportsDao::DEFAULT_CATEGORY) { + // we presume this subcategory is added by different plugin. + if (!empty($report['subcategory']) && $report['idcustomreport'] != $report['subcategory']) { + // will be added with another custom report entry. Happens when assigning a custom report to another custom report page + continue; + } + + $subcategoryName = $report['name']; + $subcategoryId = $report['idcustomreport']; + $lowerName = strtolower($subcategoryName); + + if (in_array($lowerName, $addedNames)) { + continue; // this may happen when two custom reports exist where one has eg name "My report" and the other + // custom report chooses the same subcategory "My report" + } + + if (in_array($subcategoryId, $addedCategories)) { + continue; // this may happen when two custom reports exist where one has eg name "My report" and the other + // custom report chooses the same subcategory "My report" + } + + $addedNames[] = $lowerName; + $addedCategories[] = $subcategoryId; + + $subcategory = new Subcategory(); + $subcategory->setName($subcategoryName); + $subcategory->setCategoryId($report['category']); + $subcategory->setId($subcategoryId); + $subcategory->setOrder($order++); + $subcategories[] = $subcategory; + } + } + } + + public function getStylesheetFiles(&$stylesheets) + { + // TODO: Replace the duplicated DraggableList Vue and LESS files with the core component in Matomo 6. + $stylesheets[] = "plugins/CustomReports/vue/src/Reports/DraggableList.less"; + $stylesheets[] = "plugins/CustomReports/vue/src/Reports/Edit.less"; + $stylesheets[] = "plugins/CustomReports/vue/src/Reports/List.less"; + $stylesheets[] = "plugins/CustomReports/vue/src/KpiCardView/KpiCardView.less"; + } + + public function getClientSideTranslationKeys(&$result) + { + $result[] = 'General_Actions'; + $result[] = 'General_Name'; + $result[] = 'General_Id'; + $result[] = 'General_Yes'; + $result[] = 'General_No'; + $result[] = 'General_LoadingData'; + $result[] = 'General_Description'; + $result[] = 'General_Cancel'; + $result[] = 'General_Website'; + $result[] = 'General_Metrics'; + $result[] = 'General_Metric'; + $result[] = 'General_Search'; + $result[] = 'CoreUpdater_UpdateTitle'; + $result[] = 'CustomReports_AddMetric'; + $result[] = 'CustomReports_Type'; + $result[] = 'CustomReports_Category'; + $result[] = 'CustomReports_AddDimension'; + $result[] = 'CustomReports_ReportPage'; + $result[] = 'CustomReports_ReportCategory'; + $result[] = 'CustomReports_ReportCategoryHelp'; + $result[] = 'CustomReports_ReportSubcategory'; + $result[] = 'CustomReports_ReportSubcategoryHelp'; + $result[] = 'CustomReports_ReportType'; + $result[] = 'CustomReports_Dimensions'; + $result[] = 'CustomReports_PreviewReport'; + $result[] = 'CustomReports_Preview'; + $result[] = 'CustomReports_Filter'; + $result[] = 'CustomReports_WarningRequiresUnlock'; + $result[] = 'CustomReports_Unlock'; + $result[] = 'CustomReports_ConfirmUnlockReport'; + $result[] = 'CustomReports_WarningOnUpdateReportMightGetLost'; + $result[] = 'CustomReports_InfoReportIsLocked'; + $result[] = 'CustomReports_ReportContent'; + $result[] = 'CustomReports_AvailableAllWebsites'; + $result[] = 'CustomReports_ErrorMissingMetric'; + $result[] = 'CustomReports_ErrorMissingDimension'; + $result[] = 'CustomReports_ReportEditNotAllowedAllWebsitesUpdated'; + $result[] = 'CustomReports_RemoveMetric'; + $result[] = 'CustomReports_RemoveDimension'; + $result[] = 'CustomReports_ReportAvailableToAllWebsites'; + $result[] = 'CustomReports_ApplyTo'; + $result[] = 'CustomReports_ViewReportInfo'; + $result[] = 'CustomReports_CustomReportIntroduction'; + $result[] = 'CustomReports_NoCustomReportsFound'; + $result[] = 'CustomReports_ManageReports'; + $result[] = 'CustomReports_EditReport'; + $result[] = 'CustomReports_DeleteReportConfirm'; + $result[] = 'CustomReports_DeleteReportInfo'; + $result[] = 'CustomReports_CreateNewReport'; + $result[] = 'CustomReports_ErrorXNotProvided'; + $result[] = 'CustomReports_ReportCreated'; + $result[] = 'CustomReports_ReportUpdated'; + $result[] = 'CustomReports_UpdatingData'; + $result[] = 'CustomReports_FieldNamePlaceholder'; + $result[] = 'CustomReports_FieldDescriptionPlaceholder'; + $result[] = 'CustomReports_ReportNameHelpText'; + $result[] = 'CustomReports_ReportDescriptionHelpText'; + $result[] = 'CustomReports_ReportAllWebsitesHelp'; + $result[] = 'CustomReports_ReportDimensionsHelpNew'; + $result[] = 'CustomReports_ReportMetricsHelp'; + $result[] = 'CustomReports_ReportSingleMetricHelp'; + $result[] = 'CustomReports_ReportSegmentHelp'; + $result[] = 'CustomReports_WarningOnUpdateReportMightGetLostBrowserArchivingDisabled'; + $result[] = 'CustomReports_ConfirmUnlockReportBrowserArchivingDisabled'; + $result[] = 'CustomReports_WarningRequiresUnlockBrowserArchivingDisabled'; + $result[] = 'CustomReports_InfoReportIsLockedBrowserArchivingDisabled'; + $result[] = 'CustomReports_Dimension'; + $result[] = 'CustomReports_WarningProductRevenueMetricDependency'; + $result[] = 'CustomReports_OrderSubCategoryReports'; + $result[] = 'CustomReports_OrderSubCategoryReportsDescription'; + $result[] = 'CustomReports_WarningRegionDimensionDependency'; + $result[] = 'CustomReports_SelectMeasurablesMatchingSearch'; + $result[] = 'CustomReports_FindMeasurables'; + $result[] = 'CustomReports_ReportEditNotAllowedMultipleWebsitesAccessIssue'; + $result[] = 'CustomReports_ReportAvailableToMultipleWebsites'; + $result[] = 'CustomReports_NoMeasurableAssignedYet'; + $result[] = 'CustomReports_MatchingSearchNotFound'; + $result[] = 'CustomReports_MatchingSearchConfirmTitle'; + $result[] = 'CustomReports_MatchingSearchConfirmTitleAlreadyAdded'; + $result[] = 'CustomReports_MatchingSearchMatchedAdd'; + $result[] = 'CustomReports_MatchingSearchMatchedAlreadyAdded'; + $result[] = 'CustomReports_ReportAllWebsitesNonSuperUserHelp'; + $result[] = 'CustomReports_PauseReportInfo'; + $result[] = 'CustomReports_ResumeReportInfo'; + $result[] = 'CustomReports_PauseReportConfirm'; + $result[] = 'CustomReports_ResumeReportConfirm'; + $result[] = 'CustomReports_ResumedReport'; + $result[] = 'CustomReports_PausedReport'; + $result[] = 'CustomReports_ReportInPausedStateAdmin'; + $result[] = 'CustomReports_NoDataMessagePausedStateNonAdminUser'; + $result[] = 'CustomReports_ReportDimensionsHelpExtended'; + $result[] = 'CustomReports_QuotaReachedForX'; + $result[] = 'CustomReports_CustomReport'; + $result[] = 'CustomReports_CustomReports'; + } + + public function addGlossaryItems(&$glossaryItems) + { + /** @var Glossary $glossary */ + $glossary = StaticContainer::get(Glossary::class); + $glossaryItems['CustomReports'] = [ + 'title' => Piwik::translate('CustomReports_CustomReports'), + 'entries' => $glossary->getMetricsAndDimensions(), + ]; + } + + private function getConfiguredReports($idSite, $skipCategoryMetadata = false) + { + return Request::processRequest('CustomReports.getConfiguredReports', [ + 'idSite' => $idSite, + 'skipCategoryMetadata' => $skipCategoryMetadata ? '1' : '0', + 'filter_limit' => '-1', + ], $default = []); + } + + public function processReports(&$processedReports, $reportType, $outputType, $report) + { + foreach ($processedReports as &$processedReport) { + $metadata = &$processedReport['metadata']; + if ( + !empty($metadata['module']) && + $metadata['module'] === 'CustomReports' && + !empty($metadata['metrics']) && + count($metadata['metrics']) > 1 + ) { + //we limit the columns to show only first 3 metrics as too many metrics will break the graph + $columns = implode(',', array_slice(array_keys($metadata['metrics']), 0, 3)); + $metadata['imageGraphUrl'] .= '&columns=' . $columns; + $metadata['imageGraphEvolutionUrl'] .= '&columns=' . $columns; + } + } + } +} diff --git a/files/plugin-CustomReports-5.5.0/Dao/CustomReportsDao.php b/files/plugin-CustomReports-5.5.0/Dao/CustomReportsDao.php new file mode 100644 index 0000000..b302902 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Dao/CustomReportsDao.php @@ -0,0 +1,416 @@ +tablePrefixed = Common::prefixTable($this->table); + } + + private function getDb() + { + return Db::get(); + } + + public function install() + { + // revision is there to indirectly "invalidate" an existing archive for that custom report. + // revision is appended to the archive record name and when revision changes, it will notice that no archive + // exists yet and need to re-archive reports for it + DbHelper::createTable($this->table, " + `idcustomreport` int(11) UNSIGNED NOT NULL AUTO_INCREMENT, + `idsite` int(11) NOT NULL, + `revision` MEDIUMINT(8) UNSIGNED NOT NULL DEFAULT 0, + `report_type` VARCHAR(10) NOT NULL DEFAULT '" . Table::ID . "', + `name` VARCHAR(" . Name::MAX_LENGTH . ") NOT NULL, + `description` VARCHAR(" . Description::MAX_LENGTH . ") NOT NULL DEFAULT '', + `category` VARCHAR(" . Category::MAX_LENGTH . ") NOT NULL DEFAULT '" . self::DEFAULT_CATEGORY . "', + `subcategory` VARCHAR(" . Subcategory::MAX_LENGTH . ") NOT NULL DEFAULT '', + `subcategory_order` MEDIUMINT(8) UNSIGNED NOT NULL DEFAULT 9999999, + `dimensions` TEXT NOT NULL, + `metrics` TEXT NOT NULL, + `segment_filter` TEXT NOT NULL DEFAULT '', + `created_date` DATETIME NOT NULL, + `updated_date` DATETIME NOT NULL, + `status` VARCHAR(10) NOT NULL DEFAULT '" . CustomReportsModel::STATUS_ACTIVE . "', + `multiple_idsites` VARCHAR(" . self::MULTIPLE_IDSITES_MAX_LENGTH . "), + PRIMARY KEY (`idcustomreport`), + UNIQUE unique_site_name (`idsite`, `name`)"); + } + + public function uninstall() + { + Db::query(sprintf('DROP TABLE IF EXISTS `%s`', $this->tablePrefixed)); + } + + /** + * Serializes website IDs for the `multiple_idsites` column, rejecting a value that exceeds the + * column length. + * + * @param int[]|string[] $multipleIdSites + * @return string + * @throws Exception when the serialized value exceeds the column length. + */ + public function serializeMultipleIdSites(array $multipleIdSites): string + { + $serialized = implode(',', $multipleIdSites); + + if (strlen($serialized) > self::MULTIPLE_IDSITES_MAX_LENGTH) { + throw new Exception(Piwik::translate('CustomReports_ErrorXTooLong', ['multipleIdSites', self::MULTIPLE_IDSITES_MAX_LENGTH])); + } + + return $serialized; + } + + public function addCustomReport($idSite, $name, $description, $reportType, $dimensions, $metrics, $segmentFilter, $categoryId, $subcategoryId, $status, $createdDate, $multipleIdSites) + { + $columns = array( + 'idsite' => $idSite, + 'name' => $name, + 'description' => $description, + 'report_type' => $reportType, + 'dimensions' => $dimensions, + 'metrics' => $metrics, + 'segment_filter' => $segmentFilter, + 'subcategory' => $subcategoryId, + 'category' => $categoryId, + 'status' => $status, + 'created_date' => $createdDate, + 'updated_date' => $createdDate, + 'multiple_idsites' => '' + ); + + if ($multipleIdSites) { + $columns['idsite'] = -1; + $columns['multiple_idsites'] = $this->serializeMultipleIdSites($multipleIdSites); + } + + $columns = $this->encodeFieldsWhereNeeded($columns); + + $bind = array_values($columns); + $placeholder = Common::getSqlStringFieldsArray($columns); + + $sql = sprintf( + 'INSERT INTO %s (`%s`) VALUES(%s)', + $this->tablePrefixed, + implode('`,`', array_keys($columns)), + $placeholder + ); + + $db = $this->getDb(); + + try { + $db->query($sql, $bind); + } catch (Exception $e) { + if ( + $e->getCode() == 23000 + || strpos($e->getMessage(), 'Duplicate entry') !== false + || strpos($e->getMessage(), ' 1062 ') !== false + ) { + throw new Exception(Piwik::translate('CustomReports_ErrorReportNameDuplicate')); + } + throw $e; + } + + $idCustomReport = $db->lastInsertId(); + + return (int) $idCustomReport; + } + + public function updateColumns($idSite, $idCustomReport, $columns) + { + $columns = $this->encodeFieldsWhereNeeded($columns); + + if (!empty($columns)) { + if (!isset($columns['updated_date'])) { + $columns['updated_date'] = $this->getCurrentTime(); + } + + $fields = array(); + $bind = array(); + foreach ($columns as $key => $value) { + $fields[] = ' ' . $key . ' = ?'; + $bind[] = $value; + } + $fields = implode(',', $fields); + + $query = sprintf('UPDATE %s SET %s WHERE idcustomreport = ? AND (idsite = ? or idsite = -1)', $this->tablePrefixed, $fields); + $bind[] = (int) $idCustomReport; + $bind[] = (int) $idSite; + + // we do not use $db->update() here as this method is as well used in Tracker mode and the tracker DB does not + // support "->update()". Therefore we use the query method where we know it works with tracker and regular DB + $this->getDb()->query($query, $bind); + } + } + + /** + * @return int + */ + public function getNumReportsTotal() + { + $sql = sprintf("SELECT COUNT(*) as numreports FROM %s WHERE `status` = ? or `status` = ?", $this->tablePrefixed); + return $this->getDb()->fetchOne($sql, array(CustomReportsModel::STATUS_ACTIVE, CustomReportsModel::STATUS_PAUSED)); + } + + /** + * @param int $idSite + * @param int $idCustomReport + */ + public function deleteCustomReport($idSite, $idCustomReport) + { + $query = sprintf('DELETE FROM %s WHERE (idsite = ? or idsite = 0) and idcustomreport = ?', $this->tablePrefixed); + $db = $this->getDb(); + $db->query($query, array($idSite, $idCustomReport)); + } + + /** + * @param int $idSite + */ + public function deleteCustomReports($idSite) + { + $query = sprintf('DELETE FROM %s WHERE (idsite = ? or idsite = 0) and (status = ? or `status` = ?)', $this->tablePrefixed); + $db = $this->getDb(); + $db->query($query, array($idSite, CustomReportsModel::STATUS_ACTIVE, CustomReportsModel::STATUS_PAUSED)); + } + + /** + * @param int $idSite + * @return array + */ + public function getCustomReports($idSite) + { + $query = sprintf('SELECT * FROM %s WHERE (idsite = ? or idsite = 0 or idsite = -1) and (status = ? or status = ?)', $this->tablePrefixed); + $db = $this->getDb(); + $rows = $db->fetchAll($query, array($idSite, CustomReportsModel::STATUS_ACTIVE, CustomReportsModel::STATUS_PAUSED)); + + return $this->enrichRecords($rows, $idSite); + } + + /** + * @return array + */ + public function getAllReports() + { + $table = $this->tablePrefixed; + $reports = $this->getDb()->fetchAll("SELECT * FROM $table"); + return $this->enrichRecords($reports, 'all'); + } + + /** + * @param int $idSite + * @param int $idCustomReport + * @return array|bool + */ + public function getCustomReport($idSite, $idCustomReport) + { + $query = sprintf('SELECT * FROM %s WHERE (idsite = ? or idsite = 0 or idsite = -1) and idcustomreport = ? and (status = ? or status = ?) LIMIT 1', $this->tablePrefixed); + $db = $this->getDb(); + $row = $db->fetchRow($query, array($idSite, $idCustomReport, CustomReportsModel::STATUS_ACTIVE, CustomReportsModel::STATUS_PAUSED)); + + return $this->enrichRecord($row, $idSite); + } + + public function getChildReports(int $idSite, int $idCustomReport): array + { + $query = sprintf('SELECT idcustomreport, name, subcategory_order FROM %s WHERE (idsite = ? or idsite = 0) and subcategory = ? and (status = ? or status = ?) order by subcategory_order ASC', $this->tablePrefixed); + $db = $this->getDb(); + + return $db->fetchAll($query, array($idSite, $idCustomReport, CustomReportsModel::STATUS_ACTIVE, CustomReportsModel::STATUS_PAUSED)); + } + + /** + * @param int $idCustomReport + * @return array|bool + */ + public function getCustomReportById($idCustomReport, $idSite) + { + $query = sprintf('SELECT * FROM %s WHERE idcustomreport = ? and (status = ? or status = ?) LIMIT 1', $this->tablePrefixed); + $db = $this->getDb(); + $row = $db->fetchRow($query, array($idCustomReport, CustomReportsModel::STATUS_ACTIVE, CustomReportsModel::STATUS_PAUSED)); + + return $this->enrichRecord($row, $idSite); + } + + public function updateReportOrder($idCustomReport, $subCategoryOrder, $idSite) + { + $bind = []; + $query = sprintf('UPDATE %s SET subcategory_order = ? WHERE idcustomreport = ? AND idsite = ?', $this->tablePrefixed); + $bind[] = (int) $subCategoryOrder; + $bind[] = (int) $idCustomReport; + $bind[] = (int) $idSite; + + // we do not use $db->update() here as this method is as well used in Tracker mode and the tracker DB does not + // support "->update()". Therefore we use the query method where we know it works with tracker and regular DB + $this->getDb()->query($query, $bind); + } + + public function getReportIds(array $idCustomReports): array + { + $idCustomReportsFilteredValues = $this->filterNonNumericValues($idCustomReports); + if (empty($idCustomReportsFilteredValues)) { + return []; + } + $query = sprintf('SELECT idcustomreport FROM %s WHERE idcustomreport in(' . implode(',', $idCustomReportsFilteredValues) . ') and (status = ? or status = ?)', $this->tablePrefixed); + + return $this->getDb()->fetchAll( + $query, + array(CustomReportsModel::STATUS_ACTIVE, CustomReportsModel::STATUS_PAUSED) + ); + } + + private function enrichRecords($records, $idSite) + { + if (empty($records)) { + return $records; + } + + $data = array(); + + foreach ($records as $record) { + if ($idSite === 'all' && !empty($record['multiple_idsites'])) { + $multipleIdSites = $record['multiple_idsites'] ? explode(',', $record['multiple_idsites']) : []; + foreach ($multipleIdSites as $multipleIdSite) { + $record['idsite'] = $multipleIdSite; + $data[] = $this->enrichRecord($record, $multipleIdSite); + } + } elseif ($record['idsite'] == -1) { + $multipleIdSites = $record['multiple_idsites'] ? explode(',', $record['multiple_idsites']) : []; + if (!in_array((int) $idSite, array_map('intval', $multipleIdSites), true)) { + continue; + } + $record['idsite'] = $idSite; + $data[] = $this->enrichRecord($record, $idSite); + } else { + $data[] = $this->enrichRecord($record, $record['idsite']); + } + } + + return $data; + } + + private function enrichRecord($record, $idSite) + { + if (empty($record)) { + return $record; + } + if ($record['idsite'] == -1) { + $multipleIdSites = $record['multiple_idsites'] ? explode(',', $record['multiple_idsites']) : []; + $isAllWebsitesRequest = Validator::isAllWebsitesRequest($idSite); + $allowAllWebsitesRequest = $isAllWebsitesRequest && Piwik::hasUserSuperUserAccess(); + if ($idSite != -1 && !$allowAllWebsitesRequest && !in_array((int) $idSite, array_map('intval', $multipleIdSites), true)) { + return []; + } + $record['idsite'] = $idSite; + } + + $record['idcustomreport'] = (int) $record['idcustomreport']; + $record['idsite'] = (int) $record['idsite']; + $record['revision'] = (int) $record['revision']; + $record['dimensions'] = $this->decodeField($record['dimensions']); + $record['metrics'] = $this->decodeField($record['metrics']); + $record['multiple_idsites'] = $record['multiple_idsites']; + + if (!empty($record['created_date']) && strpos($record['created_date'], '0000') !== false) { + $record['created_date'] = null; + } + + if (!empty($record['updated_date']) && strpos($record['updated_date'], '0000') !== false) { + $record['updated_date'] = null; + } + + if (empty($record['category'])) { + $record['category'] = self::DEFAULT_CATEGORY; + } + + return $record; + } + + private function encodeFieldsWhereNeeded($columns) + { + foreach ($columns as $column => $value) { + if (in_array($column, array('dimensions', 'metrics'))) { + $columns[$column] = $this->encodeField($value); + } + } + + return $columns; + } + + private function encodeField($field) + { + if (empty($field) || !is_array($field)) { + $field = array(); + } + + return json_encode($field); + } + + private function decodeField($field) + { + if (!empty($field)) { + $field = @json_decode($field, true); + } + + if (empty($field) || !is_array($field)) { + $field = array(); + } + + return $field; + } + + protected function getCurrentTime() + { + return Date::now()->getDatetime(); + } + + private function filterNonNumericValues(array $values): array + { + // allow only int values + return array_filter($values, function ($value) { + + return (is_int($value) || (is_string($value) && ctype_digit($value))); + }); + } +} diff --git a/files/plugin-CustomReports-5.5.0/DataTable/Filter/ReportTypeTableFilter.php b/files/plugin-CustomReports-5.5.0/DataTable/Filter/ReportTypeTableFilter.php new file mode 100644 index 0000000..7916fe8 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/DataTable/Filter/ReportTypeTableFilter.php @@ -0,0 +1,123 @@ +dimension = $dimension; + $this->dimensions = $nestedDimensions; + $this->idSite = $idSite; + $this->isFlat = $flat; + } + + /** + * @param DataTable $table + */ + public function filter($table) + { + $this->renameRowDimension($table, $this->dimension, $this->dimensions); + $table->setLabelsHaveChanged(); + } + + /** + * @param DataTable $table + * @param Dimension $dimension + * @param Dimension[] $dimension + */ + private function renameRowDimension($table, $dimension, $dimensions) + { + if (!$dimension) { + return; + } + + $formatter = new Formatter(); + + $nextDimension = null; + if (!empty($dimensions)) { + $nextDimension = array_shift($dimensions); + } + + foreach ($table->getRowsWithoutSummaryRow() as $row) { + $label = $row->getColumn('label'); + if ($label === Archiver::LABEL_NOT_DEFINED) { + $label = Piwik::translate('General_NotDefined', $dimension->getName()); + if ($dimension->getSegmentName() === 'regionCode') { + $label = $dimension->getName() . ' ' . Piwik::translate('General_Unknown'); + $row->setMetadata('segment', 'regionCode=='); + } elseif ($dimension->getSegmentName() === 'operatingSystemVersion') { + $label = $dimension->getName() . ' ' . Piwik::translate('General_Unknown'); + $row->setMetadata('segment', 'operatingSystemVersion=='); + } + } elseif ($dimension->getSegmentName() === 'regionCode') { + $label = !empty($label) ? $label : Piwik::translate('General_Unknown'); + $explodedValues = explode('|', $label); + if (count($explodedValues) == 2) { + $regionCode = $explodedValues[0]; + $countryCode = $explodedValues[1]; + $label = $regionCode . ', ' . $countryCode; + if (\Piwik\Plugin\Manager::getInstance()->isPluginActivated('UserCountry')) { + $label = \Piwik\Plugins\UserCountry\getRegionNameFromCodes($countryCode, $regionCode) . ', ' . \Piwik\Plugins\UserCountry\countryTranslate($countryCode); + $logo = \Piwik\Plugins\UserCountry\getFlagFromCode($countryCode); + if ($logo && !$this->isFlat) { + $row->setMetadata('logo', $logo); + } + } + $row->setMetadata('segment', 'countryCode==' . urlencode($countryCode) . ';regionCode==' . urlencode($regionCode)); + } + } elseif ($dimension->getSegmentName() === 'operatingSystemVersion') { + $label = !empty($label) ? $label : Piwik::translate('General_Unknown'); + $explodedValues = explode('|', $label); + if (count($explodedValues) == 2) { + $osVersion = trim($explodedValues[0]); + $os = trim($explodedValues[1]); + $label = $os . ': ' . $osVersion; + if (\Piwik\Plugin\Manager::getInstance()->isPluginActivated('DevicesDetection')) { + $label = \Piwik\Plugins\DevicesDetection\getOSFamilyFullName($os) . ': ' . (!empty($osVersion) ? $osVersion : Piwik::translate('General_Unknown')); + } + $row->setMetadata('segment', 'operatingSystemCode==' . urlencode($os) . ';operatingSystemVersion==' . urlencode($osVersion)); + } + } else { + $label = $dimension->formatValue($label, $this->idSite, $formatter); + } + + $row->setColumn('label', $label); + + $subtable = $row->getSubtable(); + if ($subtable) { + $this->renameRowDimension($subtable, $nextDimension, $dimensions); + } + } + } +} diff --git a/files/plugin-CustomReports-5.5.0/GetCustomReport.php b/files/plugin-CustomReports-5.5.0/GetCustomReport.php new file mode 100644 index 0000000..8b2a970 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/GetCustomReport.php @@ -0,0 +1,525 @@ +module = 'CustomReports'; + $this->action = 'getCustomReport'; + $this->order = 99; + + $this->actionToLoadSubTables = $this->action; + $request = Request::fromRequest(); + + $idSite = $request->getIntegerParameter('idSite', 0); + $idCustomReport = $request->getIntegerParameter('idCustomReport', 0); + $this->isFlat = $request->getIntegerParameter('flat', 0); + + if ( + !empty($idSite) + && ( Common::getRequestVar('actionToWidgetize', '', 'string') === 'previewReport' + || Common::getRequestVar('action', '', 'string') === 'previewReport') + && ( Common::getRequestVar('moduleToWidgetize', '', 'string') === 'CustomReports' + || Common::getRequestVar('module', '', 'string') === 'CustomReports') + ) { + $report = null; + Piwik::postEvent('CustomReports.buildPreviewReport', array(&$report)); + + $this->initCustomReport($report); + $this->doNotInitTwice = true; + } elseif (!empty($idCustomReport) && !empty($idSite)) { + // needed when this report is being rendered because viewDataTable would not find correct report. + $model = StaticContainer::get('Piwik\Plugins\CustomReports\Model\CustomReportsModel'); + $report = $model->getCustomReport($idSite, $idCustomReport); + if (!empty($report)) { + $this->initCustomReport($report); + } + } + } + + public function isEnabled() + { + if (!empty($this->dimension) && !$this->dimension->isAnonymousAllowed() && Piwik::isUserIsAnonymous()) { + return false; + } + return parent::isEnabled(); + } + + public function render() + { + if (!empty($this->dimension) && !$this->dimension->isAnonymousAllowed()) { + Piwik::checkUserIsNotAnonymous(); + } + return parent::render(); + } + + public function initCustomReport($report, DataTable\DataTableInterface $table = null) + { + if ($this->doNotInitTwice) { + return; + } + + $this->customReport = $report; + + $this->reportType = ReportType::factory($report['report_type']); + + $this->categoryId = $report['category']['id']; + $this->name = $report['name']; + $this->documentation = $report['description']; + $this->metrics = $report['metrics']; + $this->metricSemanticTypes = []; + $this->processedMetrics = array(); + + // TODO we may need to adjust defaultSortColumn if it was ignored by report builder eg if metric is actually + // not joinable anymore or does not exist anymore + $this->defaultSortColumn = reset($report['metrics']); + $this->order = 0; + + if (!empty($report['subcategory']['id'])) { + $this->subcategoryId = $report['subcategory']['id']; + if ($report['subcategory_order'] != 9999999) { + $this->order = $report['subcategory_order']; + } + } elseif (!empty($report['category']['id']) && $report['category']['id'] === CustomReportsDao::DEFAULT_CATEGORY) { + $this->subcategoryId = $report['idcustomreport']; + } else { + // when the category is not specified and does not go into the custom reports category, we use the report name + // as the category ID. Otherwise we may add eg for a reportId 19 a subcategory to Goals with the ID 19. + // when there a goal exists with ID 19 as well, we would accidentally put the report on the page of the goal. + $this->subcategoryId = $report['name']; + } + + $idSubtable = $this->getSelectedSubtableId(); + + $this->dimension = null; + if (empty($idSubtable) && !empty($report['dimensions'][0])) { + $this->dimension = $this->getDimensionInstance($report['dimensions'][0]); + } elseif (!empty($idSubtable)) { + $this->tryToSetDimensionBasedOnTable($table); + + if (empty($this->dimension) && isset($report['dimensions'][1])) { + // we assume second dimension + $this->dimension = $this->getDimensionInstance($report['dimensions'][1]); + } + } + + $factory = MetricsList::get(); + + $this->metricTranslations = array(); + $period = Common::getRequestVar('period', '', 'string'); + foreach ($report['metrics'] as $metric) { + $metricInstance = $factory->getMetric($metric); + if ( + !empty($metricInstance) && + ($metricInstance instanceof ProcessedMetric || $metricInstance instanceof ArchivedMetric) + ) { + if ($period != 'day' && stripos($metricInstance->getTranslatedName(), 'unique') === 0) { + $metricInstance->setDocumentation(Piwik::translate( + 'CustomReports_CommonUniqueMetricDescription', + array($metricInstance->getDocumentation(), ucwords($metricInstance->getTranslatedName()), str_ireplace('Unique ', '', ucwords($metricInstance->getTranslatedName()))) + )); + $metricInstance->setTranslatedName(Piwik::translate('CustomReports_CommonUniqueMetric', array($metricInstance->getTranslatedName()))); + } + $this->processedMetrics[] = $metricInstance; + $this->metricTranslations[$metric] = $metricInstance->getTranslatedName(); + } + + if ($metricInstance instanceof Plugin\Metric && method_exists($metricInstance, 'getSemanticType')) { + $this->metricSemanticTypes[$metric] = $metricInstance->getSemanticType(); + } else { + $this->metricSemanticTypes[$metric] = null; + } + } + if (!empty($report['idcustomreport'])) { + $this->parameters = array('idCustomReport' => $report['idcustomreport']); + } else { + $this->parameters = array(); + } + } + + private function getSelectedSubtableId() + { + return Common::getRequestVar('idSubtable', 0, 'int'); + } + + public function getMetrics() + { + return $this->metricTranslations; + } + + private function tryToSetDimensionBasedOnTable(DataTable\DataTableInterface $table = null) + { + if (!empty($table)) { + $level = $this->detectSubtableLevelOnTable($table); + if (!empty($level) && isset($this->customReport['dimensions'][$level - 1])) { + $this->dimension = $this->getDimensionInstance($this->customReport['dimensions'][$level - 1]); + } + } + } + + private function detectSubtableLevelOnTable(DataTable\DataTableInterface $table) + { + $row = $table->getFirstRow(); + + if ($row && $row instanceof DataTable\DataTableInterface) { + $row = $row->getFirstRow(); + } + + if ($row && $row instanceof DataTable\Row) { + $level = $row->getColumn('level'); + if (!empty($level)) { + return $level; + } + } + } + + public function getDimensionInstance($dimensionName) + { + $factory = StaticContainer::get('Piwik\Columns\DimensionsProvider'); + return $factory->factory($dimensionName); + } + + public function getDefaultTypeViewDataTable() + { + if ($this->reportType) { + return $this->reportType->getDefaultViewDataTable(); + } + + return parent::getDefaultTypeViewDataTable(); + } + + public function alwaysUseDefaultViewDataTable() + { + if ($this->reportType) { + return $this->reportType->alwaysUseDefaultViewDataTable(); + } + + return parent::alwaysUseDefaultViewDataTable(); + } + + public function configureWidgets(WidgetsList $widgetsList, ReportWidgetFactory $factory) + { + if ($this->categoryId && $this->subcategoryId && $this->reportType) { + $widget = $factory->createWidget(); + $widget->setAction($this->reportType->getRenderAction()); + $widgetsList->addWidgetConfig($widget); + } + } + + public function configureView(ViewDataTable $view) + { + $dimensionName = 'Label'; + + if (!empty($this->dimension)) { + $dimensionName = $this->dimension->getName(); + $view->config->addTranslations(array('label' => $dimensionName)); + } + + // The insights API is currently hardcoded to use 'nb_visits', and doesn't work + // if we don't have 'nb_visits' included. + if (!in_array('nb_visits', $this->metrics, true)) { + $view->config->show_insights = false; + } + + $view->config->show_flatten_table = true; + + $subdimension = $this->getSubtableDimension(); + if (!empty($subdimension)) { + $view->config->show_pivot_by_subtable = true; + $view->config->pivot_by_dimension = $subdimension->getId(); + $view->config->pivot_by_column = $this->metrics[0]; + } else { + $view->config->show_pivot_by_subtable = false; + } + + $metricsList = MetricsList::get(); + + foreach ($this->metrics as $metric) { + $metricInstance = $metricsList->getMetric($metric); + if (!empty($metricInstance) && $metricInstance->getDocumentation()) { + $view->config->metrics_documentation[$metricInstance->getName()] = $metricInstance->getDocumentation(); + } + if (!empty($metricInstance) && $metricInstance->getTranslatedName()) { + $view->config->addTranslation($metricInstance->getName(), $metricInstance->getTranslatedName()); + } + } + + $self = $this; + $report = $this->customReport; + $request = Request::fromRequest(); + $idSite = $request->getIntegerParameter('idSite', 0); + $view->config->filters[] = function (DataTable $table) use ($self, $view, $report, $metricsList, $idSite) { + // we fix the dimension based on the data table content. + $self->tryToSetDimensionBasedOnTable($table); + + $dimension = $self->getDimension(); + + if (!empty($dimension)) { + $view->config->addTranslations(array('label' => $dimension->getName())); + } + + // make urls clickable... + if ($dimension && $dimension->getType() === Dimension::TYPE_URL && (!$this->isFlat || (count($self->customReport['dimensions']) === 1))) { + $table->filter('ColumnCallbackAddMetadata', array('label', 'url', function ($label) { + if ($label === Archiver::LABEL_NOT_DEFINED) { + return false; + } + return $label; + })); + } + + if ($table->getRowsCount() === 0 && $table->getMetadata('period')) { + $period = $table->getMetadata('period'); + if ($period instanceof Period) { + /** @var Period $period */ + $reportStartDate = $period->getDateStart()->getTimestampUTC(); + + if ( + (!empty($report['created_date']) && $reportStartDate < Date::factory($report['created_date'])->getTimestamp()) + || (!empty($report['updated_date']) && $reportStartDate < Date::factory($report['updated_date'])->getTimestamp()) + ) { + $view->config->no_data_message = Piwik::translate('CoreHome_ThereIsNoDataForThisReport') . ' ' . Piwik::translate('CustomReports_NoDataNotArchivedYet'); + + if (!Plugin\Manager::getInstance()->isPluginActivated('Cloud')) { + $view->config->no_data_message .= ' ' . Piwik::translate( + 'CustomReports_NoDataNotArchivedYetReprocess', + ['', ''] + ); + } + } + } + } else { + $periodLabel = Common::getRequestVar('period', '', 'string'); + // report types reading numeric records (eg the KPI card sparkline) support unique + // metrics for the same periods as evolution graphs + $isEvolution = $view->isViewDataTableId(Evolution::ID) + || ($this->reportType && $this->reportType->usesNumericRecords()); + + if (!self::supportsUniqueMetric($periodLabel, $isEvolution)) { + $removed = array_intersect(self::$RAW_DATA_UNIQUE_METRICS, $view->config->columns_to_display); + $removed2 = array_intersect(self::$RAW_DATA_UNIQUE_METRICS, (property_exists($view->config, 'selectable_columns') && is_array($view->config->selectable_columns)) ? $view->config->selectable_columns : array()); + if (!empty($removed) || !empty($removed2)) { + $view->config->columns_to_display = array_diff($view->config->columns_to_display, self::$RAW_DATA_UNIQUE_METRICS); + + if ($isEvolution || $view->isViewDataTableId(Bar::ID) || $view->isViewDataTableId(Pie::ID)) { + $period = Common::getRequestVar('period', '', 'string'); + if (!GetCustomReport::supportsUniqueMetric($period, true)) { + /** @var Evolution $view */ + if (is_array($view->config->selectable_columns)) { + $view->config->selectable_columns = array_diff($view->config->selectable_columns, GetCustomReport::$RAW_DATA_UNIQUE_METRICS); + } + } + } + + $removedHumanReadable = array(); + foreach ($removed as $metricName) { + $foundMetric = $metricsList->getMetric($metricName); + if ($foundMetric) { + $removedHumanReadable[] = $foundMetric->getTranslatedName(); + } elseif (is_string($metricName)) { + $removedHumanReadable[] = $metricName; + } + } + + if (empty($view->config->columns_to_display)) { + $view->config->show_header_message = Piwik::translate('CustomReports_NoDataRemovedMetrics', implode(', ', $removedHumanReadable)); + } elseif ($view->config->columns_to_display == array('label')) { + $view->config->show_header_message = Piwik::translate('CustomReports_NoDataRemovedMetrics', implode(', ', $removedHumanReadable)); + $view->config->columns_to_display = array(); + $view->config->show_pagination_control = false; + $view->config->show_offset_information = false; + } else { + if (empty($view->config->show_footer_message)) { + $view->config->show_footer_message = ''; + } else { + $view->config->show_footer_message .= ' '; + } + $view->config->show_footer_message .= Piwik::translate('CustomReports_RemovedMetrics', implode(', ', $removedHumanReadable)); + } + } + } + } + if (!empty($report['status']) && $report['status'] === CustomReportsModel::STATUS_PAUSED) { + $url = Url::getCurrentQueryStringWithParametersModified(array( + 'module' => 'CustomReports', + 'action' => 'manage' + )); + $view->config->no_data_message = Piwik::translate('CustomReports_NoDataMessagePausedStateAdminUser', ['', '']); + $idSitesToCheck = !empty($report['multiple_idsites']) ? explode(',', $report['multiple_idsites']) : array($idSite); + foreach ($idSitesToCheck as $idSite) { + if (!$this->getValidator()->canWrite($idSite)) { + $view->config->no_data_message = Piwik::translate('CustomReports_NoDataMessagePausedStateNonAdminUser'); + break; + } + } + } + }; + + $idCustomReport = Common::getRequestVar('idCustomReport', 0, 'int'); + $idSubtable = $this->getSelectedSubtableId(); + + if (!empty($this->customReport['segment_filter']) && empty($idSubtable)) { + // we do not show it in subtable gain... + $segmentFormatter = StaticContainer::get('Piwik\Plugins\SegmentEditor\SegmentFormatter'); + + try { + $segmentDefinition = $segmentFormatter->getHumanReadable($this->customReport['segment_filter'], $idSite); + $view->config->show_footer_message = Piwik::translate('CustomReports_ReportHasFilterApplied', Common::sanitizeInputValue($segmentDefinition)); + } catch (\Exception $e) { + // may fail when a configured segment is no longer available + } + } + + $view->config->filters[] = function (DataTable $table) { + $table->filter('ReplaceSummaryRowLabel'); + $table->setLabelsHaveChanged(); + }; + + $view->config->show_table_all_columns = false; + $view->config->show_exclude_low_population = false; + + $pivotBy = Common::getRequestVar('pivotBy', false); + if (empty($pivotBy)) { + if ( + $view->isViewDataTableId(Pie::ID) + || $view->isViewDataTableId(Bar::ID) + ) { + /** @var Pie $view */ + $selectableColumns = array_values($this->metrics); + $view->config->selectable_columns = $selectableColumns; + $view->config->columns_to_display = array(reset($selectableColumns)); + } else { + $view->config->columns_to_display = array_merge(array('label'), $this->metrics); + } + } + + if (!empty($idCustomReport)) { + $view->config->report_id = 'CustomReports.getCustomReport_idCustomReport--' . $idCustomReport; + $view->requestConfig->request_parameters_to_modify['idCustomReport'] = $idCustomReport; + $view->requestConfig->request_parameters_to_modify['reportUniqueId'] = 'CustomReports_getCustomReport_idCustomReport--' . $idCustomReport; + + if ($this->getValidator()->canWrite($idSite)) { + $view->config->title_edit_entity_url = 'index.php' . Url::getCurrentQueryStringWithParametersModified(array( + 'module' => 'CustomReports', + 'action' => 'manage' + )) . '#?idCustomReport=' . (int)$idCustomReport; + } + } + + // Is this an evolution graph. If it is, we need to override the controller action so the right API call is made + if ($view->isViewDataTableId(Evolution::ID) && !in_array(Common::getRequestVar('action', '', 'string'), ['getMultiRowEvolutionPopover', 'getRowEvolutionPopover', 'getRowEvolutionGraph'])) { + $view->config->controllerAction = 'getEvolutionGraph'; + } + + if (!$this->isFlat && !empty($this->customReport['dimensions']) && is_array($this->customReport['dimensions']) && count($this->customReport['dimensions']) > 3) { + // add a css class to update the width of table for hierarchical, when dimensions are > 3 to resolve scrolling issue + $view->config->datatable_css_class = 'customReportsHigherDimensions'; + } + } + + public static function supportsUniqueMetric($period, $isEvolutionGraph) + { + $config = StaticContainer::get(Configuration::class); + if ($config->shouldAlwaysShowUniqueVisitors()) { + return true; + } + + if ($isEvolutionGraph) { + return SettingsPiwik::isUniqueVisitorsEnabled($period); + } + + return $period === 'day'; + } + + public function getSubtableDimension() + { + return $this->getSecondLeveltableDimension(); + } + + public function getSecondLeveltableDimension() + { + if (isset($this->customReport['dimensions'][1])) { + return $this->getDimensionInstance($this->customReport['dimensions'][1]); + } + } + + public function getThirdLeveltableDimension() + { + if (isset($this->customReport['dimensions'][2])) { + return $this->getDimensionInstance($this->customReport['dimensions'][2]); + } + } + + public function getNthLevelTableDimension(int $level): ?Dimension + { + if (isset($this->customReport['dimensions'][$level])) { + return $this->getDimensionInstance($this->customReport['dimensions'][$level]); + } + + return null; + } + + private function getValidator() + { + return StaticContainer::get('Piwik\Plugins\CustomReports\Input\Validator'); + } +} diff --git a/files/plugin-CustomReports-5.5.0/Glossary.php b/files/plugin-CustomReports-5.5.0/Glossary.php new file mode 100644 index 0000000..d92794e --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Glossary.php @@ -0,0 +1,76 @@ +api = $api; + } + + public function getMetricsAndDimensions() + { + $result = []; + + $allDimensions = Request::processRequest('CustomReports.getAvailableDimensions'); + foreach ($allDimensions as $categoryId => $category) { + foreach ($category['dimensions'] as $dimension) { + $result[] = [ + 'name' => $dimension['name'], + 'subtitle' => 'CustomReports_Dimension', + 'documentation' => '', + 'id' => $dimension['uniqueId'], + ]; + } + } + + $allMetrics = Request::processRequest('CustomReports.getAvailableMetrics'); + foreach ($allMetrics as $categoryId => $category) { + foreach ($category['metrics'] as $metric) { + $result[] = [ + 'name' => $metric['name'], + 'subtitle' => 'General_Metric', + 'documentation' => Common::sanitizeInputValue($metric['description']), // uses |raw in twig template + 'id' => $metric['uniqueId'], + 'is_metric' => true, + ]; + } + } + + usort($result, function ($a, $b) { + return strcmp($this->cleanName($a['name']), $this->cleanName($b['name'])); + }); + + return $result; + } + + private function cleanName($name) + { + $name = preg_replace('/[\'"]/', '', $name); + $name = strtolower($name); + return $name; + } +} diff --git a/files/plugin-CustomReports-5.5.0/Input/Category.php b/files/plugin-CustomReports-5.5.0/Input/Category.php new file mode 100644 index 0000000..5ff0053 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Input/Category.php @@ -0,0 +1,51 @@ +category = $categoryId; + } + + public function check() + { + $title = 'CustomReports_ReportCategory'; + + if (empty($this->category)) { + $title = Piwik::translate($title); + throw new Exception(Piwik::translate('CustomReports_ErrorXNotProvided', $title)); + } + + if (Common::mb_strlen($this->category) > static::MAX_LENGTH) { + $title = Piwik::translate($title); + throw new Exception(Piwik::translate('CustomReports_ErrorXTooLong', array($title, static::MAX_LENGTH))); + } + } +} diff --git a/files/plugin-CustomReports-5.5.0/Input/Description.php b/files/plugin-CustomReports-5.5.0/Input/Description.php new file mode 100644 index 0000000..bcf3043 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Input/Description.php @@ -0,0 +1,49 @@ +description = $description; + } + + public function check() + { + if (empty($this->description)) { + // no description is valid + return; + } + + if (Common::mb_strlen($this->description) > self::MAX_LENGTH) { + $title = Piwik::translate('General_Description'); + throw new Exception(Piwik::translate('CustomReports_ErrorXTooLong', array($title, static::MAX_LENGTH))); + } + } +} diff --git a/files/plugin-CustomReports-5.5.0/Input/Dimensions.php b/files/plugin-CustomReports-5.5.0/Input/Dimensions.php new file mode 100644 index 0000000..9648fa8 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Input/Dimensions.php @@ -0,0 +1,99 @@ +dimensions = $dimensions; + $this->idSite = $idSite; + } + + public function check() + { + if (empty($this->dimensions)) { + return; // it may be empty + } + + $title = Piwik::translate('CustomReports_Dimensions'); + + if (!is_array($this->dimensions)) { + throw new Exception(Piwik::translate('CustomReports_ErrorNotAnArray', $title)); + } + + $validateDimensionsExist = true; + if (empty($this->idSite) || $this->idSite === 'all') { + $configuration = StaticContainer::get('Piwik\Plugins\CustomReports\Configuration'); + $validateDimensionsExist = $configuration->shouldValidateReportContentWhenAllSites(); + } + + $dimensionProvider = StaticContainer::get('Piwik\Columns\DimensionsProvider'); + $availableDimensions = $this->getAvailableDimensionsList(); + foreach ($this->dimensions as $index => $dimension) { + if (empty($dimension)) { + throw new Exception(Piwik::translate('CustomReports_ErrorArrayMissingItem', array($title, $index))); + } + + if ($validateDimensionsExist && !$dimensionProvider->factory($dimension)) { + throw new Exception(Piwik::translate('CustomReports_ErrorInvalidValueInArray', array($title, $index))); + } + + if ($validateDimensionsExist && !isset($availableDimensions[$dimension])) { + throw new Exception(Piwik::translate('CustomReports_ErrorInvalidDimension', array($dimension))); + } + } + + if (count($this->dimensions) !== count(array_unique($this->dimensions))) { + $title = Piwik::translate('CustomReports_Dimension'); + throw new Exception(Piwik::translate('CustomReports_ErrorDuplicateItem', $title)); + } + } + + private function getAvailableDimensionsList() + { + $dimensions = API::getInstance()->getAvailableDimensions($this->idSite); + $list = []; + + if (!empty($dimensions)) { + foreach ($dimensions as $values) { + if (!empty($values['dimensions'])) { + foreach ($values['dimensions'] as $dimension) { + $list[$dimension['uniqueId']] = $dimension['name']; + } + } + } + } + + return $list; + } +} diff --git a/files/plugin-CustomReports-5.5.0/Input/Metrics.php b/files/plugin-CustomReports-5.5.0/Input/Metrics.php new file mode 100644 index 0000000..12306f7 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Input/Metrics.php @@ -0,0 +1,76 @@ +metrics = $metrics; + $this->idSite = $idSite; + } + + public function check() + { + if (empty($this->metrics)) { + return; // it may be empty + } + + $title = Piwik::translate('General_Metrics'); + + if (!is_array($this->metrics)) { + throw new Exception(Piwik::translate('CustomReports_ErrorNotAnArray', $title)); + } + + $validateMetricsExist = true; + if (empty($this->idSite) || $this->idSite === 'all') { + $configuration = StaticContainer::get('Piwik\Plugins\CustomReports\Configuration'); + $validateMetricsExist = $configuration->shouldValidateReportContentWhenAllSites(); + } + + $metricsList = MetricsList::get(); + + foreach ($this->metrics as $index => $metric) { + if (empty($metric)) { + throw new Exception(Piwik::translate('CustomReports_ErrorArrayMissingItem', array($title, $index))); + } + if ($validateMetricsExist && !$metricsList->getMetric($metric)) { + throw new Exception(Piwik::translate('CustomReports_ErrorInvalidValueInArray', array($title, $index))); + } + } + + if (count($this->metrics) !== count(array_unique($this->metrics))) { + $title = Piwik::translate('General_Metric'); + throw new Exception(Piwik::translate('CustomReports_ErrorDuplicateItem', $title)); + } + } +} diff --git a/files/plugin-CustomReports-5.5.0/Input/Name.php b/files/plugin-CustomReports-5.5.0/Input/Name.php new file mode 100644 index 0000000..0aa2e91 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Input/Name.php @@ -0,0 +1,51 @@ +name = $name; + } + + public function check() + { + $title = 'CustomReports_ReportName'; + + if (empty($this->name)) { + $title = Piwik::translate($title); + throw new Exception(Piwik::translate('CustomReports_ErrorXNotProvided', $title)); + } + + if (Common::mb_strlen($this->name) > static::MAX_LENGTH) { + $title = Piwik::translate($title); + throw new Exception(Piwik::translate('CustomReports_ErrorXTooLong', array($title, static::MAX_LENGTH))); + } + } +} diff --git a/files/plugin-CustomReports-5.5.0/Input/ReportType.php b/files/plugin-CustomReports-5.5.0/Input/ReportType.php new file mode 100644 index 0000000..52656a1 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Input/ReportType.php @@ -0,0 +1,45 @@ +reportType = $reportType; + } + + public function check() + { + $title = 'CustomReports_ReportType'; + + if (empty($this->reportType)) { + $title = Piwik::translate($title); + throw new Exception(Piwik::translate('CustomReports_ErrorXNotProvided', $title)); + } + + \Piwik\Plugins\CustomReports\ReportType\ReportType::factory($this->reportType); + } +} diff --git a/files/plugin-CustomReports-5.5.0/Input/SegmentFilter.php b/files/plugin-CustomReports-5.5.0/Input/SegmentFilter.php new file mode 100644 index 0000000..ac96418 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Input/SegmentFilter.php @@ -0,0 +1,47 @@ +segment = $segmentFilter; + $this->idSites = $idSites; + } + + public function check() + { + if (empty($this->segment)) { + return; // it may be empty + } + + new Segment($this->segment, $this->idSites); + } +} diff --git a/files/plugin-CustomReports-5.5.0/Input/Subcategory.php b/files/plugin-CustomReports-5.5.0/Input/Subcategory.php new file mode 100644 index 0000000..29c66dd --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Input/Subcategory.php @@ -0,0 +1,49 @@ +subcategory = $subcategoryId; + } + + public function check() + { + if (empty($this->subcategory)) { + return; // may be empty + } + $title = 'CustomReports_ReportSubcategory'; + + if (!empty($this->subcategory) && Common::mb_strlen($this->subcategory) > static::MAX_LENGTH) { + $title = Piwik::translate($title); + throw new Exception(Piwik::translate('CustomReports_ErrorXTooLong', array($title, static::MAX_LENGTH))); + } + } +} diff --git a/files/plugin-CustomReports-5.5.0/Input/Validator.php b/files/plugin-CustomReports-5.5.0/Input/Validator.php new file mode 100644 index 0000000..b402715 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Input/Validator.php @@ -0,0 +1,122 @@ +supportsMethod('checkUserHasSomeWriteAccess')) { + // since Matomo 3.6.0 + Piwik::checkUserHasSomeWriteAccess(); + return; + } + + Piwik::checkUserHasSomeAdminAccess(); + } + + public function checkWritePermission($idSite) + { + if (self::isAllWebsitesRequest($idSite)) { + Piwik::checkUserHasSuperUserAccess(); + } else { + $this->checkSiteExists($idSite); + + if ($this->supportsMethod('checkUserHasWriteAccess')) { + // since Matomo 3.6.0 + Piwik::checkUserHasWriteAccess($idSite); + return; + } + + Piwik::checkUserHasAdminAccess($idSite); + } + } + + public function checkSitesDuplicationPermission($idSites) + { + if (count($idSites) < 1) { + throw new \Exception(Piwik::translate('General_Required', ['idSites'])); + } + + Piwik::checkUserHasWriteAccess($idSites); + // Check if any of the specified sites are rollups + $rollupSiteNames = []; + foreach ($idSites as $idSite) { + if (Site::getTypeFor($idSite) === 'rollup') { + $rollupSiteNames[] = Site::getNameFor($idSite); + } + } + + if (count($rollupSiteNames) > 0) { + throw new \Exception(Piwik::translate( + 'CustomReports_CustomReportDuplicationSiteTypeError', + ['\'' . implode('\', \'', $rollupSiteNames) . '\''] + )); + } + } + + public static function isAllWebsitesRequest($idSite) + { + return $idSite === 0 || $idSite === '0' || $idSite === 'all' || $idSite === false; + } + + public function checkReportViewPermission($idSite) + { + $this->checkSiteExists($idSite); + Piwik::checkUserHasViewAccess($idSite); + } + + public function checkSiteExists($idSite) + { + if (self::isAllWebsitesRequest($idSite)) { + return; + } + + new Site($idSite); + } + + public function canViewReport($idSite) + { + if (empty($idSite)) { + return false; + } + + return Piwik::isUserHasViewAccess($idSite); + } + + public function canWrite($idSite) + { + if (empty($idSite)) { + return false; + } + + if ($this->supportsMethod('isUserHasWriteAccess')) { + // since Matomo 3.6.0 + return Piwik::isUserHasWriteAccess($idSite); + } + + return Piwik::isUserHasAdminAccess($idSite); + } +} diff --git a/files/plugin-CustomReports-5.5.0/LICENSE b/files/plugin-CustomReports-5.5.0/LICENSE new file mode 100644 index 0000000..4686f35 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/LICENSE @@ -0,0 +1,49 @@ +InnoCraft License + +This InnoCraft End User License Agreement (the "InnoCraft EULA") is between you and InnoCraft Ltd (NZBN 6106769) ("InnoCraft"). If you are agreeing to this Agreement not as an individual but on behalf of your company, then "Customer" or "you" means your company, and you are binding your company to this Agreement. InnoCraft may modify this Agreement from time to time, subject to the terms in Section (xii) below. + +By clicking on the "I’ve read and accept the terms & conditions (https://shop.matomo.org/terms-conditions/)" (or similar button) that is presented to you at the time of your Order, or by using or accessing InnoCraft products, you indicate your assent to be bound by this Agreement. + + +InnoCraft EULA + +(i) InnoCraft is the licensor of the Plugin for Matomo Analytics (the "Software"). + +(ii) Subject to the terms and conditions of this Agreement, InnoCraft grants you a limited, worldwide, non-exclusive, non-transferable and non-sublicensable license to install and use the Software only on hardware systems owned, leased or controlled by you, during the applicable License Term. The term of each Software license ("License Term") will be specified in your Order. Your License Term will end upon any breach of this Agreement. + +(iii) Unless otherwise specified in your Order, for each Software license that you purchase, you may install one production instance of the Software in a Matomo Analytics instance owned or operated by you, and accessible via one URL ("Matomo instance"). Additional licenses must be purchased in order to deploy the Software in multiple Matomo instances, including when these multiple Matomo instances are hosted on a single hardware system. + +(iv) Licenses granted by InnoCraft are granted subject to the condition that you must ensure the maximum number of Authorized Users and Authorized Sites that are able to access and use the Software is equal to the number of User and Site Licenses for which the necessary fees have been paid to InnoCraft for the Subscription period. You may upgrade your license at any time on payment of the appropriate fees to InnoCraft in order to increase the maximum number of authorized users or sites. The number of User and Site Licenses granted to you is dependent on the fees paid by you. “User License” means a license granted under this EULA to you to permit an Authorized User to use the Software. “Authorized User” means a person who has an account in the Matomo instance and for which the necessary fees (“Subscription fees”) have been paid to InnoCraft for the current license term. "Site License" means a license granted under this EULA to you to permit an Authorized Site to use the Matomo Marketplace Plugin. “Authorized Sites” means a website or a measurable within Matomo instance and for which the necessary fees (“Subscription fees”) have been paid to InnoCraft for the current license term. These restrictions also apply if you install the Matomo Analytics Platform as part of your WordPress. + +(v) Piwik Analytics was renamed to Matomo Analytics in January 2018. The same terms and conditions as well as any restrictions or grants apply if you are using any version of Piwik. + +(vi) The Software requires a license key in order to operate, which will be delivered to the email addresses specified in your Order when we have received payment of the applicable fees. + +(vii) Any information that InnoCraft may collect from you or your device will be subject to InnoCraft Privacy Policy (https://www.innocraft.com/privacy). + +(viii) You are bound by the Matomo Marketplace Terms and Conditions (https://shop.matomo.org/terms-conditions/). + +(ix) You may not reverse engineer or disassemble or re-distribute the Software in whole or in part, or create any derivative works from or sublicense any rights in the Software, unless otherwise expressly authorized in writing by InnoCraft. + +(x) The Software is protected by copyright and other intellectual property laws and treaties. InnoCraft own all title, copyright and other intellectual property rights in the Software, and the Software is licensed to you directly by InnoCraft, not sold. + +(xi) The Software is provided under an "as is" basis and without any support or maintenance. Nothing in this Agreement shall require InnoCraft to provide you with support or fixes to any bug, failure, mis-performance or other defect in The Software. InnoCraft may provide you, from time to time, according to his sole discretion, with updates of the Software. You hereby warrant to keep the Software up-to-date and install all relevant updates. InnoCraft shall provide any update free of charge. + +(xii) The Software is provided "as is", and InnoCraft hereby disclaim all warranties, including but not limited to any implied warranties of title, non-infringement, merchantability or fitness for a particular purpose. InnoCraft shall not be liable or responsible in any way for any losses or damage of any kind, including lost profits or other indirect or consequential damages, relating to your use of or reliance upon the Software. + +(xiii) We may update or modify this Agreement from time to time, including the referenced Privacy Policy and the Matomo Marketplace Terms and Conditions. If a revision meaningfully reduces your rights, we will use reasonable efforts to notify you (by, for example, sending an email to the billing or technical contact you designate in the applicable Order). If we modify the Agreement during your License Term or Subscription Term, the modified version will be effective upon your next renewal of a License Term. + + +About InnoCraft Ltd + +At InnoCraft Ltd, we create innovating quality products to grow your business and to maximize your success. + +Our software products are built on top of Matomo Analytics: the leading open digital analytics platform used by more than one million websites worldwide. We are the creators and makers of the Matomo Analytics platform. + + +Contact + +Email: contact@innocraft.com +Contact form: https://www.innocraft.com/#contact +Website: https://www.innocraft.com/ +Buy our products: Premium Features for Matomo Analytics https://plugins.matomo.org/premium diff --git a/files/plugin-CustomReports-5.5.0/Menu.php b/files/plugin-CustomReports-5.5.0/Menu.php new file mode 100644 index 0000000..3052ef5 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Menu.php @@ -0,0 +1,45 @@ +validator = $validator; + + parent::__construct(); + } + + public function configureAdminMenu(MenuAdmin $menu) + { + $idSite = Common::getRequestVar('idSite', 0, 'int'); + + if ($this->validator->canWrite($idSite)) { + $menu->addMeasurableItem('CustomReports_CustomReports', $this->urlForAction('manage'), $orderId = 42); + } + } +} diff --git a/files/plugin-CustomReports-5.5.0/Model/CustomReportsModel.php b/files/plugin-CustomReports-5.5.0/Model/CustomReportsModel.php new file mode 100644 index 0000000..eec62f0 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Model/CustomReportsModel.php @@ -0,0 +1,559 @@ +dao = $dao; + } + + public function createCustomReport($idSite, $name, $description, $reportType, $dimensions, $metrics, $segmentFilter, $categoryId, $subcategoryId, $createdDate, $multipleIdSites) + { + $this->forceValuesAllowedByReportType($reportType, $dimensions, $metrics); + + $this->validateReportValues($idSite, $name, $description, $reportType, $dimensions, $metrics, $segmentFilter, $categoryId, $subcategoryId, [], $multipleIdSites); + + $status = self::STATUS_ACTIVE; + + $idCustomReport = $this->dao->addCustomReport($idSite, $name, $description, $reportType, $dimensions, $metrics, $segmentFilter, $categoryId, $subcategoryId, $status, $createdDate, $multipleIdSites); + + return $idCustomReport; + } + + /** + * Removes values a report type does not support: dimensions for types that don't need them and + * metrics exceeding the type's maximum (only the first ones are kept). + */ + private function forceValuesAllowedByReportType($reportType, &$dimensions, &$metrics) + { + try { + $type = \Piwik\Plugins\CustomReports\ReportType\ReportType::factory($reportType); + } catch (Exception $e) { + return; // an invalid report type will be reported by validateReportValues() + } + + if (!$type->needsDimensions()) { + $dimensions = array(); + } + + $maxMetrics = $type->getMaxMetrics(); + if ($maxMetrics !== null && is_array($metrics) && count($metrics) > $maxMetrics) { + $metrics = array_slice(array_values($metrics), 0, $maxMetrics); + } + } + + private function areMetricsEqual($metricNew, $metricOld) + { + if (!is_array($metricNew) || !is_array($metricOld)) { + return false; + } + + if (count($metricNew) > count($metricOld)) { + // there are now more metrics in the new version... + return false; + } + + if (array_diff($metricNew, $metricOld) === array_diff($metricOld, $metricNew)) { + return true; // they are still the same metrics + } + + if (array_diff($metricNew, $metricOld) === array()) { + return true; // the new metric contains still all of the old metrics so we do not need to invalidate reports with a new revision + } + + return false; + } + + public function updateCustomReport($idSite, $idCustomReport, $name, $description, $reportType, $dimensions, $metrics, $segmentFilter, $categoryId, $subcategoryId, $updatedDate, $subCategoryReportIds, $multipleIdSites) + { + $this->forceValuesAllowedByReportType($reportType, $dimensions, $metrics); + + $this->validateReportValues($idSite, $name, $description, $reportType, $dimensions, $metrics, $segmentFilter, $categoryId, $subcategoryId, $subCategoryReportIds, $multipleIdSites); + + $report = $this->getCustomReportById($idCustomReport, $idSite); + + $revision = $report['revision']; + + if ( + $report['dimensions'] !== $dimensions + || $reportType !== $report['report_type'] + || $segmentFilter !== $report['segment_filter'] + || !$this->areMetricsEqual($metrics, $report['metrics']) + ) { + // we do not need to create a new revision if metrics only has a different order as we would still have all the data + $revision++; + } + + $columns = array( + 'idsite' => $idSite, + 'name' => $name, + 'description' => $description, + 'report_type' => $reportType, + 'dimensions' => $dimensions, + 'metrics' => $metrics, + 'segment_filter' => $segmentFilter, + 'subcategory' => $subcategoryId, + 'category' => $categoryId, + 'updated_date' => $updatedDate, + 'revision' => $revision, + 'multiple_idsites' => '', + ); + if (!empty($multipleIdSites)) { + $columns['idsite'] = -1; + $columns['multiple_idsites'] = $this->dao->serializeMultipleIdSites($multipleIdSites); + } + // idsite might change when configuring a report so we cannot use $idSite but need to use the currently stored + // idsite in order to update the report! + + $this->updateReportColumns($report['idsite'], $idCustomReport, $columns); + + if (!empty($subCategoryReportIds)) { + $this->updateSubCategoryReportsOrder($subCategoryReportIds, $idSite); + } + } + + /** + * @param $idSite + * @param $idCustomReport + * @return array|false + * @throws \Exception + */ + public function getCustomReport($idSite, $idCustomReport) + { + $report = $this->dao->getCustomReport($idSite, $idCustomReport); + return $this->enrichReport($report, false, true); + } + + /** + * @param $idCustomReport + * @param $idSite + * @return array|false + * @throws \Exception + */ + public function getCustomReportById($idCustomReport, $idSite) + { + $report = $this->dao->getCustomReportById($idCustomReport, $idSite); + return $this->enrichReport($report, false, true); + } + + /** + * @return array + */ + public function getAllCustomReportsForSite($idSite, $skipCategoryMetadata = false, $skipEnrich = false) + { + $reports = $this->dao->getCustomReports($idSite); + if ($skipEnrich) { + return $reports; + } + + return $this->enrichReports($reports, $skipCategoryMetadata); + } + + private function enrichReports($reports, $skipCategoryMetadata = false) + { + if (empty($reports)) { + return array(); + } + + foreach ($reports as $index => $report) { + $reports[$index] = $this->enrichReport($report, $skipCategoryMetadata); + } + + return $reports; + } + + private function enrichReport($report, $skipCategoryMetadata = false, $addChildReports = false) + { + if (empty($report)) { + return $report; + } + + if (empty($report['idsite'])) { + $report['site'] = array('id' => $report['idsite'], 'name' => Piwik::translate('General_MultiSitesSummary')); + } else { + $report['site'] = array('id' => $report['idsite'], 'name' => Site::getNameFor($report['idsite'])); + } + + if (!$skipCategoryMetadata) { + $category = $report['category']; + $report['category'] = $this->buildCategoryMetadata($category); + $report['subcategory'] = $this->buildSubcategoryMetadata($category, $report['subcategory']); + } + + if ($addChildReports) { + $report['child_reports'] = $this->dao->getChildReports($report['idsite'], $report['idcustomreport']); + + // Since child reports are added for single reports so add multiple_sites info for single reports only + $report['multipleIdSites'] = $report['multiple_idsites'] ? $this->getMultipleIdSitesInfo(explode(',', $report['multiple_idsites'])) : []; + } + + return $report; + } + + private function getCategoryList() + { + if (!$this->categoryList) { + $this->categoryList = CategoryList::get(); + } + return $this->categoryList; + } + + /** + * Consist API return with API.getWidgetMetadata and API.getReportingPages... + * @param string $categoryId + * @return array + */ + private function buildCategoryMetadata($categoryId) + { + if (empty($categoryId)) { + return array( + 'id' => CustomReportsDao::DEFAULT_CATEGORY, + 'name' => Piwik::translate(CustomReportsDao::DEFAULT_CATEGORY), + 'order' => 999, + 'icon' => '', + ); + } + + $category = $this->getCategoryList()->getCategory($categoryId); + + if (!empty($category)) { + $name = method_exists($category, 'getDisplayName') ? $category->getDisplayName() : Piwik::translate($category->getId()); + + return array( + 'id' => (string) $category->getId(), + 'name' => $name, + 'order' => $category->getOrder(), + 'icon' => $category->getIcon(), + ); + } + + return array( + 'id' => (string) $categoryId, + 'name' => Piwik::translate($categoryId), + 'order' => 999, + 'icon' => '', + ); + } + + /** + * Consist API return with API.getWidgetMetadata and API.getReportingPages... + * @param Subcategory|null $subcategory + * @return array + */ + private function buildSubcategoryMetadata($categoryId, $subcategoryId) + { + if (empty($subcategoryId)) { + return null; + } + + if (!empty($categoryId)) { + $category = $this->getCategoryList()->getCategory($categoryId); + } else { + $category = null; + } + + if (!empty($category)) { + $subcategory = $category->getSubcategory($subcategoryId); + + if (!empty($subcategory)) { + return array( + 'id' => (string) $subcategory->getId(), + 'name' => Piwik::translate($subcategory->getName()), + 'order' => $subcategory->getOrder(), + ); + } + } + + return array( + 'id' => (string) $subcategoryId, + 'name' => Piwik::translate((string) $subcategoryId), + 'order' => 999, + ); + } + + public function checkReportExists($idSite, $idCustomReport) + { + $report = $this->dao->getCustomReport($idSite, $idCustomReport); + + if (empty($report)) { + throw new Exception(Piwik::translate('CustomReports_ErrorReportDoesNotExist')); + } + } + + public function deactivateReport($idSite, $idCustomReport, $customReportName = '') + { + $columns = array('status' => self::STATUS_DELETED); + if (!empty($customReportName)) { + //Since the unique index is created using combination of idsite+name, just soft deleting will not allow to create a new form with deleted form name, so we just update the name too when marking the status as deleted + $columns['name'] = 'deleted-' . $idCustomReport . '-' . $customReportName; + } + $this->updateReportColumns($idSite, $idCustomReport, $columns); + } + + public function pauseReport($idSite, $idCustomReport, $customReportName = '') + { + $columns = array('status' => self::STATUS_PAUSED); + $this->updateReportColumns($idSite, $idCustomReport, $columns); + } + + public function resumeReport($idSite, $idCustomReport, $customReportName = '') + { + $columns = array('status' => self::STATUS_ACTIVE); + $this->updateReportColumns($idSite, $idCustomReport, $columns); + } + + protected function getCurrentDateTime() + { + return Date::now()->getDatetime(); + } + + private function validateReportValues($idSite, $name, $description, $reportType, $dimensions, $metrics, $segmentFilter, $categoryId, $subcategoryId, $subCategoryReportIds = [], $multipleIdSites = []) + { + $nameObj = new Name($name); + $nameObj->check(); + + $descriptionObj = new Description($description); + $descriptionObj->check(); + + $typeObj = new ReportType($reportType); + $typeObj->check(); + + $categoryObj = new Category($categoryId); + $categoryObj->check(); + + $subcategoryObj = new Subcategory($subcategoryId); + $subcategoryObj->check(); + + if (!empty($multipleIdSites)) { + foreach ($multipleIdSites as $multipleIdSite) { + $dimensionsObj = new Dimensions($dimensions, $multipleIdSite); + $dimensionsObj->check(); + + $metricsObj = new Metrics($metrics, $multipleIdSite); + $metricsObj->check(); + } + } else { + $dimensionsObj = new Dimensions($dimensions, $idSite); + $dimensionsObj->check(); + + $metricsObj = new Metrics($metrics, $idSite); + $metricsObj->check(); + } + + if (!empty($multipleIdSites)) { + $idSite = $multipleIdSites; + } elseif ($idSite === '0' || $idSite === 0 || $idSite === 'all') { + // just fetching some sites as we have to pass them to the segment selector + $idSite = Request::processRequest('SitesManager.getSitesIdWithAtLeastViewAccess'); + } elseif (!empty($idSite)) { + $idSite = array($idSite); + } + + $segment = new SegmentFilter($segmentFilter, $idSite); + $segment->check(); + + $type = \Piwik\Plugins\CustomReports\ReportType\ReportType::factory($reportType); + + if ($type->needsDimensions() && empty($dimensions)) { + throw new Exception(Piwik::translate('CustomReports_ErrorMissingDimension')); + } + + $maxDimensions = StaticContainer::get(Configuration::class)->getMaxDimensions(); + if (!empty($dimensions) && is_array($dimensions) && count($dimensions) > $maxDimensions) { + throw new Exception(Piwik::translate('CustomReports_ErrorTooManyDimension', $maxDimensions)); + } + + $maxMetrics = $type->getMaxMetrics(); + if ($maxMetrics !== null && !empty($metrics) && is_array($metrics) && count($metrics) > $maxMetrics) { + throw new Exception(Piwik::translate('CustomReports_ErrorTooManyMetrics', $maxMetrics)); + } + + if (!empty($subCategoryReportIds) && count($subCategoryReportIds) != count($this->dao->getReportIds($subCategoryReportIds))) { + throw new Exception(Piwik::translate('CustomReports_ErrorInvalidSubCategoryReport')); + } + + $this->validateQuery($idSite, $dimensions, $metrics, $segmentFilter, $reportType); + } + + public function deactivateReportsForSite($idSite) + { + foreach ($this->dao->getCustomReports($idSite) as $report) { + // getCustomReports also returns sites for "all websites"... we need to make sure to not delete those. + if (!empty($report['idsite']) && $report['idsite'] == $idSite) { + $this->deactivateReport($idSite, $report['idcustomreport'], $report['name']); + } + } + } + + private function updateReportColumns($idSite, $idCustomReport, $columns) + { + if (!isset($columns['updated_date'])) { + $columns['updated_date'] = $this->getCurrentDateTime(); + } + $this->dao->updateColumns($idSite, $idCustomReport, $columns); + } + + private function updateSubCategoryReportsOrder($subCategoryReportIds, $idSite) + { + if (!empty($subCategoryReportIds)) { + foreach ($subCategoryReportIds as $subCategoryReportIdIndex => $subCategoryReportId) { + $this->dao->updateReportOrder($subCategoryReportId, $subCategoryReportIdIndex, $idSite); + } + } + } + + private function getMultipleIdSitesInfo($idSites) + { + $sitesInfo = []; + + if (!empty($idSites)) { + foreach ($idSites as $idSite) { + if (empty($idSite)) { + $sitesInfo[] = array('idsite' => $idSite, 'name' => Piwik::translate('General_MultiSitesSummary')); + } else { + // If we don't do this, it will fail for admin users who can just view the edit-report, but has no access to all the sites + \Piwik\Access::doAsSuperUser(function () use ($idSite, &$sitesInfo) { + $sitesInfo[] = array('idsite' => $idSite, 'name' => Site::getNameFor($idSite)); + }); + } + } + } + + return $sitesInfo; + } + + private function validateQuery($idSites, $dimensions, $metrics, $segmentFilter, $reportType) + { + $idSite = $idSites[0]; + $report = [ + 'idsite' => $idSite, + 'dimensions' => $dimensions, + 'metrics' => $metrics, + 'segment_filter' => $segmentFilter, + 'report_type' => $reportType, + ]; + $recordBuilder = StaticContainer::getContainer()->make(\Piwik\Plugins\CustomReports\RecordBuilders\CustomReport::class, ['report' => $report]); + try { + $recordBuilder->aggregateReport($this->makeArchiveProcessor($idSite), $idSite, [], $isDryRun = true); + } catch (\Exception $exception) { + $message = $exception->getMessage(); + if (stripos($message, 'be joined for segmentation') !== false) { + $faqLink = Url::addCampaignParametersToMatomoLink('https://matomo.org/faq/custom-reports/how-to-resolve-errors-when-using-different-dimension-combinations-in-matomo/', null, null, 'App.CustomReports.validateQuery'); + $message = Piwik::translate('CustomReports_NoSegmentationJoinExceptionMessage', ['', '']); + } + throw new \Exception($message); + } + } + + private function makeArchiveProcessor($idSite, $date = 'today', $period = 'day', $archiveOnlyReport = []): ArchiveProcessor + { + $period = Period\Factory::build($period, $date); + $segment = new Segment('', array($idSite)); + + $params = new ArchiveProcessor\Parameters(new Site($idSite), $period, $segment); + if (!empty($archiveOnlyReport)) { + $params->setArchiveOnlyReport($archiveOnlyReport); + } + $writer = new ArchiveWriter($params); + $logAggregator = new LogAggregator($params); + $processor = new ArchiveProcessor($params, $writer, $logAggregator); + + return $processor; + } + + /** + * @return Metric[] + */ + public function getArchivableMetricsInReport(array $report): array + { + $metrics = array(); + + if (!empty($report['metrics'])) { + foreach ($report['metrics'] as $metric) { + $metrics = $this->getMetrics($metrics, $metric); + } + } + + return $metrics; + } + + /** + * @return Metric[] + */ + private function getMetrics(array $metricInstances, string $metricName): array + { + $metricsList = MetricsList::get(); + $metricInstance = $metricsList->getMetric($metricName); + + if ($metricInstance instanceof ArchivedMetric) { + if (!in_array($metricInstance, $metricInstances, $strict = true)) { + $metricInstances[] = $metricInstance; + } + } elseif ($metricInstance instanceof ProcessedMetric) { + $depMetrics = $metricInstance->getDependentMetrics(); + foreach ($depMetrics as $depMetric) { + $metricInstances = $this->getMetrics($metricInstances, $depMetric); + } + } + + return $metricInstances; + } +} diff --git a/files/plugin-CustomReports-5.5.0/README.md b/files/plugin-CustomReports-5.5.0/README.md new file mode 100644 index 0000000..6f7e089 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/README.md @@ -0,0 +1,93 @@ +# Matomo CustomReports Plugin + +## Description + +Every organisation measures success differently. [Custom Reports](https://matomo.org/guide/reporting-tools/custom-reports/) provide the flexibility to analyse user behaviour, content performance, and Ecommerce activity on your terms. + +Create reports, apply filters, and structure the output around your actual reporting needs rather than working backwards from predefined dashboards. + +**Need more granularity?** Increase the [number of dimensions](https://matomo.org/faq/on-premise/how-do-i-change-the-number-of-dimensions-shown-in-a-custom-report/) per report, adjust row limits, and tune performance for large datasets. + +### How Custom Reports work + +
+
+

Build reports that reflect your own goals. Choose from hundreds of metrics and dimensions - covering everything from page behaviour and form completions to checkout steps and content consumption.

+

Compare behaviours across different visitor segments and identify patterns that may otherwise remain hidden in broader reports.

+
+
+Build reports that reflect your own goals +
+
+ +#### Put recurring reports on autopilot + +
+
+

Automate report delivery and spend less time compiling updates. Schedule custom reports for automatic delivery via email or SMS, or extend delivery options to Microsoft Teams or Slack with integration plugins. The right people see the numbers they need, on a cadence that suits them.

+
+
+Put recurring reports on autopilot +
+
+ +#### Export and share data your way + +Export directly from the [Matomo interface](https://matomo.org/faq/how-to/faq_26727/), pull data via the [HTTP API](https://developer.matomo.org/api-reference/api), or query the underlying MySQL tables for advanced workflows. Use your custom report data in Looker Studio, [BI platforms](https://matomo.org/guide/apis/export-data-to-other-tools/), data warehouses or wherever your organisation's reporting already lives. + +Add reports as [dashboard widgets](https://matomo.org/faq/dashboards/create-dashboards-and-customise-widgets-and-layout/) to share focused views with specific teams, clients, or stakeholders with controlled access. + +#### Understand what happens before a conversion + +
+
+

Pair Event Tracking with Custom Reports to see which interactions (clicks, downloads, video plays, form completions) precede conversions. Identify where users drop off and treat those exit points as optimisation opportunities rather than lost causes.

+
+
+Understand what happens before a conversion +
+
+ +#### Built into the Matomo experience + +
+
+

Custom Reports extends what's already in Matomo, giving you more ways to explore and interpret your data.

+

Apply Segments to analyse specific audiences, use Row Evolution to track changes over time, Visitor Logs to investigate individual journeys, and pivot reports to compare data across multiple dimensions. Build custom dashboards to bring related reports together in one view.

+
+
+Built into the Matomo experience +Built into the Matomo experience +
+
+ +### Try Custom Reports Today + +No cap on reports. Create as many custom reports as you need for marketing, product, sales, finance, or executive audiences. There is no limit on the number of reports you can create and share. + +Start your 30-day free trial today. Full access to Custom Reports. No credit card required. + +### Custom Reports features +* Create compelling custom reports tailored to your needs without any developer knowledge in just seconds. +* Choose from over 200 dimensions and metrics. +* Supports several visualisations including evolution over time graphs, data tables, bar graph, pie chart, cloud chart, and others. +* Define a report filter which is applied on the raw data to show the data only for a subset of your visitors and users. +* Put your custom report on any existing reporting page or on its own reporting page. +* Superusers can make reports available for all websites. + +### Integrates with Matomo Analytics +* Enjoy features that are not supported by most standard Matomo reports such as pivoting. +* Drill down deeper and filter custom reports [with Segments](https://matomo.org/docs/segmentation/). +* View the evolution over time of any dimension value with [Row Evolution](https://matomo.org/docs/row-evolution/). +* View the segmented [Visitor Log](https://matomo.org/docs/real-time/#visitor-log) with just one click. +* Supports other typical Matomo features like flatten, search, and more. +* View your custom reports on the go with the [Matomo Mobile App](https://matomo.org/mobile/). +* Create unlimited number of reports. [No data limits](https://matomo.org/docs/data-limits/). +* Supports other premium features like [Media Analytics](https://plugins.matomo.org/MediaAnalytics), [Form Analytics](https://plugins.matomo.org/FormAnalytics) and [Activity Log](https://plugins.matomo.org/ActivityLog). + +### Export and API features +* Get automatic [email and sms reports](https://matomo.org/docs/email-reports/) for your custom reports, or send them to your colleagues or customers. +* [Embed](https://matomo.org/docs/embed-piwik-report/) the custom report widgets directly in your app, dashboard, or even TV screen! +* HTTP API to manage your custom reports. +* HTTP API to fetch and export all [Custom Reports](https://developer.matomo.org/api-reference/reporting-api#CustomReports). +* Get access to all the raw data via MySQL for 100% data ownership. diff --git a/files/plugin-CustomReports-5.5.0/RecordBuilders/CustomReport.php b/files/plugin-CustomReports-5.5.0/RecordBuilders/CustomReport.php new file mode 100644 index 0000000..90dd360 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/RecordBuilders/CustomReport.php @@ -0,0 +1,573 @@ +report = $report; + $this->dimension = $dimension; + $this->metricsList = MetricsList::get(); + $this->configuration = $configuration; + + $noOfDimensions = (!empty($this->report['dimensions']) ? count($this->report['dimensions']) : 0); + $this->maxRowsInTable = $this->configuration->getArchiveMaxRowsInMainTable($noOfDimensions); + $this->maxRowsInSubtable = $this->configuration->getArchiveMaxRowsInSubTable($noOfDimensions); + + $metrics = $this->getArchivableMetricsInReport(); + + $orderBy = null; + if (!empty($metrics)) { + $metricOrderBy = reset($metrics); + if (is_object($metricOrderBy)) { + $orderBy = $metricOrderBy->getName(); + } + } + + $this->columnToSortByBeforeTruncation = $orderBy; + + $this->columnAggregationOps = $this->getMetricAggregations(); + } + + public function getRecordMetadata(ArchiveProcessor $archiveProcessor): array + { + $report = $this->report; + + $periodLabel = $archiveProcessor->getParams()->getPeriod()->getLabel(); + $isMultiPeriod = $periodLabel != 'day'; + + $metricToSort = $this->columnToSortByBeforeTruncation; + if ($isMultiPeriod) { + $metricToSort = $this->getMetricToSortMultiPeriod(); + } + + $records = []; + + $record = Record::make(Record::TYPE_BLOB, Archiver::makeRecordName($report['idcustomreport'], $report['revision'])); + if ($metricToSort) { + $record->setColumnToSortByBeforeTruncation($metricToSort); + } + $records[] = $record; + + if (ReportType::factory($report['report_type'])->usesNumericRecords()) { + $metrics = $this->getArchivableMetricsInReport(); + foreach ($metrics as $metric) { + if ($isMultiPeriod && $this->shouldAggregateRawDataForMultiPeriodMetric($metric, $periodLabel)) { + continue; // these are aggregated in buildMultiplePeriod() manually + } + + $recordName = Archiver::makeEvolutionRecordName($report['idcustomreport'], $report['revision'], $metric->getName()); + $records[] = Record::make(Record::TYPE_NUMERIC, $recordName); + } + } + + return $records; + } + + // public for use in CustomReports\Controller.php + public function aggregate(ArchiveProcessor $archiveProcessor): array + { + $idSite = $archiveProcessor->getParams()->getSite()->getId(); + if (empty($idSite)) { + return []; + } + + $this->doneLevel = 0; + + $records = $this->aggregateReport($archiveProcessor, $idSite); + return $records; + } + + public function buildForNonDayPeriod(ArchiveProcessor $archiveProcessor): void + { + parent::buildForNonDayPeriod($archiveProcessor); + + if (!ReportType::factory($this->report['report_type'])->usesNumericRecords()) { + return; + } + + // handle metrics that must be aggregated from raw data for multiple periods (ie unique metrics) + $idSite = $archiveProcessor->getParams()->getSite()->getId(); + if (empty($idSite)) { + return; + } + + $periodLabel = $archiveProcessor->getParams()->getPeriod()->getLabel(); + $metrics = $this->getArchivableMetricsInReport(); + + $rawDataMetricNames = []; + foreach ($metrics as $metric) { + if ($this->shouldAggregateRawDataForMultiPeriodMetric($metric, $periodLabel)) { + $rawDataMetricNames[] = $metric->getName(); + } + } + + if (!empty($rawDataMetricNames)) { + $aggregatedRecords = $this->aggregateReport($archiveProcessor, $idSite, $rawDataMetricNames); + foreach ($aggregatedRecords as $name => $value) { + if ($value instanceof DataTable) { + Common::destroy($value); // not needed + $aggregatedRecords[$name] = null; + } + } + + $aggregatedRecords = array_filter($aggregatedRecords); + $archiveProcessor->insertNumericRecords($aggregatedRecords); + } + } + + public function aggregateReport(ArchiveProcessor $archiveProcessor, int $idSite, $onlyMetrics = array(), bool $isDryRun = false): array + { + $report = $this->report; + + /** @var DataTable[] $records */ + $records = []; + + /** @var Dimension[] $allDimensions */ + $allDimensions = array(); + + if (!empty($report['dimensions'])) { + foreach ($report['dimensions'] as $dimension) { + $columnInstance = $this->dimension->factory($dimension); + if (!empty($columnInstance)) { + $allDimensions[] = $columnInstance; + } else { + $columnInstance = $this->findNotFoundCustomDimensionManually($dimension, $idSite); + + if (!empty($columnInstance)) { + $allDimensions[] = $columnInstance; + } + } + } + } + + $metrics = $this->getArchivableMetricsInReport(); + + if (empty($metrics)) { + return $records; + } + + if ($report['report_type'] === Table::ID && empty($allDimensions)) { + // none of the orignally assigned dimensions exist anymore. no need to do anything + return $records; + } + + if (!empty($onlyMetrics)) { + foreach ($metrics as $index => $metric) { + if (!in_array($metric->getName(), $onlyMetrics)) { + unset($metrics[$index]); + } + } + $metrics = array_values($metrics); + } + + if (empty($metrics)) { + return $records; + } + + $record = new DataTable(); + + $executionPlan = new ExecutionPlan($allDimensions, $metrics); + $dimensionsPlan = $executionPlan->getDimensionsPlan(); + + foreach ($dimensionsPlan as $dimensionsGroup) { + $metricsPlan = $executionPlan->getMetricsPlanForGroup(); + + // for each group of dimensions, we need to resolve all the metrics in several queries + foreach ($metricsPlan as $metricsToFetch) { + $queryBuilder = new QueryBuilder($archiveProcessor->getLogAggregator(), $this->configuration, $archiveProcessor->getParams()); + + foreach ($dimensionsGroup['left'] as $index => $dimension) { + try { + /** @var Dimension $dimension */ + $queryBuilder->addDimension($dimension, false); + } catch (NotJoinableException $e) { + Log::info(sprintf('Ignored dimension %s in report %d as it is not joinable', $dimension->getId(), $report['idcustomreport'])); + } + } + + foreach ($dimensionsGroup['right'] as $index => $dimension) { + try { + /** @var Dimension $dimension */ + $queryBuilder->addDimension($dimension, true); + } catch (NotJoinableException $e) { + Log::info(sprintf('Ignored dimension %s in report %d as it is not joinable', $dimension->getId(), $report['idcustomreport'])); + } + } + + foreach ($metricsToFetch as $metric) { + try { + /** @var ArchivedMetric $metric */ + $queryBuilder->addMetric($metric); + } catch (NotJoinableException $e) { + Log::info(sprintf('Ignored metric %s in report %d as it is not joinable', $metric->getName(), $report['idcustomreport'])); + } + } + + if (!$queryBuilder->isValid()) { + continue; + } + + if (!empty($report['segment_filter'])) { + try { + $queryBuilder->addSegmentFilter($report['segment_filter'], $idSite); + } catch (\Exception $e) { + if (strpos($e->getMessage(), 'is not a supported segment') !== false) { + // eg a plugin was uninstalled etc + continue; + } + throw $e; + } + } + + $query = $queryBuilder->buildQuery(); + if ($isDryRun) { + continue; + } + + $db = $archiveProcessor->getLogAggregator()->getDb(); + + try { + $cursor = $db->query($query['sql'], $query['bind']); + } catch (\Exception $e) { + // we also need to check for the 'maximum statement execution time exceeded' text as the query might be + // aborted at different stages and we can't really know all the possible codes at which it may be aborted etc + if ($this->configuration->getMaxExecutionTime()) { + // handling this in the IF in here as it requires newer version of Matomo as those constants weren't defined before + // Matomo 3.12 or so + $isMaxExecutionTimeError = strpos($e->getMessage(), 'maximum statement execution time exceeded') !== false + || $db->isErrNo($e, DbMigration::ERROR_CODE_MAX_EXECUTION_TIME_EXCEEDED_QUERY_INTERRUPTED) + || $db->isErrNo($e, DbMigration::ERROR_CODE_MAX_EXECUTION_TIME_EXCEEDED_SORT_ABORTED); + if ($isMaxExecutionTimeError) { + $params = array( + 'sql' => $query['sql'], + 'bind' => $query['bind'], + 'segment' => $archiveProcessor->getParams()->getSegment()->getString(), + 'dateStart' => $archiveProcessor->getParams()->getDateStart()->getDatetime(), + 'dateEnd' => $archiveProcessor->getParams()->getDateEnd()->getDatetime(), + 'report' => $report, + ); + + /** + * @ignore + * @internal + */ + Piwik::postEvent('Live.queryMaxExecutionTimeExceeded', array($params)); + + throw new \Exception('Max execution time exceeded: The custom report ' . $report['idcustomreport'] . ' took too long to execute.', 1, $e); + } + } + // var_export($queryBuilder->getReportQuery()->getFrom()); + // var_export($queryBuilder->getReportQuery()->getSelect()); + // var_export($queryBuilder->getReportQuery()->getWhere()); + // echo $query['sql']; + throw $e; + } + + // we need to bring them in correct order again in case they were reversed + $this->makeRegularReport($record, $records, $dimensionsGroup['left'], $cursor, $idSite); + } + + $this->doneLevel = count($dimensionsGroup['left']); + } + + if ($isDryRun) { + return $records; + } + + $recordName = Archiver::makeRecordName($report['idcustomreport'], $report['revision']); + $records[$recordName] = $record; + return $records; + } + + private function findNotFoundCustomDimensionManually(string $dimensionId, int $idSite): ?CustomDimension + { + $manager = Plugin\Manager::getInstance(); + + if ( + strpos($dimensionId, 'CustomDimension.') === 0 + && $manager->isPluginActivated('CustomDimensions') + ) { + try { + $configuration = StaticContainer::get('Piwik\Plugins\CustomDimensions\Dao\Configuration'); + } catch (\Exception $e) { + // plugin does not have the class + return null; + } + + if (!$configuration || !method_exists($configuration, 'getCustomDimensionsForSite')) { + return null; + } + + $dimensions = $configuration->getCustomDimensionsForSite($idSite); + foreach ($dimensions as $dimension) { + if (!$dimension['active']) { + continue; + } + + $custom = new CustomDimension(); + $custom->initCustomDimension($dimension); + + if ($custom->getId() === $dimensionId) { + return $custom; + } + } + } + + return null; + } + + /** + * @deprecated Moved to the model + * @return ArchivedMetric[] + */ + public function getArchivableMetricsInReport(): array + { + $customReportsModel = StaticContainer::get('Piwik\Plugins\CustomReports\Model\CustomReportsModel'); + return $customReportsModel->getArchivableMetricsInReport($this->report); + } + + private function usesSqlFunction(string $function, string $select): bool + { + return preg_match('/(' . $function . ')\s*\(/', $select); + } + + + /** + * @param Dimension[] $dimensionsInThisRun + * @param \Zend_Db_Statement $cursor + * @param int $idSite + */ + private function makeRegularReport(DataTable $mainRecord, array &$records, array $dimensionsInThisRun, $cursor, int $idSite): void + { + $report = $this->report; + + if (ReportType::factory($report['report_type'])->usesNumericRecords()) { + $row = $cursor->fetch(); + + if (!empty($row)) { + foreach ($row as $metric => $value) { + $records[Archiver::makeEvolutionRecordName($report['idcustomreport'], $report['revision'], $metric)] = $value; + } + } + return; // TODO: is the main report archived in this case? double check + } + + $isDimBinaryMap = $dimColumnMap = []; + foreach ($dimensionsInThisRun as $index => $dimension) { + $isDimBinaryMap[$index] = $dimension->getType() === Dimension::TYPE_BINARY; + $dimColumnMap[$index] = $dimension->getId() ?: null; + } + + while ($row = $cursor->fetch()) { + $columns = []; + $dimensionLabelMap = []; + foreach ($dimensionsInThisRun as $index => $dimension) { + if ($isDimBinaryMap[$index]) { + $row[$dimension->getId()] = bin2hex($row[$dimension->getId()]); + } + + if (isset($row[$dimension->getId()])) { + $row[$dimension->getId()] = $dimension->groupValue($row[$dimension->getId()], $idSite); + } + + $dimensionLabel = Archiver::LABEL_NOT_DEFINED; + $dimensionColumn = $dimColumnMap[$index]; + if (isset($dimensionColumn) && isset($row[$dimensionColumn])) { + $dimensionLabel = $row[$dimensionColumn]; + unset($row[$dimensionColumn]); + + if ($this->isEmptyLabel($dimensionLabel)) { + $dimensionLabel = Archiver::LABEL_NOT_DEFINED; + } + } + $dimensionLabelMap[$index] = $dimensionLabel; + + foreach ($this->columnAggregationOps as $metricName => $ignore) { + $columns[$metricName] = (float)($row[$metricName] ?? 0); + } + + $this->sumRowsForIndex($mainRecord, $columns, $dimensionLabelMap, $index + 1); + } + } + + $cursor->closeCursor(); + } + + private function sumRowsForIndex(DataTable $mainRecord, array &$columns, array $dimensionLabelMap, int $level): void + { + // If this level has already been done, skip + if ($this->doneLevel >= $level) { + return; + } + + $columns['level'] = $level; + // If this is level 1, simply sum the row and return + if ($level === 1) { + $mainRecord->sumRowWithLabel($dimensionLabelMap[0], $columns, $this->columnAggregationOps); + + return; + } + + $rowList = []; + // Build the top level row + $rowList[] = $mainRecord->sumRowWithLabel($dimensionLabelMap[0], []); + // Build all the subtables above the current subtable + for ($i = 1; $i < $level - 1; $i++) { + $rowList[] = $rowList[$i - 1]->sumRowWithLabelToSubtable($dimensionLabelMap[$i], []); + } + + // Sum the current subtable + $rowList[$level - 2]->sumRowWithLabelToSubtable($dimensionLabelMap[$level - 1], $columns, $this->columnAggregationOps); + } + + /** + * public wrapper for finalizing an archive + */ + public function finalize(ArchiveProcessor $archiveProcessor): void + { + $archiveProcessor->getArchiveWriter()->flushSpools(); + } + + public function getMetricAggregations(): array + { + $metrics = $this->getArchivableMetricsInReport(); // TODO: cache result of this method, it's used a lot + + $allMetricNames = array(); + foreach ($metrics as $metric) { + $query = $metric->getQuery(); + $metricName = $metric->getName(); + + $columnsAggregationOperation = 'sum'; + if ($this->usesSqlFunction('sum', $query)) { + $columnsAggregationOperation = 'sum'; // we check for sum again in case there is like sum(min(0,1)) + } elseif ($this->usesSqlFunction('min', $query)) { + $columnsAggregationOperation = 'min'; + } elseif ($this->usesSqlFunction('max', $query)) { + $columnsAggregationOperation = 'max'; + } + // TODO add possibility for metric to add custom callback aggregation method and / or operation keyword + + $allMetricNames[$metricName] = $columnsAggregationOperation; + } + + $allMetricNames['level'] = function ($thisColumnValue, $columnToSumValue) { + // we do not want to sum the level or so. always keep either value + if (!empty($thisColumnValue)) { + return $thisColumnValue; + } + if (!empty($columnToSumValue)) { + return $columnToSumValue; + } + }; + + return $allMetricNames; + } + + private function getMetricToSortMultiPeriod(): ?string + { + $metrics = $this->getArchivableMetricsInReport(); + + $metricsAvailableForSort = []; + foreach ($metrics as $metric) { + $metricName = $metric->getName(); + if ($metricName !== 'nb_uniq_visitors' && $metricName !== 'nb_users' && $metricName !== 'nb_uniq_corehome_userid') { + // we don't want to sort by any unique metric as they are not available in aggregated reports + $metricsAvailableForSort[] = $metricName; + } + } + + $metricToSort = $this->columnToSortByBeforeTruncation; + foreach (array('nb_visits', 'sum_actions', 'hits', 'pageviews') as $preferredSortMetric) { + if (in_array($preferredSortMetric, $metricsAvailableForSort, true)) { + $metricToSort = $preferredSortMetric; + break; + } + } + + if (!$metricToSort && !empty($metricsAvailableForSort)) { + // we prefer to sort by nb_visits if it is present + $metricToSort = array_shift($metricsAvailableForSort); + } + + return $metricToSort; + } + + private function shouldAggregateRawDataForMultiPeriodMetric(ArchivedMetric $metric, string $periodLabel): bool + { + return in_array($metric->getName(), GetCustomReport::$RAW_DATA_UNIQUE_METRICS) + && GetCustomReport::supportsUniqueMetric($periodLabel, true) + && !$this->configuration->forceAggregateUniqueMetricsFromReportsInsteadOfRawDataInEvolutionReport($periodLabel); + } + + protected function isEmptyLabel(string $label): bool + { + return $label === ''; + } +} diff --git a/files/plugin-CustomReports-5.5.0/ReportType/Evolution.php b/files/plugin-CustomReports-5.5.0/ReportType/Evolution.php new file mode 100644 index 0000000..d208db3 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/ReportType/Evolution.php @@ -0,0 +1,105 @@ +getMetricsRequiredForReport(null, $requestedColumns); + + $customReport = StaticContainer::get('\Piwik\Plugins\CustomReports\Model\CustomReportsModel'); + $reportData = $customReport->getCustomReport($idSite, $idCustomReport); + + $recordNamePrefix = Archiver::makeEvolutionRecordNamePrefix($idCustomReport, $reportData['revision']); + + $recordNames = array_map(function ($metricName) use ($idCustomReport, $reportData) { + return Archiver::makeEvolutionRecordName($idCustomReport, $reportData['revision'], $metricName); + }, $columns); + + $dataTable = $archive->getDataTableFromNumeric($recordNames); + $dataTable->filter(function (DataTable $table) use ($recordNamePrefix) { + foreach ($table->getRows() as $row) { + $columns = $row->getColumns(); + foreach ($columns as $column => $value) { + if (strpos($column, $recordNamePrefix) === 0) { + $row->setColumn(substr($column, strlen($recordNamePrefix)), $value); + $row->deleteColumn($column); + } + } + } + }); + + if (!GetCustomReport::supportsUniqueMetric($period, $isEvolution = true)) { + $hasUniqueMetrics = array_intersect(GetCustomReport::$RAW_DATA_UNIQUE_METRICS, array_keys($report->getMetrics())); + if (!empty($hasUniqueMetrics)) { + $dataTable->queueFilter('ColumnDelete', array(GetCustomReport::$RAW_DATA_UNIQUE_METRICS)); + } + } + + if (!empty($requestedColumns)) { + $dataTable->queueFilter('ColumnDelete', array($columnsToRemove = array(), $requestedColumns)); + } + + return $dataTable; + } +} diff --git a/files/plugin-CustomReports-5.5.0/ReportType/KpiCard.php b/files/plugin-CustomReports-5.5.0/ReportType/KpiCard.php new file mode 100644 index 0000000..aa986f7 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/ReportType/KpiCard.php @@ -0,0 +1,45 @@ +getCustomReport($idSite, $idCustomReport); + + $recordName = Archiver::makeRecordName($idCustomReport, $reportData['revision']); + + $table = Archive::createDataTableFromArchive($recordName, $idSite, $period, $date, $segment, $expanded, $flat, $idSubtable); + $table->disableFilter('AddColumnsProcessedMetrics'); + + $recordBuilder = StaticContainer::getContainer()->make(CustomReport::class, ['report' => $reportData]); + $aggregations = $recordBuilder->getMetricAggregations(); + $table->filter(function (DataTable $t) use ($aggregations) { + $t->setMetadata(DataTable::COLUMN_AGGREGATION_OPS_METADATA_NAME, $aggregations); + }); + + $report = new GetCustomReport(); + $report->initCustomReport($reportData, $table); + $dimension = $report->getDimension(); + $dimensions = $reportData['dimensions']; + $nestedDimensions = []; + if (!empty($dimensions) && count($dimensions) > 1) { + foreach ($dimensions as $index => $dimensionName) { + if ($index === 0) { + continue; + } + $dimensionToAdd = $report->getDimensionInstance($dimensionName); + if ($dimensionToAdd && $dimension !== $dimensionToAdd) { + $nestedDimensions[] = $dimensionToAdd; + } + } + } + + if (!empty($dimension) && $dimension->getSegmentName() && !$flat && !$idSubtable) { + if ($dimension instanceof DownloadUrl) { + // skip see DEV-2635, for now disabling as the segment won't work properly + } elseif (!empty($reportData['segment_filter'])) { + // we do not add segmented label to table when requesting a subtable since we would need to combine the segment + // with the root table or all root tables above this table. We do not have this information though + $segmentFilter = $reportData['segment_filter']; + $segmentNameDimension = $dimension->getSegmentName(); + $table->filter(function (DataTable $table) use ($segmentFilter, $segmentNameDimension) { + foreach ($table->getRowsWithoutSummaryRow() as $row) { + $firstPart = $segmentNameDimension . '==' . urlencode($row->getColumn('label')); + $row->setMetadata('segment', $firstPart . ';' . $segmentFilter); + } + }); + } else { + $table->filter('AddSegmentByLabel', array($dimension->getSegmentName())); + } + } + + if (!empty($dimension)) { + // we cannot really queue since the sorting by label wouldn't work correctly when it would sort eg + // a translation key or an enum value like "EU" or "MAC" + $table->filter('Piwik\Plugins\CustomReports\DataTable\Filter\ReportTypeTableFilter', [$idSite, $dimension, $nestedDimensions, $flat]); + } + + if (!GetCustomReport::supportsUniqueMetric($period, $isEvolution = false)) { + $hasUniqueMetrics = array_intersect(GetCustomReport::$RAW_DATA_UNIQUE_METRICS, array_keys($report->getMetrics())); + if (!empty($hasUniqueMetrics)) { + $table->queueFilter('ColumnDelete', array(GetCustomReport::$RAW_DATA_UNIQUE_METRICS)); + } + } + + // we cannot delete level column otherwise GetCustomReport won't be able to detect currently active dimension anymore + // $table->filter('ColumnDelete', array('level')); + + return $table; + } +} diff --git a/files/plugin-CustomReports-5.5.0/Updates/5.0.18.php b/files/plugin-CustomReports-5.5.0/Updates/5.0.18.php new file mode 100644 index 0000000..a827ec9 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Updates/5.0.18.php @@ -0,0 +1,68 @@ +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. + * + * @param Updater $updater + * @return Migration\Db[] + */ + public function getMigrations(Updater $updater) + { + $tableName = Common::prefixTable('custom_reports'); + $updateQuery = "UPDATE $tableName SET `metrics` = CONCAT(SUBSTR(`metrics`, 1, CHAR_LENGTH(`metrics`) - 1), ',\"sum_ecommerce_productquantity\"]')"; + $updateQuery .= " WHERE `status` = 'active' AND `metrics` LIKE '%product_revenue%' AND `metrics` NOT LIKE '%ecommerce_productquantity%';"; + $migration = $this->migration->db->sql($updateQuery); + + return [ + $migration + ]; + } + + /** + * 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. + * + * @param Updater $updater + */ + public function doUpdate(Updater $updater) + { + $updater->executeMigrations(__FILE__, $this->getMigrations($updater)); + } +} diff --git a/files/plugin-CustomReports-5.5.0/Updates/5.1.0.php b/files/plugin-CustomReports-5.5.0/Updates/5.1.0.php new file mode 100644 index 0000000..b7cba2c --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Updates/5.1.0.php @@ -0,0 +1,62 @@ +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. + * + * @param Updater $updater + * @return Migration\Db[] + */ + public function getMigrations(Updater $updater) + { + return [ + $this->migration->db->addColumn('custom_reports', 'subcategory_order', 'MEDIUMINT(8) UNSIGNED NOT NULL DEFAULT 9999999', 'subcategory') + ]; + } + + /** + * 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. + * + * @param Updater $updater + */ + public function doUpdate(Updater $updater) + { + $updater->executeMigrations(__FILE__, $this->getMigrations($updater)); + } +} diff --git a/files/plugin-CustomReports-5.5.0/Updates/5.2.0.php b/files/plugin-CustomReports-5.5.0/Updates/5.2.0.php new file mode 100644 index 0000000..ef0d2bd --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Updates/5.2.0.php @@ -0,0 +1,63 @@ +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. + * + * @param Updater $updater + * @return Migration\Db[] + */ + public function getMigrations(Updater $updater) + { + return [ + $this->migration->db->changeColumn('custom_reports', 'idsite', 'idsite', 'INT NOT NULL'), + $this->migration->db->addColumn('custom_reports', 'multiple_idsites', 'VARCHAR(2000) NULL', 'status') + ]; + } + + /** + * 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. + * + * @param Updater $updater + */ + public function doUpdate(Updater $updater) + { + $updater->executeMigrations(__FILE__, $this->getMigrations($updater)); + } +} diff --git a/files/plugin-CustomReports-5.5.0/Updates/5.4.0.php b/files/plugin-CustomReports-5.5.0/Updates/5.4.0.php new file mode 100644 index 0000000..898074e --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Updates/5.4.0.php @@ -0,0 +1,77 @@ +CustomReports; + $maxRows = (!empty($customReportsConfig['datatable_archiving_maximum_rows_custom_reports']) ? $customReportsConfig['datatable_archiving_maximum_rows_custom_reports'] : Configuration::DEFAULT_ARCHIVE_MAXIMUM_ROWS_CUSTOM_DIMENSIONS_DEFAULT); + $maxSubTableRows = ( + !empty($customReportsConfig['datatable_archiving_maximum_rows_subtable_custom_reports']) ? + $customReportsConfig['datatable_archiving_maximum_rows_subtable_custom_reports'] + : Configuration::DEFAULT_ARCHIVE_MAXIMUM_ROWS_SUB_TABLE_CUSTOM_DIMENSIONS_DEFAULT + ); + for ($i = 1; $i <= 3; $i++) { + if (empty($customReportsConfig[Configuration::KEY_ARCHIVE_MAXIMUM_ROWS_CUSTOM_DIMENSIONS . $i])) { + $customReportsConfig[Configuration::KEY_ARCHIVE_MAXIMUM_ROWS_CUSTOM_DIMENSIONS . $i] = $maxRows; + } + + // To avoid customer confusion, don't create a subtable entry for first dimension as there are no subtables + if ($i > 1 && empty($customReportsConfig[Configuration::KEY_ARCHIVE_MAXIMUM_ROWS_SUB_TABLE_CUSTOM_DIMENSIONS . $i])) { + $customReportsConfig[Configuration::KEY_ARCHIVE_MAXIMUM_ROWS_SUB_TABLE_CUSTOM_DIMENSIONS . $i] = $maxSubTableRows; + } + } + + if (empty($customReportsConfig[Configuration::KEY_ARCHIVE_MAXIMUM_ROWS_CUSTOM_DIMENSIONS . 4])) { + $customReportsConfig[Configuration::KEY_ARCHIVE_MAXIMUM_ROWS_CUSTOM_DIMENSIONS . 4] = 125; + } + if (empty($customReportsConfig[Configuration::KEY_ARCHIVE_MAXIMUM_ROWS_SUB_TABLE_CUSTOM_DIMENSIONS . 4])) { + $customReportsConfig[Configuration::KEY_ARCHIVE_MAXIMUM_ROWS_SUB_TABLE_CUSTOM_DIMENSIONS . 4] = 100; + } + + for ($i = 5; $i <= 6; $i++) { + if (empty($customReportsConfig[Configuration::KEY_ARCHIVE_MAXIMUM_ROWS_CUSTOM_DIMENSIONS . $i])) { + $customReportsConfig[Configuration::KEY_ARCHIVE_MAXIMUM_ROWS_CUSTOM_DIMENSIONS . $i] = Configuration::DEFAULT_ARCHIVE_MAXIMUM_ROWS_CUSTOM_DIMENSIONS_DEFAULT; + } + + if (empty($customReportsConfig[Configuration::KEY_ARCHIVE_MAXIMUM_ROWS_SUB_TABLE_CUSTOM_DIMENSIONS . $i])) { + $customReportsConfig[Configuration::KEY_ARCHIVE_MAXIMUM_ROWS_SUB_TABLE_CUSTOM_DIMENSIONS . $i] = Configuration::DEFAULT_ARCHIVE_MAXIMUM_ROWS_SUB_TABLE_CUSTOM_DIMENSIONS_DEFAULT; + } + } + + if (isset($customReportsConfig['datatable_archiving_maximum_rows_custom_reports'])) { + unset($customReportsConfig['datatable_archiving_maximum_rows_custom_reports']); + } + if (isset($customReportsConfig['datatable_archiving_maximum_rows_subtable_custom_reports'])) { + unset($customReportsConfig['datatable_archiving_maximum_rows_subtable_custom_reports']); + } + + $config->CustomReports = $customReportsConfig; + $config->forceSave(); + } +} diff --git a/files/plugin-CustomReports-5.5.0/Updates/5.4.2.php b/files/plugin-CustomReports-5.5.0/Updates/5.4.2.php new file mode 100644 index 0000000..35ad22e --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Updates/5.4.2.php @@ -0,0 +1,42 @@ +CustomReports; + + if (!isset($customReportsConfig[Configuration::KEY_MAX_DIMENSIONS])) { + $configuration = StaticContainer::get(Configuration::class); + $customReportsConfig[Configuration::KEY_MAX_DIMENSIONS] = $configuration->getMaxDimensions(); + $config->CustomReports = $customReportsConfig; + $config->forceSave(); + } + } +} diff --git a/files/plugin-CustomReports-5.5.0/Widgets/BaseWidget.php b/files/plugin-CustomReports-5.5.0/Widgets/BaseWidget.php new file mode 100644 index 0000000..e1fe146 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Widgets/BaseWidget.php @@ -0,0 +1,36 @@ +setOrder(99); + $config->setCategoryId(CustomReportsDao::DEFAULT_CATEGORY); + } + + protected static function getIdSite() + { + return Common::getRequestVar('idSite', false, 'int'); + } +} diff --git a/files/plugin-CustomReports-5.5.0/Widgets/GetManageReports.php b/files/plugin-CustomReports-5.5.0/Widgets/GetManageReports.php new file mode 100644 index 0000000..ec998ba --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/Widgets/GetManageReports.php @@ -0,0 +1,74 @@ +setCategoryId(CustomReportsDao::DEFAULT_CATEGORY); + $config->setSubcategoryId('CustomReports_ManageReports'); + $config->setName('CustomReports_ManageReports'); + $config->setParameters(array('showtitle' => 0)); + $config->setOrder(99); + $config->setIsNotWidgetizable(); + + $idSite = Common::getRequestVar('idSite', 0, 'int'); + if (self::getAccessValidator()->canWrite($idSite)) { + $config->enable(); + } else { + $config->disable(); + } + } + + private static function getAccessValidator() + { + return StaticContainer::get('Piwik\Plugins\CustomReports\Input\Validator'); + } + + public function render() + { + $idSite = Common::getRequestVar('idSite', null, 'int'); + self::getAccessValidator()->checkWritePermission($idSite); + + $browserArchivingDisabled = !Rules::isBrowserTriggerEnabled(); + $browserArchivingDisabled = json_encode($browserArchivingDisabled); + + $configuration = StaticContainer::get(Configuration::class); + $reArchiveLastN = $configuration->getReArchiveReportsInPastLastNMonths(); + $reArchiveLastN = json_encode($reArchiveLastN); + $maxDimensions = $configuration->getMaxDimensions(); + $maxDimensions = json_encode($maxDimensions); + $isCloud = \Piwik\Plugin\Manager::getInstance()->isPluginLoaded('Cloud'); + $isCloud = json_encode($isCloud); + + return "
"; + } +} diff --git a/files/plugin-CustomReports-5.5.0/config/config.php b/files/plugin-CustomReports-5.5.0/config/config.php new file mode 100644 index 0000000..fc9f8c7 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/config/config.php @@ -0,0 +1,5 @@ + Piwik\DI::add(array('idCustomReport')), +); diff --git a/files/plugin-CustomReports-5.5.0/docs/index.md b/files/plugin-CustomReports-5.5.0/docs/index.md new file mode 100644 index 0000000..a962837 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/docs/index.md @@ -0,0 +1,3 @@ +## Documentation + +The [Custom Reports User Guide](https://matomo.org/docs/custom-reports/) and the [Custom Reports FAQ](https://matomo.org/faq/custom-reports/) cover how to get the most out of this plugin. \ No newline at end of file diff --git a/files/plugin-CustomReports-5.5.0/lang/am.json b/files/plugin-CustomReports-5.5.0/lang/am.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/am.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/ar.json b/files/plugin-CustomReports-5.5.0/lang/ar.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/ar.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/az.json b/files/plugin-CustomReports-5.5.0/lang/az.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/az.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/be.json b/files/plugin-CustomReports-5.5.0/lang/be.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/be.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/bg.json b/files/plugin-CustomReports-5.5.0/lang/bg.json new file mode 100644 index 0000000..27e625b --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/bg.json @@ -0,0 +1,7 @@ +{ + "CustomReports": { + "CustomReport": "Персонализиран отчет", + "CustomReports": "Персонализирани отчети", + "ManageReports": "Управление на отчети" + } +} diff --git a/files/plugin-CustomReports-5.5.0/lang/bn.json b/files/plugin-CustomReports-5.5.0/lang/bn.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/bn.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/bs.json b/files/plugin-CustomReports-5.5.0/lang/bs.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/bs.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/ca.json b/files/plugin-CustomReports-5.5.0/lang/ca.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/ca.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/cs.json b/files/plugin-CustomReports-5.5.0/lang/cs.json new file mode 100644 index 0000000..5538cd4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/cs.json @@ -0,0 +1,12 @@ +{ + "CustomReports": { + "ErrorNotAnArray": "\"%1$s\" musí být pole.", + "ErrorXNotProvided": "Zadejte prosím hodnotu pro \"%1$s\".", + "ErrorXNotWhitelisted": "Hodnota pro \"%1$s\" není povolena, použijte jednu z: %2$s.", + "ErrorXTooLong": "\"%1$s\" je příliš dlouhé, max %2$s znaků je povoleno.", + "Filter": "Filtr", + "Unlock": "Odemknout", + "UpdatingData": "Aktualizuji data…", + "ViewReportInfo": "Zobrazit report" + } +} diff --git a/files/plugin-CustomReports-5.5.0/lang/cy.json b/files/plugin-CustomReports-5.5.0/lang/cy.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/cy.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/da.json b/files/plugin-CustomReports-5.5.0/lang/da.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/da.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/de.json b/files/plugin-CustomReports-5.5.0/lang/de.json new file mode 100644 index 0000000..7d7dcfb --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/de.json @@ -0,0 +1,79 @@ +{ + "CustomReports": { + "AddDimension": "Dimension hinzufügen", + "AddMetric": "Metrik hinzufügen", + "ApplyTo": "Anwenden auf", + "AvailableAllWebsites": "Verfügbar in allen Websites", + "Category": "Hauptkategorie", + "CommonUniqueMetric": "Summe von %1$s", + "CommonUniqueMetricDescription": "%1$s. Wenn Sie einen Zeitraum betrachten, der kein Tag ist, wird diese Metrik zu \"Summe von %2$s\". Wenn in einem solchen Fall dieselbe %3$s an 2 oder mehr Tagen innerhalb des ausgewählten Zeitraums erscheint, wird sie als 2 oder die Gesamtzahl der Tage gezählt, an denen sie erschienen ist, und nicht als 1.", + "ConfirmUnlockReport": "Wollen Sie wirklich diesen Bericht entsperren? Wenn Sie diesen Bericht aktualisieren, werden Berichte aus der Vergangenheit für diesen benutzerdefinierten Bericht nicht mehr erreichbar sein.", + "ConfirmUnlockReportBrowserArchivingDisabled": "Sind Sie sicher, dass Sie diesen Bericht entsperren möchten? Wenn Sie den Inhalt dieses Berichts aktualisieren, werden zuvor erstellte Berichte für diesen benutzerdefinierten Bericht nicht mehr verfügbar sein und Berichte für die letzten %1$s Monate werden neu erstellt.", + "CreateNewReport": "Neuen Bericht erstellen", + "CustomReport": "Benutzerdefinierter Bericht", + "CustomReportIntroduction": "Mit benutzerdefinierte Berichte (Custom Reports) können Sie maßgeschneiderte Berichte erstellen, um neue Einblicke zu erhalten, die mit Standardberichten nicht möglich sind. Sie wählen die Dimensionen (z.B. \"Gerätetyp\" und \"Browser\") und Metriken (z.B. \"Anzahl der Besucher\" und \"Absprungrate\") sowie wie Art und Weise wie diese angezeigt werden sollen, um die Daten zu erhalten, die Sie benötigen.", + "CustomReports": "Benutzerdefinierte Berichte", + "DeleteReportConfirm": "Wollen Sie wirklich diesen Bericht löschen? Bereits generierte Berichte für diesen benutzerdefinierten Bericht werden danach nicht mehr erreichbar sein.", + "DeleteReportInfo": "Wenn Sie diesen Bericht löschen, werden die zuvor generierten Berichte für diesen benutzerdefinierten Bericht nicht mehr erreichbar sein und es ist nicht möglich diesen Schritt rückgängig zu machen.", + "Dimension": "Dimension", + "Dimensions": "Dimensionen", + "EditReport": "Editiere Bericht", + "ErrorArrayMissingItem": "Fehlender Wert in \"%1$s\" array an der Position \"%2$s\".", + "ErrorDuplicateItem": "Es ist nicht erlaubt den gleichen %s mehrfach zu verwenden.", + "ErrorInvalidValueInArray": "Der Wert \"%1$s\" an der Position \"%2$s\" existiert nicht.", + "ErrorMissingDimension": "Mindestens eine Dimension muss ausgewählt werden.", + "ErrorMissingMetric": "Mindestens eine Metrik muss ausgewählt werden.", + "ErrorNotAnArray": "\"%1$s\" muss ein array sein.", + "ErrorReportDoesNotExist": "Der angefragte Benutzerdefinierte Bericht existiert für diese Webseite nicht. Stellen Sie sicher, dass die Seite korrekt ist.", + "ErrorReportNameDuplicate": "Der Name des Berichts wird bereits in einem anderen Bericht verwendet.", + "ErrorTooManyDimension": "Zu viele Dimensionen ausgewählt. Maximal %s Dimensionen können ausgewählt werden.", + "ErrorXNotProvided": "Bitte geben Sie einen Wert für \"%1$s\" an.", + "ErrorXNotWhitelisted": "Der Wert für \"%1$s\" ist nicht erlaubt, verwenden Sie einen von diesen: %2$s.", + "ErrorXTooLong": "\"%1$s\" ist zu lang, maximal %2$s Zeichen sind erlaubt.", + "FieldDescriptionPlaceholder": "z.B. 'Die Anzahl der Besucher pro Land'", + "FieldNamePlaceholder": "z.B. 'Besucher pro Land'", + "Filter": "Filter", + "InfoReportIsLocked": "Dieser Bericht ist derzeit gesperrt. Klicken Sie auf \"entsperren\" um diesen Bericht bearbeiten zu können. Wenn Sie diesen Bericht aktualisieren, werden Berichte aus der Vergangenheit für diesen benutzerdefinierten Bericht nicht mehr erreichbar sein.", + "InfoReportIsLockedBrowserArchivingDisabled": "Dieser Bericht ist derzeit gesperrt und Sie müssen bestätigen, dass Sie diesen Bericht \"entsperren\" möchten. Wenn Sie Änderungen am Inhalt dieses Berichts vornehmen und ihn anschließend aktualisieren, werden neue Berichtsdaten für die letzten %1$s Monate neu generiert und zuvor erstellte Berichte für diesen benutzerdefinierten Bericht sind nicht mehr verfügbar.", + "ManageReports": "Benutzerdefinierte Berichte verwalten", + "ManageReportsSubcategoryHelp": "Mit benutzerdefinierten Berichten können Sie neue Berichte erstellen, um neue Erkenntnisse zu gewinnen, die mit Standardberichten nicht möglich sind. In diesem Abschnitt können Sie benutzerdefinierte Berichte für Ihre Website erstellen und verwalten.", + "NCustomReports": "%s benutzerdefinierte Berichte", + "NoCustomReportsFound": "Keine benutzerdefinierte Berichte gefunden", + "NoDataNotArchivedYet": "Da der Bericht erst kürzlich erstellt oder bearbeitet wurde, kann es einige Stunden dauern bis die Berichtsdaten verfügbar werden, falls Sie sich die heutigen Berichte ansehen. Die Berichts-Daten für Daten vor der Erstellung des Berichts werden nicht verfügbar sein.", + "NoDataNotArchivedYetReprocess": "Ein Systemadministrator kann Matomo jedoch veranlassen die Berichte dennoch %1$serneut zu verarbeiten%2$s damit die Berichte verfügbar werden.", + "NoDataRemovedMetrics": "Es werden keine Daten angezeigt, da der Bericht nur Metriken enthält, die für den gewählten Zeitraum nicht unterstützt werden (%s).", + "Preview": "Vorschau", + "PreviewDate": "Die Vorschau basiert auf den Daten von %s.", + "PreviewReport": "Berichtvorschau", + "PreviewSupportsDimension": "Die Vorschau unterstützt derzeit nur bis zu %s Dimensionen.", + "RemoveDimension": "Dimension entfernen", + "RemoveMetric": "Metrik entfernen", + "RemovedMetrics": "Diese Metriken wurden entfernt, da sie für die gewählte Zeitraum nicht korrekt berechnet werden können: %s.", + "ReportAllWebsitesHelp": "Wählen Sie, in welcher Website dieser Bericht verfügbar sein soll. Sie können \"Alle Websites\" auswählen um den Bericht in allen Websites verfügbar zu machen (der Bericht wird auch in Websites verfügbar sein, die in der Zukunft erstellt werden).", + "ReportAvailableToAllWebsites": "Dieser Bericht ist verfügbar in allen Websites.", + "ReportCategory": "Bericht Kategorie", + "ReportCategoryHelp": "Wählen Sie die Hauptkategorie, unter welcher dieser Bericht im Berichts-Menü verfügbar sein soll.", + "ReportContent": "Bericht Inhalt", + "ReportCreated": "Der Bericht wurde erfolgreich erstellt.", + "ReportDescriptionHelp": "Eine optionale Beschreibung für diesen Bericht. Diese wird angezeigt, wenn Sie auf das Hilfesymbol neben dem Berichtstitel klicken. Zum Beispiel können Sie hier den Zweck dieses Berichts erwähnen.", + "ReportHasFilterApplied": "Dieser Report hat den folgenden Filter angewendet: %s.", + "ReportMetricsHelp": "Metriken sind quantitative Messungen. Wenn Sie beispielsweise die Besucherzahl auf Ihrer Website erhöhen möchten, fügen Sie \"Besucher\" hinzu. Wollen Sie zum Beispiel die Leistung Ihrer Website verbessern, fügen Sie \"Durchschnittliche Generierungszeit\" hinzu.", + "ReportName": "Bericht Name", + "ReportNameHelp": "Definiert den Namen unter welchen dieser Bericht verfügbar sein wird.", + "ReportPage": "Seite des Berichts", + "ReportSegmentHelp": "Mit einem Filter (Segment) können Sie einen Bericht basierend auf einer Teilmenge Ihrer Besucher erstellen. Zum Beispiel können Sie die Benutzer die in diesem Bericht berücksichtigt werden auf mobile Geräte beschränken. Das heißt, der Bericht enthält lediglich Daten für die Benutzer, die Ihre Website mit einem mobilen Gerät besucht haben.", + "ReportSubcategory": "Bericht Unterkategorie", + "ReportSubcategoryHelp": "Optional kann dieser Bericht auf eine bereits existierte Berichts-Seite hinzugefügt werden. Wenn keine Unterkategorie ausgewählt ist, wird eine neue Seite für diesen Bericht unterhalb der ausgewählten Hauptkategorie erstellt.", + "ReportType": "Bericht Art", + "ReportUpdated": "Der Bericht wurde erfolgreich aktualisiert.", + "Type": "Art", + "Unlock": "Entsperren", + "UpdatingData": "Aktualisiere Daten…", + "ViewReportInfo": "Bericht ansehen", + "WarningOnUpdateReportMightGetLost": "Wenn Sie diesen Bericht aktualisieren, werden bereits generierte Berichte aus der Vergangenheit für diesen benutzerdefinierten Bericht nicht mehr zur Verfügung stehen.", + "WarningOnUpdateReportMightGetLostBrowserArchivingDisabled": "Wenn Sie diesen benutzerdefinierten Bericht aktualisieren, sind die zuvor erstellten Berichte nicht mehr verfügbar, und die Berichte für die letzten %1$s Monate werden neu erstellt.", + "WarningRequiresUnlock": "Um Änderungen an diesem Bericht vorzunehmen, muss der Bericht \"entsperrt\" werden. Damit Sie Änderungen vornehmen können, müssen Sie den Funnel \"entsperren\". Wenn Sie Änderungen an diesem Bericht vornehmen, werden bereits generierte Berichte aus der Vergangenheit nicht mehr zur Verfügung stehen.", + "WarningRequiresUnlockBrowserArchivingDisabled": "Um Änderungen am Inhalt dieses Berichts vorzunehmen, müssen Sie diesen Bericht \"entsperren\". Wenn Sie Änderungen and der Konfiguration des Berichts vornehmen und diesen Bericht aktualisieren, werden neue Berichtsdaten für die letzten %1$s Monate neu generiert und zuvor erstellte Berichte für diesen Bericht sind nicht mehr verfügbar.", + "WebsiteName": "Webseitenname" + } +} diff --git a/files/plugin-CustomReports-5.5.0/lang/dv.json b/files/plugin-CustomReports-5.5.0/lang/dv.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/dv.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/el.json b/files/plugin-CustomReports-5.5.0/lang/el.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/el.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/en.json b/files/plugin-CustomReports-5.5.0/lang/en.json new file mode 100644 index 0000000..4f35aa7 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/en.json @@ -0,0 +1,134 @@ +{ + "CustomReports": { + "ManageReports": "Manage Reports", + "CustomReports": "Custom Reports", + "CustomReport": "Custom Report", + "NCustomReports": "%s custom reports", + "CustomReportIntroduction": "Custom Reports lets you create new reports to draw new insights that aren't possible with standard reports. You choose the dimensions (such as \"Device Type\" and \"Browser\") and metrics (such as \"Number of Visitors\" and \"Bounce Rate\") and how they should be displayed to get the unique actionable and useful data you need.", + "NoCustomReportsFound": "No custom reports found", + "EditReport": "Edit report", + "Preview": "Preview", + "PreviewReport": "Preview report", + "ViewReportInfo": "View report", + "CreateNewReport": "Create new report", + "Unlock": "Unlock", + "ApplyTo": "Apply to", + "ReportEditNotAllowedAllWebsitesUpdated": "Because this report is available for all websites, only a user with superuser access can change this report.", + "ReportEditNotAllowedMultipleWebsitesAccessIssue": "Because this report is available for multiple websites, only a superuser or an admin with access to all websites included in the report can modify it.", + "ReportAvailableToAllWebsites": "This report is available for all websites.", + "ReportAvailableToMultipleWebsites": "This report is available for multiple websites.", + "AvailableAllWebsites": "Available in all websites", + "WarningRequiresUnlock": "In order to make changes to this report content, you need to \"unlock\" this report. When you make changes to the report configuration and update this report, new reporting data needs to be generated and previously generated reports for this report won't be available anymore.", + "WarningOnUpdateReportMightGetLost": "When you update this custom report, previously generated reports for this custom report will become unavailable.", + "ConfirmUnlockReport": "Are you sure you want to unlock this report? When you update the report content of this report, previously generated reports for this custom report will become unavailable.", + "InfoReportIsLocked": "This report is currently locked and you need to confirm to \"unlock\" this report. When you make changes to this report content and update it afterwards, new reporting data needs to be generated and previously generated reports for this custom report won't be available anymore.", + "DeleteReportConfirm": "Are you sure you want to delete this custom report? Previously generated reports will no longer be available in the reporting UI.", + "DeleteReportInfo": "When you delete this report, its reports will no longer be available and you cannot undo this action.", + "ErrorXNotProvided": "Please provide a value for \"%1$s\".", + "ErrorXTooLong": "\"%1$s\" is too long, max %2$s characters are allowed.", + "ErrorNotAnArray": "\"%1$s\" has to be an array.", + "ErrorArrayMissingItem": "Missing value in \"%1$s\" array at position \"%2$s\".", + "ErrorDuplicateItem": "It is not allowed to use the same %s multiple times.", + "ErrorInvalidValueInArray": "The given \"%1$s\" at position \"%2$s\" does not exist.", + "ErrorReportDoesNotExist": "The requested custom report does not exist for this website. Please ensure the site is correct.", + "ErrorXNotWhitelisted": "The value for \"%1$s\" is not allowed, use one of: %2$s.", + "ErrorMissingDimension": "At least one dimension needs to be selected.", + "ErrorMissingMetric": "At least one metric needs to be selected.", + "ErrorTooManyDimension": "Too many dimensions selected. Max %s dimensions can be used.", + "ErrorTooManyMetrics": "Too many metrics selected. This report type supports a maximum of %s metric(s).", + "ErrorInvalidDimension": "Dimension %1$s is not supported in Custom Reports.", + "PreviewSupportsDimension": "The preview currently only supports up to %s dimensions.", + "PreviewDate": "This preview is based on the data of %s.", + "RemovedMetrics": "These metrics were removed because they cannot be calculated correctly for this period: %s.", + "NoDataRemovedMetrics": "No data is shown because this report only contains metrics that are not supported by the selected period (%s).", + "Filter": "Filter", + "ReportHasFilterApplied": "This report has the following filter applied: %s.", + "Dimensions": "Dimensions", + "Dimension": "Dimension", + "RemoveDimension": "Remove dimension", + "RemoveMetric": "Remove metric", + "AddMetric": "Add metric", + "AddDimension": "Add dimension", + "ReportType": "Report type", + "Type": "Type", + "ReportContent": "Report content", + "ReportName": "Report name", + "ReportPage": "Report page", + "ReportCategory": "Report category", + "Category": "Category", + "ReportCategoryHelp": "Select the main reporting category under which this report will be available in the reporting menu.", + "ReportAllWebsitesHelp": "Select the website this report should be available for. You can also choose \"All websites\" to make this report available for all your sites (also sites that will be created in the future).", + "ReportAllWebsitesNonSuperUserHelp": "Select the website this report should be available for.", + "ReportDimensionsHelpNew": "A dimension typically describes characteristics and is shown in the first column of a report. You can select multiple dimensions (e.g., Browser, Country, City). At least one dimension must be selected.", + "ReportDimensionsHelpExtended": "If you require additional dimensions, you can increase the limit. %1$sLearn more%2$s.", + "ReportMetricsHelp": "Metrics are quantitative measurements. For example, if you want to increase the traffic on your website, include \"Visits\", if you are improving the performance of your website, include \"Avg. Generation Time\".", + "ReportTypeKpiCard": "KPI card", + "ReportSingleMetricHelp": "A KPI card shows the value of a single metric, together with a sparkline and a comparison to the previous period.", + "ReportSegmentHelp": "A filter lets you see this report only for a subset of your visitors. For example, you can filter this report down by mobile device users only.", + "ReportSubcategory": "Report subcategory", + "ReportSubcategoryHelp": "You can optionally add this report to any existing reporting page. If you don't choose a subcategory, a new reporting page under the selected category will be created using the report name.", + "ReportCreated": "The report has been successfully created.", + "ReportUpdated": "The report has been successfully updated.", + "ErrorReportNameDuplicate": "The report name is already in use by another report.", + "NoDataNotArchivedYet": "As the report was created or updated only recently, it may take a few hours for the data to become available when you are viewing today's reports. The report data for dates before this report was created won't become available.", + "NoDataNotArchivedYetReprocess": "However, a system administrator can force Matomo to %1$sreprocess the reports%2$s to make the data available.", + "UpdatingData": "Updating data…", + "FieldNamePlaceholder": "Visitors by Country", + "FieldDescriptionPlaceholder": "The number of visitors from each country", + "ReportNameHelp": "Defines the name under which this report will be available.", + "ReportNameHelpText": "Enter a unique name to clearly identify this custom report.", + "ReportDescriptionHelp": "An optional description for your report which will be shown when you click on the help icon next to the report title. For example, you could mention the purpose of this report.", + "ReportDescriptionHelpText": "Enter a description to provide additional context, such as the purpose or usage. This description is displayed when clicking the help icon next to the report title. %1$sLearn more%2$s.", + "WarningOnUpdateReportMightGetLostBrowserArchivingDisabled": "When you update this custom report, previously generated reports will become unavailable and reports for the last %1$s months will be regenerated.", + "ConfirmUnlockReportBrowserArchivingDisabled": "Are you sure you want to unlock this report? When you update the report content of this report, previously generated reports for this custom report will become unavailable and reports for the last %1$s months will be regenerated.", + "WarningRequiresUnlockBrowserArchivingDisabled": "In order to make changes to this report content, you need to \"unlock\" this report. When you make changes to the report configuration and update this report, new reporting data for the last %1$s months will be regenerated and previously generated reports for this report won't be available anymore.", + "InfoReportIsLockedBrowserArchivingDisabled": "This report is currently locked and you need to confirm to \"unlock\" this report. When you make changes to this report content and update it afterwards, new reporting data for the last %1$s months will be regenerated and previously generated reports for this custom report won't be available anymore.", + "ManageReportsSubcategoryHelp": "Custom Reports lets you create new reports to draw new insights that aren't possible with standard reports. This section enables you to create and manage Custom Reports for your site.", + "WebsiteName": "Website name", + "CommonUniqueMetric": "Sum of %1$s", + "CommonUniqueMetricDescription": "%1$s. When viewing a period that is not day, then this metric will become \"Sum of %2$s\". In such case, if the same %3$s appear in 2 or more days within the selected period, then it will be counted as 2 or the total number of days it has appeared and not 1.", + "ProductsWithX": "Products with %s", + "ContentImpressions": "Content Impressions", + "ErrorProductRevenueMetricDependency": "Product Revenue metrics need an accompanying Product Quantity metric in the same report.", + "WarningProductRevenueMetricDependency": "Product Revenue metrics need an accompanying Product Quantity metric in the same report. The accompanying metric has been added automatically. %1$sLearn more%2$s.", + "OrderSubCategoryReports": "Order Subcategory reports", + "OrderSubCategoryReportsDescription": "Optional: Adjust the order in which subcategories will be shown while viewing this custom report.", + "ErrorInvalidSubCategoryReport": "Invalid subcategory report ids.", + "SelectMeasurablesMatchingSearch": "or add all measureables that contain the following search term", + "FindMeasurables": "Find measurables", + "NoMeasurableAssignedYet": "No measurable has been assigned yet, please select a measurable from the selector above.", + "MatchingSearchNotFound": "There is no measurable matching the search term %s.", + "MatchingSearchConfirmTitle": "Are you sure you want to add %s matching measurables?", + "MatchingSearchConfirmTitleAlreadyAdded": "%s matching measurables are already added", + "MatchingSearchMatchedAdd": "These %1$s measurables matched %2$s and will be added to this custom report", + "MatchingSearchMatchedAlreadyAdded": "These %1$s measurables matched %2$s but are already added to this custom report", + "ErrorInvalidMultipleIdSite": "For multiple idsites, idsite:%1$s should be included in the list.", + "ErrorInvalidIdSite": "Invalid website ID: %1$s", + "PauseReportInfo": "Pausing this report stops its archiving until you resume the report.", + "ResumeReportInfo": "When you resume this report, archiving will restart from today.", + "PauseReportConfirm": "Pausing this report stops its archiving until you resume the report. Are you sure you want to pause this report?", + "ResumeReportConfirm": "When you resume this report, archiving will restart from today. Are you sure you want to resume this custom report?", + "PausedReport": "The report has been paused successfully.", + "ResumedReport": "The report has been resumed successfully.", + "DeleteExceptionMessage": "Cannot delete report, site does not match", + "PauseExceptionMessage": "Cannot pause report, site does not match", + "ResumeExceptionMessage": "Cannot resume report, site does not match", + "ActivityReportAddedDescription": "added a custom report \"%1$s\" for site \"%2$s\"", + "ActivityReportUpdatedDescription": "updated the report \"%1$s\" for site \"%2$s\"", + "ActivityReportDeletedDescription": "deleted the report \"%1$s\" for site \"%2$s\"", + "ActivityReportPausedDescription": "paused the report \"%1$s\" for site \"%2$s\"", + "ActivityReportResumedDescription": "resumed the report \"%1$s\" for site \"%2$s\"", + "ErrorCustomReportDoesNotExist": "The requested custom report does not exist", + "ReportInPausedStateAdmin": "This report is paused. No archiving will occur until the report is resumed. To resume the report, go to %1$sManage Reports%2$s and click Play for the selected report.", + "NoDataMessagePausedStateNonAdminUser": "This report is paused, no new data will be available until the report is resumed. If you need access to the report, please contact your Matomo superuser.", + "NoDataMessagePausedStateAdminUser": "This report is paused, no new data will be available until the report is resumed. To resume the report, go to %1$sManage Reports%2$s and click Play for the selected report.", + "NoSegmentationJoinExceptionMessage": "The current order or combination of dimensions is not compatible. %1$sLearn more%2$s.", + "PreviewReportInvalidTimeFrameValues": "Invalid timeframe values set for preview report, allowed values are seconds, minutes, hours and days.", + "PreviewReportExceedsMaximumTimeFrameValueAllowed": "The maximum time frame for preview report cannot be > 24 hours.", + "QuotaReachedForX": "You’ve reached your quota limit. Unable to copy the %1$s due to exceeded usage. Consider deleting unused or less critical %2$s to manage your quota.", + "SourceCustomReportLookupError": "There was an unexpected error looking up the source custom report. Please try again. Contact your administrator or support if the problem persists.", + "CustomReportDuplicationError": "Process incomplete. The custom report could not be copied to all selected sites.", + "CustomReportCopied": "The custom report has been successfully copied.", + "CustomReportDuplicationSiteTypeError": "Copy feature is not supported for sites: %1$s." + } +} diff --git a/files/plugin-CustomReports-5.5.0/lang/eo.json b/files/plugin-CustomReports-5.5.0/lang/eo.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/eo.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/es-ar.json b/files/plugin-CustomReports-5.5.0/lang/es-ar.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/es-ar.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/es.json b/files/plugin-CustomReports-5.5.0/lang/es.json new file mode 100644 index 0000000..f510a6e --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/es.json @@ -0,0 +1,68 @@ +{ + "CustomReports": { + "AddDimension": "Agregar dimensión", + "AddMetric": "Agregar métrica", + "ApplyTo": "Usar en", + "AvailableAllWebsites": "Disponible en todos los sitios de internet", + "Category": "Categoría", + "ConfirmUnlockReport": "¿Está seguro de que quieres desbloquear este informe? Cuando actualice el contenido de este informe, los informes generados previamente para este informe personalizado no estarán disponibles.", + "CreateNewReport": "Crear nuevo informe", + "CustomReport": "Informe personalizado", + "CustomReportIntroduction": "Los informes personalizados le permiten crear nuevos informes para extraer nuevas perspectivas que no son posibles con los informes estándar. Elija las dimensiones (como \"Tipo de dispositivo\" y \"Navegador\") y las métricas (como \"Número de visitantes\" y \"Tasa de rebote\") y cómo deben mostrarse para obtener los datos únicos y útiles que necesita.", + "CustomReports": "Informes personalizados", + "DeleteReportConfirm": "¿Está seguro que desea eliminar este informe personalizado? Aquellos informes previamente generados ya no estarán disponibles en la interfaz de usuario Informes.", + "DeleteReportInfo": "Cuando elimine este informe, el mismo no estará disponible, y no podrá deshacer esta acción.", + "Dimension": "Dimensión", + "Dimensions": "Dimensiones", + "EditReport": "Modificar informe", + "ErrorArrayMissingItem": "Falta el valor en la matriz \"%1$s\" en la posición \"%2$s\".", + "ErrorDuplicateItem": "No está permitido usar el mismo %s varias veces.", + "ErrorInvalidValueInArray": "\"%1$s\" en la posición \"%2$s\" no existe.", + "ErrorMissingDimension": "Al menos una dimensión debe ser seleccionada.", + "ErrorMissingMetric": "Al menos una métrica debe ser seleccionada.", + "ErrorNotAnArray": "\"%1$s\" tiene que ser una matriz.", + "ErrorReportNameDuplicate": "El nombre del informe ya está en uso por otro informe.", + "ErrorTooManyDimension": "Demasiadas dimensiones seleccionadas. Un máximo de %s pueden ser usadas.", + "ErrorXNotProvided": "Por favor suministre un valor para \"%1$s\".", + "ErrorXNotWhitelisted": "El valor para \"%1$s\" no está permitido, use uno de: %2$s.", + "ErrorXTooLong": "\"%1$s\" es muy largo, un máximo de %2$s caracteres es permitido.", + "FieldDescriptionPlaceholder": "ej 'El número de visitantes por país'", + "FieldNamePlaceholder": "ej 'Visitantes por país'", + "Filter": "Filtro", + "InfoReportIsLocked": "Este informe está actualmente bloqueado y debe confirmar que desea \"desbloquearlo\". Cuando realice cambios en el contenido de este informe y lo actualice, es necesario generar nuevos datos de informes y los informes generados previamente para este informe personalizado ya no estarán disponibles.", + "ManageReports": "Administrar Informes", + "NCustomReports": "%s informes personalizados", + "NoCustomReportsFound": "No se encuentran informes personalizados", + "NoDataNotArchivedYet": "Debido a que el informe se creó o actualizó recientemente, puede tardar algunas horas para que los datos estén disponibles cuando esté viendo los informes de hoy. Los datos del informe para las fechas anteriores a la creación de este informe no estarán disponibles.", + "NoDataNotArchivedYetReprocess": "Sin embargo, un administrador de sistema puede forzar a Matomo a %1$sreprocesar los informes%2$s para hacer que los datos estén disponibles.", + "Preview": "Previsualización", + "PreviewDate": "La vista previa está basada en los datos de %s.", + "PreviewReport": "Previsualizar informe", + "PreviewSupportsDimension": "La vista previa actualmente es hasta %s dimensiones.", + "RemoveDimension": "Quitar dimensión", + "RemoveMetric": "Quitar métrica", + "ReportAllWebsitesHelp": "Seleccione el sitio web para el que debe estar disponible este informe. También puede elegir \"Todos los sitios web\" para que este informe esté disponible para todos sus sitios (y también aquellos sitios que se crearán en el futuro).", + "ReportAvailableToAllWebsites": "Este informe está disponible para todos los sitios web.", + "ReportCategory": "Categoría de informe", + "ReportCategoryHelp": "Seleccione la categoría de informe principal bajo la cual este informe estará disponible en el menú de informes.", + "ReportContent": "Contenido del informe", + "ReportCreated": "El informe se ha creado exitosamente.", + "ReportDescriptionHelp": "Una descripción opcional para su informe que se mostrará cuando haga clic en el icono de ayuda junto al título del informe. Por ejemplo, podría mencionar el propósito de este informe.", + "ReportHasFilterApplied": "Este informe tiene aplicado el siguiente filtro: %s.", + "ReportMetricsHelp": "Las métricas son medidas cuantitativas. Por ejemplo, si desea aumentar el tráfico en su sitio web, incluya \"Visitas\", si está mejorando el rendimiento de su sitio web, incluya \"Tiempo promedio de generación\".", + "ReportName": "Nombre del informe", + "ReportNameHelp": "Define el nombre bajo el cual este informe estará disponible.", + "ReportPage": "Página de informe", + "ReportSegmentHelp": "Un filtro le permite ver este informe solo para un subconjunto de sus visitantes. Por ejemplo, puede filtrar este informe solo por usuarios de dispositivos móviles.", + "ReportSubcategory": "Subcategoría de informe", + "ReportSubcategoryHelp": "Opcionalmente, puede agregar este informe a cualquier página de informe existente. Si no elige una subcategoría, se creará una nueva página de informes bajo la categoría seleccionada usando el nombre del informe.", + "ReportType": "Tipo de informe", + "ReportUpdated": "El informe se ha actualizado exitosamente.", + "Type": "Tipo", + "Unlock": "Desbloquear", + "UpdatingData": "Actualizando datos…", + "ViewReportInfo": "Ver informe", + "WarningOnUpdateReportMightGetLost": "Cuando actualice este informe personalizado, los informes generados previamente para este informe personalizado no estarán disponibles.", + "WarningRequiresUnlock": "Para realizar cambios en el contenido de este informe, debe \"desbloquearlo\". Cuando realice cambios en la configuración del informe y actualice este informe, es necesario generar nuevos datos de informes y los informes generados anteriormente para el mismo ya no estarán disponibles." + } +} diff --git a/files/plugin-CustomReports-5.5.0/lang/et.json b/files/plugin-CustomReports-5.5.0/lang/et.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/et.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/eu.json b/files/plugin-CustomReports-5.5.0/lang/eu.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/eu.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/fa.json b/files/plugin-CustomReports-5.5.0/lang/fa.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/fa.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/fi.json b/files/plugin-CustomReports-5.5.0/lang/fi.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/fi.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/fr.json b/files/plugin-CustomReports-5.5.0/lang/fr.json new file mode 100644 index 0000000..5409ff5 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/fr.json @@ -0,0 +1,128 @@ +{ + "CustomReports": { + "ActivityReportAddedDescription": "a ajouté un rapport personnalisé \"%1$s\"» pour le site \"%2$s\"", + "ActivityReportDeletedDescription": "a supprimé le rapport \"%1$s\" pour le site \"%2$s\"", + "ActivityReportPausedDescription": "a mis en pause le rapport \"%1$s\" pour le site \"%2$s\"", + "ActivityReportResumedDescription": "a repris le rapport \"%1$s\" pour le site \"%2$s\"", + "ActivityReportUpdatedDescription": "a mis à jour le rapport \"%1$s\" pour le site \"%2$s\"", + "AddDimension": "Ajouter une dimension", + "AddMetric": "Ajouter un élément mesurable", + "ApplyTo": "Appliquer à", + "AvailableAllWebsites": "Disponible pour tous les sites", + "Category": "Catégorie", + "CommonUniqueMetric": "Total de %1$s", + "CommonUniqueMetricDescription": "%1$s. Lorsque vous visualisez une période qui n'est pas un jour, cette mesure devient \"Somme de %2$s\". Dans ce cas, si le même %3$s apparaît dans 2 ou plusieurs jours de la période sélectionnée, il sera compté comme 2 ou le nombre total de jours où il est apparu et non 1.", + "ConfirmUnlockReport": "Êtes vous sûr de vouloir débloquer ce rapport ? Lorsque vous mettez à jour le contenu de ce rapport, les rapports précédemment générés pour ce rapport personnalisé ne seront plus disponibles.", + "ConfirmUnlockReportBrowserArchivingDisabled": "Êtes-vous sûr de vouloir déverrouiller ce rapport ? Lorsque vous mettez à jour le contenu de ce rapport, les rapports précédemment générés pour ce rapport personnalisé deviennent indisponibles et les rapports des derniers %1$s mois sont régénérés.", + "ContentImpressions": "Impressions de contenu", + "CreateNewReport": "Créer un nouveau rapport", + "CustomReport": "Rapport personnalisé", + "CustomReportCopied": "Le rapport personnalisé a été copié avec succès.", + "CustomReportDuplicationError": "Processus incomplet. Le rapport personnalisé n’a pas pu être copié sur tous les sites sélectionnés.", + "CustomReportDuplicationSiteTypeError": "La fonction de copie n’est pas prise en charge pour les sites suivants : %1$s.", + "CustomReportIntroduction": "Les rapports personnalisés vous permettent de créer de nouveaux rapports pour dessiner de nouvelles vues impossibles à obtenir avec les rapports standards. Vous choisissez les dimensions (tel que \"Type de périphérique\" et \"Navigateur\") ainsi que les métriques (tel que \"Nombre de visiteurs\" et \"Taux de rebond\") et comment ils doivent être affichés pour avoir les données uniques, exploitables et utiles dont vous avez besoin.", + "CustomReports": "Rapports personnalisés", + "DeleteExceptionMessage": "Impossible de supprimer le rapport, le site ne correspond pas", + "DeleteReportConfirm": "Êtes-vous sûr de vouloir supprimer ce rapport personnalisé ? Les rapports générés précédemment ne seront plus disponibles dans l'interface de rapport.", + "DeleteReportInfo": "Lorsque vous supprimez ce rapport, ses rapports ne sont plus disponibles et vous ne pouvez pas annuler cette action.", + "Dimension": "Dimension", + "Dimensions": "Dimensions", + "EditReport": "Éditer le rapport", + "ErrorArrayMissingItem": "Valeur manquante dans le tableau \"%1$s\" à la position \"%2$s\".", + "ErrorCustomReportDoesNotExist": "Le rapport personnalisé demandé n’existe pas", + "ErrorDuplicateItem": "Il n'est pas autorisé d'utiliser le même %s plusieurs fois.", + "ErrorInvalidDimension": "La dimension %1$s n’est pas prise en charge dans les Rapports personnalisés.", + "ErrorInvalidMultipleIdSite": "Pour plusieurs idsites, idsite :%1$s doit être inclus dans la liste.", + "ErrorInvalidSubCategoryReport": "Identifiants de rapports de sous-catégorie invalides.", + "ErrorInvalidValueInArray": "\"%1$s\" indiqué à la position \"%2$s\" n'existe pas.", + "ErrorMissingDimension": "Une dimension au moins doit être sélectionnée.", + "ErrorMissingMetric": "Un élément mesurable au moins doit être sélectionné.", + "ErrorNotAnArray": "\"%1$s\" doit être un tableau.", + "ErrorProductRevenueMetricDependency": "Les métriques de chiffre d'affaires produit nécessitent la présence d'une métrique de quantité de produit dans le même rapport.", + "ErrorReportDoesNotExist": "Le rapport personnalisé demandé n'existe pas pour ce site web. Veuillez vous assurer que le site est correct.", + "ErrorReportNameDuplicate": "Ce nom est déjà utilisé pour un autre rapport.", + "ErrorTooManyDimension": "Trop de dimensions sélectionnées. %s dimensions peuvent être utilisées au maximum.", + "ErrorXNotProvided": "Veuillez indiquer une valeur pour \"%1$s\".", + "ErrorXNotWhitelisted": "La valeur pour \"%1$s\" n'est pas autorisée, utilisez une de celles-ci : %2$s.", + "ErrorXTooLong": "\"%1$s\" est trop long, %2$s caractères maximum sont autorisés.", + "FieldDescriptionPlaceholder": "ex. 'Le nombre de visiteurs par pays'", + "FieldNamePlaceholder": "ex. 'Visiteurs par pays'", + "Filter": "Filtre", + "FindMeasurables": "Trouver des éléments mesurables", + "InfoReportIsLocked": "Ce rapport est actuellement verrouillé et vous devez confirmer pour \"déverrouiller\" ce rapport. Lorsque vous effectuez des changements sur le contenu du rapport et que vous le mettez à jour par la suite, de nouvelles données de rapport doivent être générées et les rapports précédemment générés pour ce rapport personnalisé ne seront plus disponibles.", + "InfoReportIsLockedBrowserArchivingDisabled": "Ce rapport est actuellement verrouillé et vous devez confirmer pour \"déverrouiller\" ce rapport. Lorsque vous apportez des modifications au contenu de ce rapport et que vous le mettez à jour par la suite, les nouvelles données de rapport pour les derniers %1$s mois seront régénérées et les rapports précédemment générés pour ce rapport personnalisé ne seront plus disponibles.", + "ManageReports": "Gestion des rapports", + "ManageReportsSubcategoryHelp": "Les rapports personnalisés vous permettent de créer de nouveaux rapports pour obtenir de nouvelles informations qui ne sont pas possibles avec les rapports standard. Cette section vous permet de créer et de gérer des rapports personnalisés pour votre site.", + "MatchingSearchConfirmTitle": "Êtes-vous sûr de vouloir ajouter des %s correspondant à ces élements mesurables ?", + "MatchingSearchConfirmTitleAlreadyAdded": "%s éléments mesurables correspondants sont déjà ajoutés", + "MatchingSearchMatchedAdd": "Ces %1$s éléments mesurables correspondent à %2$s et seront ajoutées à ce rapport personnalisé", + "MatchingSearchMatchedAlreadyAdded": "Ces %1$s éléments mesurables correspondent à %2$s mais sont déjà ajoutées à ce rapport personnalisé", + "MatchingSearchNotFound": "Il n'y a pas d'éléments mesurables correspondant au terme de recherche %s.", + "NCustomReports": "%s rapports personnalisés", + "NoCustomReportsFound": "Aucun rapport personnalisé trouvé", + "NoDataMessagePausedStateAdminUser": "Ce rapport est en pause, aucune nouvelle donnée ne sera disponible jusqu’à ce que le rapport soit repris. Pour reprendre le rapport, accédez à %1$sGérer les rapports%2$s et cliquez sur Lecture pour le rapport sélectionné.", + "NoDataMessagePausedStateNonAdminUser": "Ce rapport est en pause, aucune nouvelle donnée ne sera disponible jusqu’à ce que le rapport soit repris. Si vous avez besoin d’accéder au rapport, veuillez contacter votre super utilisateur Matomo.", + "NoDataNotArchivedYet": "Comme ce rapport a été créé ou mis à jour récemment, cela peut prendre quelques heures avant que les données soient disponibles quand vous consultez les rapports du jour. Les données concernant les dates antérieures à la création de ce rapport ne seront plus disponibles.", + "NoDataNotArchivedYetReprocess": "Cependant, un administrateur peut forcer Matomo à %1$s recalculer les rapports %2$s pour rendre les données disponibles.", + "NoDataRemovedMetrics": "Aucune donnée n'est affichée car ce rapport ne contient que des éléments mesurables non disponibles pour la période sélectionnée (%s).", + "NoMeasurableAssignedYet": "Aucun élément mesurable n'a encore été attribué, veuillez sélectionner un élément mesurable dans le sélecteur ci-dessus.", + "NoSegmentationJoinExceptionMessage": "L’ordre ou la combinaison actuelle des dimensions n’est pas compatible. %1$sEn savoir plus%2$s.", + "OrderSubCategoryReports": "Rapports de sous-catégorie de commande", + "OrderSubCategoryReportsDescription": "Optionnel : Ajustez l’ordre dans lequel les sous-catégories seront affichées lors de la consultation de ce rapport personnalisé.", + "PauseExceptionMessage": "Impossible de mettre le rapport en pause, le site ne correspond pas", + "PauseReportConfirm": "La mise en pause de ce rapport arrête son archivage jusqu'à ce que vous repreniez le rapport. Êtes-vous sûr de vouloir mettre ce rapport en pause ?", + "PauseReportInfo": "La mise en pause de ce rapport arrête son archivage jusqu'à ce que vous repreniez le rapport.", + "PausedReport": "Le rapport a été mis en pause avec succès.", + "Preview": "Prévisualiser", + "PreviewDate": "Cette prévisualisation se base sur les données de %s.", + "PreviewReport": "Prévisualiser le rapport", + "PreviewReportExceedsMaximumTimeFrameValueAllowed": "La période maximale pour l’aperçu du rapport ne peut pas dépasser 24 heures.", + "PreviewReportInvalidTimeFrameValues": "Valeurs de période non valides définies pour l’aperçu du rapport. Les valeurs autorisées sont : secondes, minutes, heures et jours.", + "PreviewSupportsDimension": "La prévisualisation supporte seulement %s dimensions pour le moment.", + "ProductsWithX": "Produits avec %s", + "QuotaReachedForX": "Vous avez atteint votre limite de quota. Impossible de copier le %1$s en raison d’une utilisation dépassée. Envisagez de supprimer les %2$s inutilisés ou moins importants afin de gérer votre quota.", + "RemoveDimension": "Retirer une dimension", + "RemoveMetric": "Retirer un élément mesurable", + "RemovedMetrics": "Ces éléments mesurables ont été retirés car ils ne peuvent pas être calculés correctement sur la période : %s.", + "ReportAllWebsitesHelp": "Sélectionnez le site pour lequel ce rapport sera disponible. Vous pouvez choisir \"Tous les sites\" pour rendre ce rapport disponible pour tous les sites (y compris ceux créés à l'avenir).", + "ReportAllWebsitesNonSuperUserHelp": "Sélectionnez le site web pour lequel ce rapport doit être disponible.", + "ReportAvailableToAllWebsites": "Ce rapport est disponible pour tous les sites.", + "ReportAvailableToMultipleWebsites": "Ce rapport est disponible pour plusieurs sites web.", + "ReportCategory": "Catégorie du rapport", + "ReportCategoryHelp": "Sélectionnez la catégorie principale dans laquelle le rapport sera disponible via le menu.", + "ReportContent": "Contenu du rapport", + "ReportCreated": "Le rapport a été créé avec succès.", + "ReportDescriptionHelp": "Une description facultative de votre rapport qui s'affichera lorsque vous cliquerez sur l'icône d'aide à côté du titre du rapport. Par exemple, vous pourriez mentionner le but de ce rapport.", + "ReportDimensionsHelpExtended": "Si vous avez besoin de dimensions supplémentaires, vous pouvez augmenter la limite. %1$sEn savoir plus%2$s.", + "ReportDimensionsHelpNew": "Une dimension décrit généralement des caractéristiques et apparaît dans la première colonne d’un rapport. Vous pouvez sélectionner plusieurs dimensions (par exemple : navigateur, pays, ville). Au moins une dimension doit être sélectionnée.", + "ReportEditNotAllowedAllWebsitesUpdated": "Parce que ce rapport est disponible pour tous les sites web, seul un utilisateur avec un accès super utilisateur peut modifier ce rapport.", + "ReportEditNotAllowedMultipleWebsitesAccessIssue": "Parce que ce rapport est disponible pour plusieurs sites web, seul un super utilisateur ou un administrateur ayant accès à tous les sites web inclus dans le rapport peut le modifier.", + "ReportHasFilterApplied": "Ce rapport a le filtre suivant : %s.", + "ReportInPausedStateAdmin": "Ce rapport est en pause. Aucun archivage n’aura lieu jusqu’à ce que le rapport soit repris. Pour reprendre le rapport, accédez à %1$sGérer les rapports%2$s et cliquez sur Lecture pour le rapport sélectionné.", + "ReportMetricsHelp": "Les éléments mesurables sont des mesures quantitatives. Par exemple, si vous souhaitez augmenter le trafic de votre site web, incluez \"Visites\", si vous recherchez la performance de votre site web, incluez \"Moyenne du temps de génération\".", + "ReportName": "Nom du rapport", + "ReportNameHelp": "Définissez le nom sous lequel ce rapport sera disponible.", + "ReportPage": "Page du rapport", + "ReportSegmentHelp": "Un filtre vous permet de voir ce rapport uniquement pour un sous-ensemble de vos visiteurs. Par exemple, vous pouvez filtrer ce rapport en fonction des utilisateurs d'appareils mobiles uniquement.", + "ReportSubcategory": "Sous-catégorie du rapport", + "ReportSubcategoryHelp": "Vous pouvez en option ajouter ce rapport dans n'importe quelle page existante. Si vous ne choisissez pas de sous-catégorie, une nouvelle page dans la catégorie sélectionnée sera créée en utilisant le nom du rapport.", + "ReportType": "Type de rapport", + "ReportUpdated": "Le rapport a bien été mis à jour.", + "ResumeExceptionMessage": "Impossible de reprendre le rapport, le site ne correspond pas", + "ResumeReportConfirm": "Lorsque vous reprendrez ce rapport, l'archivage redémarrera à partir d'aujourd'hui. Êtes-vous sûr de vouloir reprendre ce rapport personnalisé ?", + "ResumeReportInfo": "Lorsque vous reprendrez ce rapport, l'archivage redémarrera à partir d'aujourd'hui.", + "ResumedReport": "Le rapport a été repris avec succès.", + "SelectMeasurablesMatchingSearch": "ou ajouter toutes les entités mesurables contenant le terme de recherche suivant", + "SourceCustomReportLookupError": "Une erreur inattendue s’est produite lors de la recherche du rapport personnalisé source. Veuillez réessayer. Contactez votre administrateur ou le support si le problème persiste.", + "Type": "Type", + "Unlock": "Déverrouiller", + "UpdatingData": "Mise à jour des données…", + "ViewReportInfo": "Voir le rapport", + "WarningOnUpdateReportMightGetLost": "Lorsque vous mettez à jour ce rapport personnalisé, les rapports précédemment générées pour ce rapport personnalisé ne seront plus disponibles.", + "WarningOnUpdateReportMightGetLostBrowserArchivingDisabled": "Lorsque vous mettez à jour ce rapport personnalisé, les rapports précédemment générés deviennent indisponibles et les rapports des derniers %1$s mois sont régénérés.", + "WarningProductRevenueMetricDependency": "Les métriques de chiffre d'affaires produit nécessitent la présence d'une métrique de quantité de produit dans le même rapport. La métrique correspondante a été ajoutée automatiquement. %1$sEn savoir plus%2$s.", + "WarningRequiresUnlock": "Pour faire des changements sur le contenu de ce rapport, vous devez \"déverrouiller\" ce rapport. Lorsque vous faites des changements dans la configuration du rapport et qu'il est mis à jour, les nouvelles données de reporting doivent être générées et les études générées précédemment pour ce rapport ne seront plus disponibles.", + "WarningRequiresUnlockBrowserArchivingDisabled": "Pour pouvoir apporter des modifications au contenu de ce rapport, vous devez le \"déverrouiller\". Lorsque vous apportez des modifications à la configuration du rapport et que vous mettez à jour ce rapport, les nouvelles données de rapport pour les derniers %1$s mois seront régénérées et les rapports précédemment générés pour ce rapport ne seront plus disponibles.", + "WebsiteName": "Nom du site web" + } +} diff --git a/files/plugin-CustomReports-5.5.0/lang/ga.json b/files/plugin-CustomReports-5.5.0/lang/ga.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/ga.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/gl.json b/files/plugin-CustomReports-5.5.0/lang/gl.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/gl.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/gu.json b/files/plugin-CustomReports-5.5.0/lang/gu.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/gu.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/he.json b/files/plugin-CustomReports-5.5.0/lang/he.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/he.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/hi.json b/files/plugin-CustomReports-5.5.0/lang/hi.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/hi.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/hr.json b/files/plugin-CustomReports-5.5.0/lang/hr.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/hr.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/hu.json b/files/plugin-CustomReports-5.5.0/lang/hu.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/hu.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/hy.json b/files/plugin-CustomReports-5.5.0/lang/hy.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/hy.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/id.json b/files/plugin-CustomReports-5.5.0/lang/id.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/id.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/is.json b/files/plugin-CustomReports-5.5.0/lang/is.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/is.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/it.json b/files/plugin-CustomReports-5.5.0/lang/it.json new file mode 100644 index 0000000..b1290c8 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/it.json @@ -0,0 +1,76 @@ +{ + "CustomReports": { + "AddDimension": "Aggiungi dimensione", + "AddMetric": "Aggiungi metrica", + "ApplyTo": "Applica a", + "AvailableAllWebsites": "Disponibile in tutti i siti web", + "Category": "Categoria", + "ConfirmUnlockReport": "Sei sicuro di voler sbloccare questo report? Quando si aggiorna il suo contenuto, i report generati in precedenza per questo report personalizzato non saranno più disponibili.", + "ConfirmUnlockReportBrowserArchivingDisabled": "Sei sicuro di voler sbloccare questo report? Quando aggiorni il contenuto di questo report, quelli generati in precedenza per questo report personalizzato non saranno disponibili e quelli per gli ultimi %1$s mesi verranno rigenerati.", + "CreateNewReport": "Crea nuovo report", + "CustomReport": "Report Personalizzato", + "CustomReportIntroduction": "I report personalizzati ti consentono di creare nuovi report per ricavare nuovi approfondimenti che non sono possibili con i quelli standard. Scegli le dimensioni (come \"Tipo di Dispositivo\" e \"Browser\") e le metriche (come \"Numero di Visitatori\" e \"Frequenza dei Rimbalzi\") e come dovrebbero essere visualizzate per ottenere i dati unici, utilizzabili e utili, di cui hai bisogno.", + "CustomReports": "Report Personalizzati", + "DeleteReportConfirm": "Sei sicuro di voler cancellare questo report personalizzato? I report generati in precedenza non saranno più disponibili nell'Interfaccia Utente dei report.", + "DeleteReportInfo": "Quando elimini questo report, i suoi risultati non saranno più disponibili e non potrai annullare questa azione.", + "Dimension": "Dimensione", + "Dimensions": "Dimensioni", + "EditReport": "Modifica report", + "ErrorArrayMissingItem": "Valore mancante nell'array \"%1$s\" alla posizione \"%2$s\".", + "ErrorDuplicateItem": "Non è possibile usare più volte lo stesso %s", + "ErrorInvalidValueInArray": "\"%1$s\" alla posizione \"%2$s\" non esiste.", + "ErrorMissingDimension": "Deve essere selezionata almeno una dimensione.", + "ErrorMissingMetric": "Deve essere selezionata almeno una metrica.", + "ErrorNotAnArray": "\"%1$s\" deve essere un array.", + "ErrorReportDoesNotExist": "Il report personalizzato richiesto non esiste per questo sito web. Assicurati che il sito sia quello giusto.", + "ErrorReportNameDuplicate": "Il nome del report è già utilizzato da un altro report.", + "ErrorTooManyDimension": "Sono state selezionate troppe dimensioni. Ne possono essere utilizzate max. %s.", + "ErrorXNotProvided": "Si prega di assegnare un valore per \"%1$s\".", + "ErrorXNotWhitelisted": "Il valore per \"%1$s\" non è consentito, usane uno tra: %2$s.", + "ErrorXTooLong": "\"%1$s\" è troppo lungo, è permesso un massimo di %2$s caratteri.", + "FieldDescriptionPlaceholder": "es. 'Numero di visitatori per nazione'", + "FieldNamePlaceholder": "es. 'Visitatori per Nazione'", + "Filter": "Filtra", + "InfoReportIsLocked": "Questo report è attualmente bloccato ed è necessario confermare per \"sbloccarlo\". Quando si apportano modifiche a questo contenuto del report e lo si aggiorna successivamente, è necessario generare nuovi dati di report, e quelli generati in precedenza per questo report personalizzato non saranno più disponibili.", + "InfoReportIsLockedBrowserArchivingDisabled": "Questo report è attualmente bloccato e devi confermare per \"sbloccarlo\". Quando apporti modifiche al contenuto di questo report e lo aggiorni in seguito, i nuovi dati dei report per gli ultimi %1$s mesi verranno rigenerati e i report generati in precedenza per questo report personalizzato non saranno più disponibili.", + "ManageReports": "Gestisci Reports", + "ManageReportsSubcategoryHelp": "I report personalizzati ti consentono di creare nuovi rapporti per trarre nuove informazioni che non sono possibili con quelli standard. Questa sezione ti consente di creare e gestire report personalizzati per il tuo sito.", + "NCustomReports": "%sreport personalizzati", + "NoCustomReportsFound": "Non sono stati trovati report personalizzati", + "NoDataNotArchivedYet": "Poiché il report è stato creato o aggiornato solo di recente, potrebbero essere necessarie alcune ore prima che i dati siano disponibili, quando si visualizzano i report di oggi. I dati del report per i periodi precedenti alla sua creazione non saranno disponibili.", + "NoDataNotArchivedYetReprocess": "Tuttavia, un amministratore di sistema può forzare Matomo a %1$srielaborare i report%2$s per rendere disponibili i dati.", + "NoDataRemovedMetrics": "Non vengono mostrati dati in quanto questo report contiene solamente metriche che non sono supportate dal periodo selezionato (%s).", + "Preview": "Anteprima", + "PreviewDate": "Questa anteprima è basata sui dati di %s.", + "PreviewReport": "Anteprima report", + "PreviewSupportsDimension": "L'anteprima supporta al momento fino a %s dimensioni.", + "RemoveDimension": "Rimuovi dimensione", + "RemoveMetric": "Rimuovi metrica", + "RemovedMetrics": "Queste metriche sono state rimosse in quanto esse non possono essere calcolate correttamente per questo periodo: %s.", + "ReportAllWebsitesHelp": "Seleziona il sito web per cui questo report deve essere disponibile. Puoi anche scegliere \"Tutti i siti web\" per rendere questo report disponibile per tutti i tuoi siti (anche i siti che verranno creati in futuro).", + "ReportAvailableToAllWebsites": "Questo report è disponibile per tutti i siti web.", + "ReportCategory": "Categoria report", + "ReportCategoryHelp": "Seleziona la categoria principale in base alla quale questo report sarà disponibile nel relativo menù.", + "ReportContent": "Contenuto report", + "ReportCreated": "Il report è stato creato con successo.", + "ReportDescriptionHelp": "Una descrizione facoltativa del tuo report che verrà visualizzata quando clicchi sull'icona della guida accanto al titolo del report stesso. Ad esempio, potresti citare lo scopo di questo report.", + "ReportHasFilterApplied": "Questo report ha applicati i seguenti files: %s.", + "ReportMetricsHelp": "Le metriche sono misure quantitative. Ad esempio, se desideri aumentare il traffico sul tuo sito web, includi \"Visite\", se stai migliorando le prestazioni del tuo sito web, includi \"Tempo Medio di Generazione\".", + "ReportName": "Nome report", + "ReportNameHelp": "Definisce il nome sotto cui questo report sarà disponibile.", + "ReportPage": "Pagina report", + "ReportSegmentHelp": "Un filtro ti consente di visualizzare questo report solo per un sottoinsieme di visitatori. Ad esempio, puoi filtrare questo report solo per gli utenti di dispositivi mobili.", + "ReportSubcategory": "Sottocategoria report", + "ReportSubcategoryHelp": "È possibile aggiungere facoltativamente questo report a qualsiasi pagina di report esistente. Se non si sceglie una sottocategoria, verrà creata una nuova pagina di report sotto la categoria selezionata utilizzando il nome del report stesso.", + "ReportType": "Tipo di report", + "ReportUpdated": "Il report è stato aggiornato con successo.", + "Type": "Tipo", + "Unlock": "Sblocca", + "UpdatingData": "Aggiornamento dati…", + "ViewReportInfo": "Vedi report", + "WarningOnUpdateReportMightGetLost": "Quando aggiorni questo report personalizzato, i report generati in precedenza non saranno più disponibili per questo report personalizzato.", + "WarningOnUpdateReportMightGetLostBrowserArchivingDisabled": "Quando aggiorni questo report personalizzato, i report generati in precedenza non saranno disponibili e i report per gli ultimi %1$s mesi verranno rigenerati.", + "WarningRequiresUnlock": "Per apportare modifiche a questo contenuto del report, è necessario \"sbloccarlo\". Quando si apportano modifiche alla configurazione del report e lo si aggiorna, è necessario generare dei nuovi dati di report, e quelli generati in precedenza per questo report personalizzato non saranno più disponibili.", + "WarningRequiresUnlockBrowserArchivingDisabled": "Per apportare modifiche al contenuto di questo report, è necessario \"sbloccarlo\". Quando apporti modifiche alla configurazione del report e lo aggiorni, i nuovi dati dei report per gli ultimi %1$s mesi verranno rigenerati e i report generati in precedenza per questo report non saranno più disponibili." + } +} diff --git a/files/plugin-CustomReports-5.5.0/lang/ja.json b/files/plugin-CustomReports-5.5.0/lang/ja.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/ja.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/ka.json b/files/plugin-CustomReports-5.5.0/lang/ka.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/ka.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/ko.json b/files/plugin-CustomReports-5.5.0/lang/ko.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/ko.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/ku.json b/files/plugin-CustomReports-5.5.0/lang/ku.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/ku.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/lb.json b/files/plugin-CustomReports-5.5.0/lang/lb.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/lb.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/lt.json b/files/plugin-CustomReports-5.5.0/lang/lt.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/lt.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/lv.json b/files/plugin-CustomReports-5.5.0/lang/lv.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/lv.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/ms.json b/files/plugin-CustomReports-5.5.0/lang/ms.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/ms.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/nb.json b/files/plugin-CustomReports-5.5.0/lang/nb.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/nb.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/nl.json b/files/plugin-CustomReports-5.5.0/lang/nl.json new file mode 100644 index 0000000..8b8d002 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/nl.json @@ -0,0 +1,70 @@ +{ + "CustomReports": { + "AddDimension": "Voeg dimensie toe", + "AddMetric": "Voeg statistiek toe", + "ApplyTo": "Van toepassing op", + "AvailableAllWebsites": "Beschikbaar in alle websites.", + "Category": "Categorie", + "ConfirmUnlockReport": "Weet je zeker dat je dit rapport wilt openen? Wanneer je de inhoud van het rapport update, eerder gegenereerde rapporten van dit maatwerk rapport worden dan onbeschikbaar.", + "CreateNewReport": "Maak een nieuw rapport", + "CustomReport": "Maatwerk rapport", + "CustomReportIntroduction": "Maatwerk rapporten laten je nieuwe rapporten maken waarmee je nieuwe inzichten krijgt die niet beschikbaar zijn in de standaard rapporten. Je kiest een dimensie (zoals \"Apparaat type\" en \"Browser\") en statistieken (zoals \"Aantal bezoekers\" en \"Bounce rate\") en hoe dit getoond dient te worden om de unieke actiegerichte en nuttige data te krijgen die je zoekt.", + "CustomReports": "Maatwerk rapporten", + "DeleteReportConfirm": "Weet je zeker dat je dit maatwerk rapport wilt verwijderen? Eerder gegenereerde rapportages zijn dan niet meer beschikbaar in de UI.", + "DeleteReportInfo": "Wanneer je het rapport verwijderd, wordt het rapport niet meer beschikbaar. Deze actie kan je niet meer ongedaan maken.", + "Dimension": "Dimensies", + "Dimensions": "Dimensies", + "EditReport": "Bewerk rapport", + "ErrorArrayMissingItem": "Missende waarde in \"%1$s\" reeks op positie \"%2$s\".", + "ErrorDuplicateItem": "Het is niet toegestaan om dezelfde %s meerdere keren te gebruiken.", + "ErrorInvalidValueInArray": "Het gegeven \"%1$s\" op positie \"%2$s\" bestaat niet.", + "ErrorMissingDimension": "Minstens één dimensie dient geselecteerd te worden.", + "ErrorMissingMetric": "Minstens één statistiek dient geselecteerd te worden.", + "ErrorNotAnArray": "\"%1$s\" dient een reeks te zijn.", + "ErrorReportNameDuplicate": "De rapport naam is al in gebruik door een ander rapport.", + "ErrorTooManyDimension": "Teveel dimensies zijn geselecteerd. Maximaal %s dimensies kunnen worden gebruikt.", + "ErrorXNotProvided": "Geef een waarde voor \"%1$s\".", + "ErrorXNotWhitelisted": "De waarde voor \"%1$s\" is niet toegestaan. Gebruik één van de: %2$s", + "ErrorXTooLong": "\"%1$s\" is te lang, maximaal %2$skarakters zijn toegestaan.", + "FieldDescriptionPlaceholder": "bv 'het aantal bezoekers per land'", + "FieldNamePlaceholder": "bv 'Bezoekers per land'", + "Filter": "Filter", + "InfoReportIsLocked": "Dit rapport is op dit moment gesloten en je dient eerst te bevestigen om dit rapport te ontgrendelen. Wanneer je wijzigingen aan dit rapport maakt en het daarna bijwerkt, nieuwe rapportage data dient dan gegenereerd te worden en eerder gegenereerde rapportages voor dit maatwerk rapport zijn dan niet meer beschikbaar.", + "ManageReports": "Beheer rapporten", + "NCustomReports": "%s maatwerk rapporten", + "NoCustomReportsFound": "Geen maatwerk rapport gevonden", + "NoDataNotArchivedYet": "Wanneer het rapport onlangs is gemaakt of bijgewerkt, dan kan het enkele uren duren voordat de data van vandaag beschikbaar wordt. De data van rapporten van voordat het rapport gecreëerd is komt niet beschikbaar.", + "NoDataNotArchivedYetReprocess": "Echter, een systeem administrator kan Matomo forceren om %1$s de rapporten %2$s opnieuw te verwerken om de data beschikbaar te maken.", + "NoDataRemovedMetrics": "Er wordt geen data getoond omdat dit rapport alleen metrics bevat die niet worden ondersteund voor de geselecteerde periode (%s).", + "Preview": "Preview", + "PreviewDate": "Deze preview is gebaseerd op data van %s.", + "PreviewReport": "Preview rapport", + "PreviewSupportsDimension": "De huidige preview ondersteund tot %s dimensies.", + "RemoveDimension": "Verwijder dimensie", + "RemoveMetric": "Verwijder statistiek", + "RemovedMetrics": "Deze metrics zijn verwijderd omdat deze niet correct berekend kunnen worden over deze periode: %s.", + "ReportAllWebsitesHelp": "Selecteer de website waar deze rapportage voor beschikbaar moet zijn. Je kan ook \"Alle websites\" selecteren om deze rapportage voor alle websites beschikbaar te maken (dit is ook voor websites die in de toekomst aangemaakt worden.)", + "ReportAvailableToAllWebsites": "Dit rapport is beschikbaar voor alle websites.", + "ReportCategory": "Rapport categorie", + "ReportCategoryHelp": "Selecteer de hoofdcategorie waaronder dit rapport beschikbaar komt in het rapport menu.", + "ReportContent": "Rapport inhoud", + "ReportCreated": "Het rapport is met succes aangemaakt.", + "ReportDescriptionHelp": "Een optionele omschrijving voor je rapport, welke wordt getoond wanneer je op help icoon klikt naast de rapport titel klikt. Je kan bijvoorbeeld het doel benoemen van het rapport.", + "ReportHasFilterApplied": "Dit rapport heeft de volgende filters: 1%s", + "ReportMetricsHelp": "Statistieken zijn kwantitatieve metingen. Bijvoorbeeld, wanneer je het verkeer op je website wilt verhogen, voeg \"Bezoeken\" toe. Wanneer je de prestatie van je website wilt verbeteren, voeg dan \"Gemiddelde generatietijd \"toe.", + "ReportName": "Rapport naam", + "ReportNameHelp": "Bepaalt onder welke naam dit rapport beschikbaar wordt.", + "ReportPage": "Rapport pagina", + "ReportSegmentHelp": "Een filter laat alleen een deelverzameling van het rapport van je bezoekers zien. Bijvoorbeeld, je kan het rapport filteren op alleen smartphone bezoekers.", + "ReportSubcategory": "Rapport subcategorie", + "ReportSubcategoryHelp": "Optioneel kan je dit rapport toevoegen aan een bestaande rapportage pagina. Wanneer je geen subcategorie selecteert, wordt er een nieuwe rapportage pagina, met als titel de rapportnaam, aangemaakt onder de geselecteerde categorie.", + "ReportType": "Rapport type", + "ReportUpdated": "Het rapport is succesvol bijgewerkt.", + "Type": "Type", + "Unlock": "Ontgrendel", + "UpdatingData": "Bijwerken gegevens…", + "ViewReportInfo": "Bekijk rapport", + "WarningOnUpdateReportMightGetLost": "Wanneer je dit maatwerk rapport bijwerkt, worden eerder gegenereerde rapporten van dit aangepaste rapport niet meer beschikbaar.", + "WarningRequiresUnlock": "Om aan dit rapport wijzigingen te maken, dient dit als eerst ontgrendeld te worden. Wanneer je wijzigingen maakt aan aan de inrichting van het rapport en deze update, zal nieuwe rapport data gegenereerd worden en eerdere gemaakte rapporten van dit rapport zijn dan niet meer beschikbaar." + } +} diff --git a/files/plugin-CustomReports-5.5.0/lang/nn.json b/files/plugin-CustomReports-5.5.0/lang/nn.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/nn.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/pl.json b/files/plugin-CustomReports-5.5.0/lang/pl.json new file mode 100644 index 0000000..bace865 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/pl.json @@ -0,0 +1,44 @@ +{ + "CustomReports": { + "AddDimension": "Dodaj wymiar", + "AddMetric": "Dodaj metrykę", + "ApplyTo": "Zastosuj do", + "AvailableAllWebsites": "Dostępny we wszystkich serwisach", + "Category": "Kategoria", + "CreateNewReport": "Utwórz nowy raport", + "CustomReport": "Niestandardowy Raport", + "CustomReports": "Niestandardowe Raporty", + "Dimension": "Wymiar", + "Dimensions": "Wymiary", + "EditReport": "Edycja raportu", + "ErrorNotAnArray": "\"%1$s\" musi być tabelą.", + "ErrorReportNameDuplicate": "Wybrana nazwa jest już wykorzystywana przez inny raport.", + "ErrorXNotProvided": "Proszę podać wartość dla \"%1$s\".", + "ErrorXNotWhitelisted": "Niedozwolona wartość pola \"%1$s\", spróbuj jednej z: %2$s.", + "ErrorXTooLong": "Ciąg \"%1$s\" jest za długi. Dopuszczone są ciągi o długości do %2$s znaków.", + "FieldDescriptionPlaceholder": "np. 'Liczba odwiedzających na kraj'", + "FieldNamePlaceholder": "np. 'Odwiedzający na Kraj'", + "Filter": "Filtruj", + "ManageReports": "Zarządzaj Raportami", + "NCustomReports": "%s niestandardowe raporty", + "NoCustomReportsFound": "Brak raportów niestandardowych", + "Preview": "Podgląd", + "PreviewReport": "Podgląd raportu", + "RemoveDimension": "Usuń wymiar", + "RemoveMetric": "Usuń metrykę", + "ReportAvailableToAllWebsites": "Ten raport dostępny jest dla wszystkich serwisów.", + "ReportCategory": "Kategoria raportu", + "ReportContent": "Zawartość raportu", + "ReportCreated": "Pomyślnie utworzono raport.", + "ReportName": "Nazwa raportu", + "ReportNameHelp": "Określa nazwę, która będzie reprezentowała raport.", + "ReportPage": "Strona raportu", + "ReportSubcategory": "Podkategoria raportu", + "ReportType": "Typ raportu", + "ReportUpdated": "Pomyślnie zaktualizowano raport.", + "Type": "Typ", + "Unlock": "Odblokuj", + "UpdatingData": "Aktualizacja danych…", + "ViewReportInfo": "Raport wyświetleń" + } +} diff --git a/files/plugin-CustomReports-5.5.0/lang/pt-br.json b/files/plugin-CustomReports-5.5.0/lang/pt-br.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/pt-br.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/pt.json b/files/plugin-CustomReports-5.5.0/lang/pt.json new file mode 100644 index 0000000..e1fa11c --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/pt.json @@ -0,0 +1,70 @@ +{ + "CustomReports": { + "AddDimension": "Adicionar dimensão", + "AddMetric": "Adicionar métrica", + "ApplyTo": "Aplicar a", + "AvailableAllWebsites": "Disponível em todos os sites", + "Category": "Categoria", + "ConfirmUnlockReport": "Tem a certeza que pretende desbloquear este relatório? Quando atualiza o conteúdo deste relatório, os relatórios gerados anteriormente para este relatório personalizado ficarão indisponíveis.", + "CreateNewReport": "Criar novo relatório", + "CustomReport": "Relatório personalizado", + "CustomReportIntroduction": "Os Relatórios personalizados permitem criar novos relatórios para obter novas perspetivas que não são possíveis com os relatórios predefinidos. Pode escolher as dimensões (tais como \"Tipo de dispositivo\" e \"Navegador\") e as métricas (como o \"Número de visitantes\" e \"Taxa de ressalto\") e como devem ser apresentados para obter os dados únicos e úteis que pretende.", + "CustomReports": "Relatórios personalizados", + "DeleteReportConfirm": "Tem a certeza que pretende eliminar este relatório personalizado? Os relatórios gerados anteriormente deixarão de estar disponíveis a partir do interface dos relatórios.", + "DeleteReportInfo": "Quando eliminar este relatório os respetivos relatórios deixarão de estar disponíveis e não poderá anular esta ação.", + "Dimension": "Dimensão", + "Dimensions": "Dimensões", + "EditReport": "Editar relatório", + "ErrorArrayMissingItem": "Valor em falta no vetor \"%1$s\" na posição \"%2$s\".", + "ErrorDuplicateItem": "Não é permitida a utilização do mesmo %s múltiplas vezes.", + "ErrorInvalidValueInArray": "O \"%1$s\" fornecido na posição \"%2$s\" não existe.", + "ErrorMissingDimension": "Tem de ser selecionada pelo menos uma dimensão.", + "ErrorMissingMetric": "Pelo menos uma métrica tem de ser selecionada.", + "ErrorNotAnArray": "\"%1$s\" tem de ser um array.", + "ErrorReportNameDuplicate": "O nome do relatório já está a ser utilizado por outro relatório.", + "ErrorTooManyDimension": "Demasiadas dimensões selecionadas. Podem ser utilizadas um máximo de %s dimensões.", + "ErrorXNotProvided": "Por favor, forneça um valor para \"%1$s\".", + "ErrorXNotWhitelisted": "O valor para \"%1$s\" não é permitido; utilize um de: %2$s.", + "ErrorXTooLong": "\"%1$s\" é demasiado longo; pode utilizar um máximo de %2$s carateres.", + "FieldDescriptionPlaceholder": "Por exemplo, 'O número de visitantes por país'", + "FieldNamePlaceholder": "Por exemplo, 'Visitantes por país'", + "Filter": "Filtro", + "InfoReportIsLocked": "Este relatório está bloqueado e necessita de confirmar para \"desbloquear\" este relatório. Quando faz alterações ao conteúdo deste relatório e o atualiza posteriormente, terão de ser gerados novos resultados e os relatórios anteriores para este relatório personalizado deixarão de estar disponíveis.", + "ManageReports": "Gerir relatórios", + "NCustomReports": "%s relatórios personalizados", + "NoCustomReportsFound": "Não foram encontrados relatórios personalizados", + "NoDataNotArchivedYet": "Dado que o relatório foi criado ou atualizado recentemente, pode demorar algumas horas até que os dados sejam disponibilizados quando está a visualizar os relatórios do dia atual. Os resultados para datas anteriores à data de criação deste relatório não serão disponibilizados.", + "NoDataNotArchivedYetReprocess": "No entanto, um administrador de sistema pode forçar o Matomo a %1$sreprocessar os relatórios%2$s para disponibilizar estes resultados.", + "NoDataRemovedMetrics": "Não são apresentados dados porque este relatório apenas contém métricas que não são suportadas pelo período selecionado (%s).", + "Preview": "Pré-visualização", + "PreviewDate": "Esta pré-visualização é baseada nos dados de %s.", + "PreviewReport": "Pré-visualizar relatório", + "PreviewSupportsDimension": "A pré-visualização apenas suporta até %s dimensões.", + "RemoveDimension": "Remover dimensão", + "RemoveMetric": "Remover métrica", + "RemovedMetrics": "Estas métricas foram removidas porque não podem ser corretamente calculadas para este período: %s.", + "ReportAllWebsitesHelp": "Selecione o site no qual este relatório ficará disponível. Também pode escolher \"Todos os sites\" para disponibilizar este relatório para todos os sites (incluindo sites que venham a ser criados no futuro).", + "ReportAvailableToAllWebsites": "Este relatório está disponível para todos os sites.", + "ReportCategory": "Categoria do relatório", + "ReportCategoryHelp": "Selecione a categoria principal sob a qual este relatório ficará disponível no menu dos relatórios.", + "ReportContent": "Conteúdo do relatório", + "ReportCreated": "O relatório foi criado com sucesso.", + "ReportDescriptionHelp": "Uma descrição opcional para o seu relatório que será apresentada quando clica no ícone de ajuda junto ao nome do relatório. Por exemplo, poderia indicar o propósito deste relatório.", + "ReportHasFilterApplied": "Este relatório tem o seguinte filtro aplicado: %s.", + "ReportMetricsHelp": "As métricas são medidas quantitativas. Por exemplo, se quiser aumentar o tráfego no seu site, inclua \"Visitas\"; se pretende melhorar o desempenho, inclua \"Tempo médio de processamento\".", + "ReportName": "Nome do relatório", + "ReportNameHelp": "Define o nome sob o qual este relatório ficará disponível.", + "ReportPage": "Página do relatório", + "ReportSegmentHelp": "Um filtro permite-lhe ver este relatório apenas para um subconjunto dos seus visitantes. Por exemplo, pode filtrar este relatório para apenas utilizadores de dispositivos móveis.", + "ReportSubcategory": "Subcategoria do relatório", + "ReportSubcategoryHelp": "Opcionalmente, pode adicionar este relatório a qualquer página existente. Se não escolher uma subcategoria, será criada uma nova página sob a categoria selecionada utilizando o nome do relatório.", + "ReportType": "Tipo de relatório", + "ReportUpdated": "O relatório foi atualizado com sucesso.", + "Type": "Tipo", + "Unlock": "Desbloquear", + "UpdatingData": "A atualizar dados…", + "ViewReportInfo": "Ver relatório", + "WarningOnUpdateReportMightGetLost": "Quando atualiza este relatório personalizado, os resultados gerados anteriormente para o mesmo deixarão de estar disponíveis.", + "WarningRequiresUnlock": "Para fazer alterações ao conteúdo deste relatório, necessita de \"desbloquear\" este relatório. Quando faz alterações à configuração e atualiza o relatório, têm de ser gerados novos dados e os resultados de relatórios anteriores deixarão de estar disponíveis." + } +} diff --git a/files/plugin-CustomReports-5.5.0/lang/ro.json b/files/plugin-CustomReports-5.5.0/lang/ro.json new file mode 100644 index 0000000..d79cb1b --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/ro.json @@ -0,0 +1,5 @@ +{ + "CustomReports": { + "CustomReports": "Rapoarte particularizate" + } +} diff --git a/files/plugin-CustomReports-5.5.0/lang/ru.json b/files/plugin-CustomReports-5.5.0/lang/ru.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/ru.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/si.json b/files/plugin-CustomReports-5.5.0/lang/si.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/si.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/sk.json b/files/plugin-CustomReports-5.5.0/lang/sk.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/sk.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/sl.json b/files/plugin-CustomReports-5.5.0/lang/sl.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/sl.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/sq.json b/files/plugin-CustomReports-5.5.0/lang/sq.json new file mode 100644 index 0000000..ca339ef --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/sq.json @@ -0,0 +1,128 @@ +{ + "CustomReports": { + "ActivityReportAddedDescription": "u shtua një raport vetjak “%1$s“ për sajtin “%2$s”", + "ActivityReportDeletedDescription": "u fshi raporti “%1$s” për sajtin “%2$s”", + "ActivityReportPausedDescription": "u ndal raporti “%1$s” për sajtin “%2$s”", + "ActivityReportResumedDescription": "u rinis raporti “%1$s” për sajtin “%2$s”", + "ActivityReportUpdatedDescription": "u përditësua raporti “%1$s” për sajtin “%2$s”", + "AddDimension": "Shtoni përmasë", + "AddMetric": "Shtoni matje", + "ApplyTo": "Zbatoje mbi", + "AvailableAllWebsites": "Mund të kihet për krejt sajtet", + "Category": "Kategori", + "CommonUniqueMetric": "Shumë e %1$s", + "CommonUniqueMetricDescription": "%1$s. Kur shihet një periudhë që s’është ditë, kjo vlerë do të bëhet “Shumë e %2$s”. Në rast të tillë, nëse e njëjta %3$s shfaqet në 2 ose më tepër ditë brenda periudhës së përzgjedhur, do të numërohet si 2, ose si numri gjithsej i ditëve ku është shfaqur dhe jo si 1.", + "ConfirmUnlockReport": "Jeni i sigurt se doni të shkyçet ky raport? Kur përditësoni lëndën për këtë raport vetjak, të dhënat e prodhuara më herët për të s’do të mund të kihen më.", + "ConfirmUnlockReportBrowserArchivingDisabled": "Jeni i sigurt se doni të shkyçet ky raport? Kur përditësoni lëndën e raportit për këtë raport, raporte të prodhuar më parë për këtë raport vetjak do të bëhen të papërdorshëm dhe të dhënat për %1$s muajt e fundit do të riprodhohen.", + "ContentImpressions": "Përshtypje Nga Lënda", + "CreateNewReport": "Krijoni raport të ri", + "CustomReport": "Raport Vetjak", + "CustomReportCopied": "Raporti vetjak u kopjua me sukses.", + "CustomReportDuplicationError": "Proces i paplotësuar. Raporti vetjak s’u kopjua dot te krejt sajtet e përzgjedhur.", + "CustomReportDuplicationSiteTypeError": "Veçoria e kopjimit nuk mbulohet për sajtet: %1$s.", + "CustomReportIntroduction": "Raportet Vetjakë ju lejojnë të krijoni raporte të rinj për të ndjekur prirje të reja që s’janë të mundshme me raportet standarde. Përmasat (të tilla si, “Lloj Pajisjeje” dhe “Shfletues”), matjet (të tilla si, “Numër Vizitorësh” dhe “Nivel Kthimesh”) dhe se si duhet të shfaqen për të pasur të dhënat unike të përdorshme dhe të dobishme që ju nevojiten, i zgjidhni ju.", + "CustomReports": "Raporte Vetjakë", + "DeleteExceptionMessage": "S’fshihet dot raporti, sajti s’përputhet", + "DeleteReportConfirm": "Jeni i sigurt se doni të fshihet ky raport vetjak? Raportet e prodhuar më herët s’do të mund të kihen më përmes UI-t të raporteve.", + "DeleteReportInfo": "Kur e fshini këtë raport, raportet e tij s’do të mund të kihen më dhe këtë veprim s’mund ta zhbëni.", + "Dimension": "Përmasë", + "Dimensions": "Përmasa", + "EditReport": "Përpunoni raport", + "ErrorArrayMissingItem": "Te matrica “%1$s” mungon vlerë në pozicionin “%2$s”.", + "ErrorCustomReportDoesNotExist": "Raporti vetjak i kërkuar s’ekziston", + "ErrorDuplicateItem": "Nuk lejohet të përdoret shumë herë i njëjti %s.", + "ErrorInvalidDimension": "Përmasa %1$s nuk mbulohet në Raporte të Përshtatur.", + "ErrorInvalidMultipleIdSite": "Për ID sajtesh të shumtë, duhet përfshirë në listë idsite:%1$s.", + "ErrorInvalidSubCategoryReport": "ID të pavlefshme raportesh nënkategorish.", + "ErrorInvalidValueInArray": "“%1$s” e dhënë në pozicionin “%2$s” s’ekziston.", + "ErrorMissingDimension": "Duhet përzgjedhur të paktën një përmasë.", + "ErrorMissingMetric": "Duhet përzgjedhur të paktën një matje.", + "ErrorNotAnArray": "“%1$s” duhet të jetë një matricë.", + "ErrorProductRevenueMetricDependency": "Vlerat për “Të Ardhura Produktesh” lypin në të njëjtin raport një vlerë shoqëruese “Sasi Produkti”.", + "ErrorReportDoesNotExist": "Raporti vetjak i kërkuar s’ekziston për këtë sajt. Ju lutemi, sigurohuni se sajti është i saktë.", + "ErrorReportNameDuplicate": "Emri i raportit është tashmë në përdorim nga një raport tjetër.", + "ErrorTooManyDimension": "Janë përzgjedhur shumë përmasa. Mund të përdoren e shumta %s përmasa.", + "ErrorXNotProvided": "Ju lutemi, jepni një vlerë për “%1$s”.", + "ErrorXNotWhitelisted": "Vlera për “%1$s” nuk lejohet, përdorni një nga: %2$s.", + "ErrorXTooLong": "“%1$s” është shumë e gjatë, lejohen e shumta %2$s shenja.", + "FieldDescriptionPlaceholder": "p.sh., “Numër vizitorësh për vend”", + "FieldNamePlaceholder": "p.sh., “Vizitorë sipas Vendesh”", + "Filter": "Filtër", + "FindMeasurables": "Gjeni të matshëm", + "InfoReportIsLocked": "Ky raport është i kyçur dhe lypset të ripohoni “shkyçjen” e tij. Kur bëni ndryshime te lënda e këtij raporti dhe në vijim e përditësoni, lypset të prodhohen të dhëna të reja dhe të dhënat e prodhuara më herët për këtë raport vetjak s’do të mund të kihen më.", + "InfoReportIsLockedBrowserArchivingDisabled": "Ky raport është i kyçur dhe lypset të ripohoni “shkyçjen” e tij. Kur bëni ndryshime te lënda e këtij raporti dhe në vijim e përditësoni, do të prodhohen të dhëna të reja për %1$s muajt e fundit dhe të dhënat e prodhuara më herët për këtë raport vetjak s’do të mund të kihen më.", + "ManageReports": "Administroni Raporte", + "ManageReportsSubcategoryHelp": "Raporte Vetjake ju lejojnë të krijoni raporte të reja për të analizuar prirje të reja, çka s’është e mundshme me raportet standarde. Kjo ndarje ju jep mundësinë të krijoni dhe administroni Raporte Vetjake për sajtin tuaj.", + "MatchingSearchConfirmTitle": "Jeni i sigurt se doni të shtohen të matshme që përkojnë me %s?", + "MatchingSearchConfirmTitleAlreadyAdded": "Janë shtuar tashmë %s të matshme me përkim", + "MatchingSearchMatchedAdd": "Këto %1$s të matshme përkojnë me %2$s dhe do të shtohen te ky raport vetjak", + "MatchingSearchMatchedAlreadyAdded": "Këto %1$s të matshme përkojnë me %2$s, por gjenden tashmë te ky raport vetjak", + "MatchingSearchNotFound": "S’ka të matshme që përkon me termin e kërkimit %s.", + "NCustomReports": "%s raporte vetjakë", + "NoCustomReportsFound": "S’u gjetën raporte vetjakë", + "NoDataMessagePausedStateAdminUser": "Ky raport është ndalur, s’do të ketë të dhëna të reja, para se raporti të riniset. Që të rinisni raportin, kaloni te %1$sAdministroni Raporte%2$s dhe klikoni Luaje për raportin e përzgjedhur.", + "NoDataMessagePausedStateNonAdminUser": "Ky raport është ndalur, s’do të ketë të dhëna të reja, para se raporti të riniset . Nëse ju duhet hyrje te raporti, ju lutemi, lidhuni me superpërdoruesin e Matomo-s tuaj.", + "NoDataNotArchivedYet": "Meqë raporti u krijua apo përditësua vetëm tani afër, mund të duhen disa orë që të ketë të dhëna të përdorshme për të parë raporte për sot. S’do të ketë të përdorshme të dhëna raporti për data më herët se të krijohej ky raport.", + "NoDataNotArchivedYetReprocess": "Sidoqoftë, një përgjegjës sistemi mund ta detyrojë Matomo-n t’i %1$sripërpilojë raportet%2$s për t’i pasur këto të dhëna.", + "NoDataRemovedMetrics": "S’janë shfaqur të dhëna, ngaqë ky raport përmban vetëm vlera që s’mbulohen nga periudha e përzgjedhur (%s).", + "NoMeasurableAssignedYet": "Ende pa caktim të matshmesh, ju lutemi, përzgjidhni një të matshme që nga përzgjedhësi më sipër.", + "NoSegmentationJoinExceptionMessage": "Renditja, ose ndërthurja e tanishme e përmasave s’është e përputhshme. %1$sMësoni më tepër%2$s.", + "OrderSubCategoryReports": "Renditni raporte Nënkategorish", + "OrderSubCategoryReportsDescription": "Opsional: Përimtoni rendin në të cilin do të shfaqen nënkategoritë, kur shihet ky raport i përshtatur.", + "PauseExceptionMessage": "S’ndalet dot raporti, sajti s’përputhet", + "PauseReportConfirm": "Ndalja e këtij raporti ndal arkivimin e tij deri kur të rinisni raportin. Jeni i sigurt se doni të ndalet ky raport?", + "PauseReportInfo": "Ndalja e këtij raporti ndal arkivimin e tij deri kur të riniset raportimi.", + "PausedReport": "Raporti është ndalur me sukses.", + "Preview": "Paraparje", + "PreviewDate": "Kjo paraparje bazohet në të dhënat e %s.", + "PreviewReport": "Paraparje raporti", + "PreviewReportExceedsMaximumTimeFrameValueAllowed": "Intervali kohor maksimum për paraparje raporti s’mund të jetë > 24 orë.", + "PreviewReportInvalidTimeFrameValues": "Për paraparje raporti janë vënë vlera të pavlefshme intervalesh kohorë, vlerat e lejuara janë sekonda, minuta, orë dhe ditë.", + "PreviewSupportsDimension": "Hëpërhë paraparja mbulon deri në %s përmasa.", + "ProductsWithX": "Produkte me %s", + "QuotaReachedForX": "Keni mbërritur në kufirin e kuotës tuaj. S’arrihet të kopjohet %1$s, për shkak tejkalimi përdorimi. Që të administroni kuotën tuaj, shihni mundësinë e fshirjes së %2$s të papërdorur, ose më pak kritike.", + "RemoveDimension": "Hiqe përmasën", + "RemoveMetric": "Hiqe matjen", + "RemovedMetrics": "Këto vlera u hoqën, ngaqë s’mund të përllogariten saktë për këtë periudhë: %s.", + "ReportAllWebsitesHelp": "Përzgjidhni sajtin për të cilin duhet të jepet ky raport. Mund të shtoni edhe “Krejt sajtet”, për ta bërë këtë raport të përdorshëm në krejt sajtet tuaj (dhe në sajte që do të krijohen në të ardhmen).", + "ReportAllWebsitesNonSuperUserHelp": "Përzgjidhni sajtin për të cilin duhet të prodhohet ky raport.", + "ReportAvailableToAllWebsites": "Ky raport mund të kihet për krejt sajtet.", + "ReportAvailableToMultipleWebsites": "Ky raport është i passhëm për shumë sajte.", + "ReportCategory": "Kategori raporti", + "ReportCategoryHelp": "Përzgjidhni kategorinë kryesore të raporteve nën të cilën do të ofrohet ky raport te menuja e raportimeve.", + "ReportContent": "Lëndë raporti", + "ReportCreated": "Raporti u krijua me sukses.", + "ReportDescriptionHelp": "Një përshkrim opsional për raportin tuaj, që do të shfaqet kur klikoni mbi tekstin e ikonës, në krah të titullit të raportit. Për shembull, mund të donit të shfaqej qëllimi i këtij raporti.", + "ReportDimensionsHelpExtended": "Nëse ju duhen përmasa shtesë, mund të rritni kufirin. %1$sMësoni më tepër%2$s.", + "ReportDimensionsHelpNew": "Një përmasë zakonisht përshkruan karakteristika dhe shfaqet zakonisht në shtyllën e parë të një raporti. Mund të përzgjidhni përmasa të shumta (p.sh., Shfletues, Vend, Qytet). Duhet përzgjedhur të paktën një përmasë.", + "ReportEditNotAllowedAllWebsitesUpdated": "Ngaqë ky raport mund të kihet për krejt sajtet, vetëm një përdorues me leje superpërdoruesi mund ta ndryshojë këtë raport.", + "ReportEditNotAllowedMultipleWebsitesAccessIssue": "Ngaqë ky raport mund të kihet për sajte të shumtë, vetëm një superpërdorues, ose një përgjegjës me leje mbi krejt sajtet e përfshirë në raport mund ta ndryshojë.", + "ReportHasFilterApplied": "Mbi këtë raport është aplikuar filtri vijues: %s.", + "ReportInPausedStateAdmin": "Ky raport është ndalur. S’do të kryhet arkivim, para se raporti të jetë rinisur. Që të rinisni raportin, kaloni te %1$sAdministroni Raporte%2$s dhe klikoni Luaje, për raportin e përzgjedhur.", + "ReportMetricsHelp": "Matjet janë matje sasiore. Për shembull, nëse doni të shtoni trafikun në sajtin tuaj, përfshini “Vizita”, nëse po merreni me funksionimin e sajtit tuaj, përfshini “Kohë Mesatare Prodhimi”.", + "ReportName": "Emër raporti", + "ReportNameHelp": "Përcakton emrin me të cilin do të jepet ky raport.", + "ReportPage": "Faqe raporti", + "ReportSegmentHelp": "Një filtër ju lejon ta shihni këtë raport vetëm për një nëngrup vizitorësh. Për shembull, mund ta filtroni këtë raport vetëm për përdorues me pajisje celulare.", + "ReportSubcategory": "Nënkategori raportesh", + "ReportSubcategoryHelp": "Në daçi, mund ta shtoni këtë raport te cilado faqe ekzistuese raportimi. Nëse nuk zgjidhni një nënkategori, do të krijohet një faqe e re raportimi nën kategorinë e përzgjedhur, duke përdorur emrin e raportit.", + "ReportType": "Lloj raporti", + "ReportUpdated": "Raporti u përditësua me sukses.", + "ResumeExceptionMessage": "S’riniset dot raporti, sajti s’përputhet", + "ResumeReportConfirm": "Kur rinisni këtë raport, arkivimi do të rinisë nga sot. Jeni i sigurt se doni të riniset ky raport vetjak?", + "ResumeReportInfo": "Kur rinisni këtë raport, arkivimi do të rifillojë nga sot.", + "ResumedReport": "Raporti u rinis me sukses.", + "SelectMeasurablesMatchingSearch": "ose shtoni krejt të matshmit që përmbajnë termin vijues të kërkimit", + "SourceCustomReportLookupError": "Pati një gabim të papritur me kërkimin e raportit vetjak burim. Ju lutemi, riprovoni. Nëse problemi vazhdon, lidhuni me përgjegjësin tuaj, ose me asistencën.", + "Type": "Lloj", + "Unlock": "Shkyçe", + "UpdatingData": "Po përditësohen të dhëna…", + "ViewReportInfo": "Shiheni raportin", + "WarningOnUpdateReportMightGetLost": "Kur e përditësoni këtë raport vetjak, raportet e prodhuar më herët për këtë raport vetjak s’do të mund të kihen më.", + "WarningOnUpdateReportMightGetLostBrowserArchivingDisabled": "Kur përditësoni këtë raport vetjak, raporte të prodhuar më parë do të jenë të papërdorshëm dhe të dhënat për %1$s muajt e fundit do të riprodhohen.", + "WarningProductRevenueMetricDependency": "Vlerat për “Të Ardhura Produkti” lypin në të njëjtin raport një vlerë shoqëruese “Sasi Produkti”. Vlera shoqëruese është shtuar automatikisht. %1$sMësoni më tepër%2$s.", + "WarningRequiresUnlock": "Që të mund të bëni ndryshime te lënda e këtij raporti, lypset ta “shkyçni” këtë raport. Kur bëni ndryshime te formësimi i raportit dhe e përditësoni këtë raport, lypset të riprodhohen të dhëna të reja raporti dhe të dhënat e prodhuara më herët për këtë raport s’do të mund të kihen më.", + "WarningRequiresUnlockBrowserArchivingDisabled": "Që të mund të bëni ndryshime te lënda e këtij raporti, duhet ta “shkyçni” këtë raport. Kur bëni ndryshime te formësimi i raporteve dhe përditësoni këtë raport, të dhëna të reja raportimi për %1$s muajt e fundit do të riprodhohen dhe të dhënat e prodhuara më herët për këtë raport s’do të mund kihen më.", + "WebsiteName": "Emër sajti" + } +} diff --git a/files/plugin-CustomReports-5.5.0/lang/sr.json b/files/plugin-CustomReports-5.5.0/lang/sr.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/sr.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/sv.json b/files/plugin-CustomReports-5.5.0/lang/sv.json new file mode 100644 index 0000000..499bc19 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/sv.json @@ -0,0 +1,100 @@ +{ + "CustomReports": { + "AddDimension": "Lägg till dimension", + "AddMetric": "Lägg till mätvärde", + "ApplyTo": "Tillämpa på", + "AvailableAllWebsites": "Tillgänglig för alla webbplatser", + "Category": "Kategori", + "CommonUniqueMetric": "Summan av %1$s", + "CommonUniqueMetricDescription": "%1$s. När du tittar på en period som inte är en dag kommer det här mätvärdet att bli \"Summan av %2$s\". I de fallen, om samma %3$s finns för 2 eller fler dagar inom den valda perioden kommer det räknas som 2 eller det totala antalet dagar det fanns, och inte som 1.", + "ConfirmUnlockReport": "Är du säker på att du vill låsa upp denna rapport? När du uppdaterar rapportens innehåll kommer tidigare skapat data för rapporten att bli oåtkomliga.", + "ConfirmUnlockReportBrowserArchivingDisabled": "Är du säker på att vill låsa upp rapporten? När du uppdaterar denna rapport kommer tidigare genererade rapporter för denna anpassade rapport att bli oåtkomliga och rapporter för de senaste %1$s månaderna kommer återskapas.", + "ContentImpressions": "Innehållsexponeringar", + "CreateNewReport": "Skapa ny rapport", + "CustomReport": "Anpassas rapport", + "CustomReportIntroduction": "Anpassade rapporter låter dig skapa nya rapporter för att dra nya insikter som inte är möjliga med standardrapporterna. Du väljer dimensioner (som \"Typ av enhet\" och \"Webbläsare\") och mätetal (som \"Antal besökare\" och \"Avvisningsfrekvens\") och hur de ska visas för att ge den unikt agerbara och meningsfulla data du behöver.", + "CustomReports": "Anpassade rapporter", + "DeleteReportConfirm": "Är du säker på att du vill radera denna anpassade rapport? Tidigare genererade rapporter kommer inte längre vara åtkomliga via användargränssnittet för rapporter.", + "DeleteReportInfo": "När du raderar rapporten kommer dess rapportdata bli oåtkomligt och du kan inte ångra denna åtgärd i efterhand.", + "Dimension": "Dimension", + "Dimensions": "Dimensioner", + "EditReport": "Redigera rapport", + "ErrorArrayMissingItem": "Saknar ett värde i \"%1$s\" i array, vid position \"%2$s\".", + "ErrorDuplicateItem": "Det går inte att använda samma %s mer än en gång.", + "ErrorInvalidDimension": "Dimensionen %1$s stöds inte i anpassade rapporter.", + "ErrorInvalidMultipleIdSite": "För multipla site-id, site-id:%1$s bör läggas till listan.", + "ErrorInvalidSubCategoryReport": "Felaktigt rapport-id för underkategori.", + "ErrorInvalidValueInArray": "Angivna \"%1$s\" vid position \"%2$s\" existerar inte.", + "ErrorMissingDimension": "Åtminstone en dimension behöver väljas.", + "ErrorMissingMetric": "Åtminstone ett mätvärde måste väljas.", + "ErrorNotAnArray": "\"%1$s\" behöver vara en array.", + "ErrorProductRevenueMetricDependency": "Produktens mätetal för inkomst behöver i samma rapport också ha ett kvantitativt mätetal.", + "ErrorReportDoesNotExist": "Den anpassade rapporten finns inte för denna webbplats. Vänligen kontrollerade att webbplatsen är korrekt.", + "ErrorReportNameDuplicate": "Rapportnamnet används redan av en annan rapport.", + "ErrorTooManyDimension": "För många dimensioner är valda. Maximalt %s dimensioner kan användas.", + "ErrorXNotProvided": "Vänligen ange att värde för \"%1$s\".", + "ErrorXNotWhitelisted": "Värdet för \"%1$s\" är inte tillåtet, använd en av: %2$s.", + "ErrorXTooLong": "\"%1$s\" är för långt, maximalt %2$s tecken tillåts.", + "FieldDescriptionPlaceholder": "'Antalet besökare per land'", + "FieldNamePlaceholder": "'Besökare per land'", + "Filter": "Filter", + "FindMeasurables": "Hitta uppgifter", + "InfoReportIsLocked": "Rapporten är för närvarande låst och du behöver bekräfta genom att \"låsa upp\" rapporten. När du gör ändringar till rapporten och uppdaterar den kommer nya rapportdata att behöva genereras och tidigare genererade rapporter för den här anpassade rapporten blir oåtkomliga.", + "InfoReportIsLockedBrowserArchivingDisabled": "Rapporten är för närvarande låst och du behöver bekräfta genom att \"låsa upp\" rapporten. När du gör ändringar i denna rapport och uppdaterar den kommer ny rapportdata genereras för de senaste %1$s månaderna och tidigare skapade rapporter för denna anpassade rapport kommer bli oåtkomliga.", + "ManageReports": "Hantera rapporter", + "ManageReportsSubcategoryHelp": "Anpassade rapporter låter dig skapa nya rapporter för att dra insikter som inte är möjliga med standardrapporterna. Den här sektionen låter di skapa och hantera Anpassade Rapporter för din webbplats.", + "MatchingSearchConfirmTitle": "Är du säker på att du vill lägga till %s som matchar uppgifterna?", + "MatchingSearchConfirmTitleAlreadyAdded": "%s matchande uppgifter har redan lagts till", + "MatchingSearchMatchedAdd": "Dessa %1$s uppgifter matchade %2$s och kommer läggas till denna anpassade rapport", + "MatchingSearchMatchedAlreadyAdded": "Dessa %1$s uppgifter matchade %2$s men var redan tillagda i denna anpassade rapport", + "MatchingSearchNotFound": "Det finns ingen uppgift som matchar sökfrasen %s.", + "NCustomReports": "%s anpassade rapporter", + "NoCustomReportsFound": "Ingen anpassad rapport hittad", + "NoDataNotArchivedYet": "Då rapporten skapades eller uppdaterades nyligen kan det ta några timmar för data att synas för innevarande dags rapport. Rapportdata för datum före rapportens skapande kommer inte att bli åtkomliga.", + "NoDataNotArchivedYetReprocess": "Dock kan en systemadministratör tvinga Matomo att %1$såterbearbeta rapporter%2$s för att göra historiska data åtkomliga.", + "NoDataRemovedMetrics": "Inga data visas då denna rapport endast innehåller mätvärden som inte stöds av den valda perioden (%s).", + "NoMeasurableAssignedYet": "Inga uppgifter har tilldelats ännu, vänligen välj en uppgift från ovanstående alternativ.", + "OrderSubCategoryReports": "Ordningen av underkategorirapporter", + "OrderSubCategoryReportsDescription": "Valfritt: Justera ordningen för underkategorier som visas för denna anpassade rapport.", + "Preview": "Förhandsgranska", + "PreviewDate": "Förhandsgranskningen är baserad på data från %s.", + "PreviewReport": "Förhandsgranska rapport", + "PreviewSupportsDimension": "Förhandsgranskningen stöder upp till %s dimensioner.", + "ProductsWithX": "Produkter med %s", + "RemoveDimension": "Ta bort dimension", + "RemoveMetric": "Ta bort mätvärde", + "RemovedMetrics": "Dessa mätvärden togs bort för att de inte kan beräknas korrekt för perioden: %s.", + "ReportAllWebsitesHelp": "Välj webbplats denna rapport ska vara tillgänglig för. Du kan också välja \"Alla webbplatser\" för att göra rapporten tillgänglig för alla webbplatser (inklusive de som skapas i framtiden).", + "ReportAllWebsitesNonSuperUserHelp": "Välj vilken webbplats rapporten ska vara åtkomlig för.", + "ReportAvailableToAllWebsites": "Rapporten är tillgänglig för alla webbplatser.", + "ReportAvailableToMultipleWebsites": "Rapport är åtkomlig för flera webbplatser.", + "ReportCategory": "Raportkategori", + "ReportCategoryHelp": "Välj huvudsaklig kategori där denna rapport ska visas i rapportmenyn.", + "ReportContent": "Rapportinnehåll", + "ReportCreated": "Rapporten skapades lyckosamt.", + "ReportDescriptionHelp": "En valfri beskrivning för din rapport, vilket visas upp om du klickar på hjälpikonen bredvid rapportens titel. Exempelvis kan du ange syftet med denna rapport.", + "ReportEditNotAllowedAllWebsitesUpdated": "Eftersom denna rapport är åtkomlig för alla webbplatser kan endast en använder med superuser-åtkomst redigera rapporten.", + "ReportEditNotAllowedMultipleWebsitesAccessIssue": "Eftersom denna rapport är åtkomlig för ett flertal webbplatser kan endast en använder med superuser-åtkomst redigera rapporten.", + "ReportHasFilterApplied": "Denna rapport har följande filtreringar: %s.", + "ReportMetricsHelp": "Mätvärden är kvantitativa mått. Exempelvis, om du vill öka trafiken på din webbplats lägger du till \"Besök\", om du förbättrar hastigheten på webbplatsen lägger du till \"Genomsnittlig servertid\".", + "ReportName": "Rapportnamn", + "ReportNameHelp": "Anger namnet där denna rapport kommer visas.", + "ReportPage": "Rapportsida", + "ReportSegmentHelp": "Ett filter låter dig se rapporten för en delmängd av dina besökare. Exempelvis kan du filtrera denna rapport enbart mobilanvändare.", + "ReportSubcategory": "Rapportens underkategori", + "ReportSubcategoryHelp": "Valfritt: Du kan lägga denna rapport till en befintlig rapportsida. Ifall du inte väljer underkategori kommer en ny rapportsida att skapas under rapportkategorin med rapportens namn.", + "ReportType": "Typ av rapport", + "ReportUpdated": "Rapporten har blivit uppdaterad.", + "SelectMeasurablesMatchingSearch": "eller lägg till alla uppgifter som innehåller följande sökfras", + "Type": "Typ", + "Unlock": "Lås upp", + "UpdatingData": "Uppdaterar data…", + "ViewReportInfo": "Se rapport", + "WarningOnUpdateReportMightGetLost": "När du uppdaterar denna anpassade rapport kommer tidigare data i rapporten att bli oåtkomlig.", + "WarningOnUpdateReportMightGetLostBrowserArchivingDisabled": "När du uppdaterar denna anpassade rapport kommer tidigare genererade rapporter att bli oåtkomliga och rapporter för de senaste %1$s månaderna kommer att återskapas.", + "WarningProductRevenueMetricDependency": "Produktens mätetal för inkomst behöver i samma rapport också ha ett kvantitativt mätetal. Ett sådant mätetal lades till automatiskt. %1$sLär dig mer%2$s.", + "WarningRequiresUnlock": "För att göra ändringar till rapportens innehåll behöver rapporten \"låsas upp\". När du gör ändringar i rapportens konfiguration och uppdaterar den behöver nya rapportdata genereras och tidigare rapportdata kommer inte längre vara tillgängligt.", + "WarningRequiresUnlockBrowserArchivingDisabled": "För att göra ändringar i denna rapport behöver du \"låsa upp\" den. När du ändrar rapportens konfiguration och uppdaterar rapporten kommer nya rapportdata för de senaste %1$s månaderna att återskapas och tidigare genererade rapporter för denna anpassade rapport kommer inte längre vara åtkomliga.", + "WebsiteName": "Webbplatsnamn" + } +} diff --git a/files/plugin-CustomReports-5.5.0/lang/ta.json b/files/plugin-CustomReports-5.5.0/lang/ta.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/ta.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/te.json b/files/plugin-CustomReports-5.5.0/lang/te.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/te.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/th.json b/files/plugin-CustomReports-5.5.0/lang/th.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/th.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/tl.json b/files/plugin-CustomReports-5.5.0/lang/tl.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/tl.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/tr.json b/files/plugin-CustomReports-5.5.0/lang/tr.json new file mode 100644 index 0000000..2a7b952 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/tr.json @@ -0,0 +1,128 @@ +{ + "CustomReports": { + "ActivityReportAddedDescription": "\"%2$s\" sitesi için \"%1$s\" özel raporunu ekledi", + "ActivityReportDeletedDescription": "\"%2$s\" sitesi için \"%1$s\" raporunu sildi", + "ActivityReportPausedDescription": "\"%2$s\" sitesi için \"%1$s\" raporunu duraklattı", + "ActivityReportResumedDescription": "\"%2$s\" sitesi için \"%1$s\" raporunu sürdürdü", + "ActivityReportUpdatedDescription": "\"%2$s\" sitesi için \"%1$s\" raporunu güncelledi", + "AddDimension": "Boyut ekle", + "AddMetric": "Ölçüm ekle", + "ApplyTo": "Şuna uygula", + "AvailableAllWebsites": "Tüm siteler için kullanılabilir", + "Category": "Kategori", + "CommonUniqueMetric": "%1$s toplamı", + "CommonUniqueMetricDescription": "%1$s. Gün olmayan bir dönem görüntülenirken, bu ölçüm \"%2$s toplamı\" şeklinde olur. Böyle bir durumda, aynı %3$s, seçilmiş süre içinde 2 veya daha fazla günde görüntüleniyorsa, 1 değil, 2 veya göründüğü toplam gün sayısı olarak sayılır.", + "ConfirmUnlockReport": "Bu raporun kilidini açmak istediğinize emin misiniz? Bu özel raporu güncellediğinizde, bu özel rapor için daha önce üretilmiş raporlar kullanılamayacak.", + "ConfirmUnlockReportBrowserArchivingDisabled": "Bu raporun kilidini açmak istediğinize emin misiniz? Bu özel raporun içeriğini güncellediğinizde, daha önce üretilmiş raporlar kullanılamayacak ve son %1$s ayın raporları yeniden üretilecek.", + "ContentImpressions": "İçerik gösterimleri", + "CreateNewReport": "Yeni rapor ekle", + "CustomReport": "Özel rapor", + "CustomReportCopied": "Özel rapor kopyalandı.", + "CustomReportDuplicationError": "İşlem tamamlanmadı. Özel rapor seçilmiş tüm sitelere kopyalanamadı.", + "CustomReportDuplicationSiteTypeError": "Kopyalama özelliği sitelerde desteklenmiyor: %1$s.", + "CustomReportIntroduction": "Özel raporlar, standart raporlarda bulunmayan yeni bakış açıları oluşturmanızı sağlar. Boyutları (\"Aygıt türü\" ve \"Tarayıcı\" gibi) ve ölçümleri (\"Ziyaretçi sayısı\" ve \"Geri dönüş oranı\" gibi) ve nasıl görüneceğini seçerek eşsiz ve işinize yarayacak veriler elde edebilirsiniz.", + "CustomReports": "Özel raporlar", + "DeleteExceptionMessage": "Rapor silinemedi. Site eşleşmiyor", + "DeleteReportConfirm": "Bu özel raporu silmek istediğinize emin misiniz? Daha önce üretilmiş raporlar kullanıcı arayüzündeki raporlar bölümünde görüntülenmeyecek.", + "DeleteReportInfo": "Bu raporu sildiğinizde ilgili raporlar artık kullanılamayacak ve bu işlem geri alınamayacak.", + "Dimension": "Boyut", + "Dimensions": "Boyutlar", + "EditReport": "Raporu düzenle", + "ErrorArrayMissingItem": "\"%1$s\" dizisinde \"%2$s\" konumundaki değer eksik.", + "ErrorCustomReportDoesNotExist": "İstenilen özel rapor bulunamadı", + "ErrorDuplicateItem": "Aynı %s birden çok kez kullanılamaz.", + "ErrorInvalidDimension": "Özel Raporlar içinde %1$s boyutu desteklenmiyor.", + "ErrorInvalidMultipleIdSite": "Birden fazla site kimliği için, listeye idsite:%1$s eklenmelidir.", + "ErrorInvalidSubCategoryReport": "Alt kategori rapor kimlikleri geçersiz.", + "ErrorInvalidValueInArray": "\"%2$s\" konumunda belirtilen \"%1$s\" bulunamadı.", + "ErrorMissingDimension": "En az bir boyut seçilmelidir.", + "ErrorMissingMetric": "En az bir ölçüm seçilmelidir.", + "ErrorNotAnArray": "\"%1$s\" bir dizi olmalıdır.", + "ErrorProductRevenueMetricDependency": "Ürün geliri ölçümleri için aynı raporda eşlik edecek bir ürün adeti ölçümü gereklidir.", + "ErrorReportDoesNotExist": "Bu site için istenilen özel rapor bulunamadı. Lütfen sitenin doğru olduğundan emin olun.", + "ErrorReportNameDuplicate": "Aynı adı kullanan başka bir rapor zaten var.", + "ErrorTooManyDimension": "Çok fazla boyut seçilmiş. En fazla %s boyut kullanılabilir.", + "ErrorXNotProvided": "Lütfen \"%1$s\" için bir değer yazın.", + "ErrorXNotWhitelisted": "\"%1$s\" değeri kullanılamaz. Şunlardan birini kullanın: %2$s.", + "ErrorXTooLong": "\"%1$s\" çok uzun. İzin verilen en fazla karakter sayısı %2$s.", + "FieldDescriptionPlaceholder": "Örnek: 'Ülkelere göre ziyaretçi sayıları'", + "FieldNamePlaceholder": "Örnek: 'Ülkelere göre ziyaretçiler'", + "Filter": "Süzgeç", + "FindMeasurables": "Ölçülebilir arama", + "InfoReportIsLocked": "Bu rapor şu anda kilitli ve \"kilidini açmayı\" onaylamanız gerekiyor. Rapor yapılandırmasını değiştirip güncelledikten sonra, yeni rapor verilerinin üretilmesi gerekir ve daha önce üretilmiş raporlar artık kullanılamaz.", + "InfoReportIsLockedBrowserArchivingDisabled": "Bu rapor şu anda kilitli ve \"kilidini açmayı\" onaylamanız gerekiyor. Rapor yapılandırmasını değiştirip güncelledikten sonra, son %1$s ayın yeni rapor verileri yeniden üretilecek ve daha önce üretilmiş raporlar artık kullanılamayacak.", + "ManageReports": "Rapor yönetimi", + "ManageReportsSubcategoryHelp": "Özel raporlar, standart raporların sunmadığı yeni bakış açılarının elde edilmesini sağlar. Site için özel raporlar bu bölümden eklenip yönetilebilir.", + "MatchingSearchConfirmTitle": "Eşleşen %s ölçülebiliri eklemek istediğinize emin misiniz?", + "MatchingSearchConfirmTitleAlreadyAdded": "%s eşleşen ölçülebilir zaten eklenmiş", + "MatchingSearchMatchedAdd": "Bu %1$s ölçülebilir %2$s ile eşleşti ve bu özel rapora eklenecek", + "MatchingSearchMatchedAlreadyAdded": "Bu %1$s ölçülebilir %2$s ile eşleşti ancak zaten bu özel rapora eklenmiş", + "MatchingSearchNotFound": "%s ifadesine uyan bir ölçülebilir yok.", + "NCustomReports": "%s özel rapor", + "NoCustomReportsFound": "Herhangi bir özel rapor bulunamadı", + "NoDataMessagePausedStateAdminUser": "Bu rapor duraklatılmış. Rapor sürdürülene kadar yeni veriler bulunmayacak. Raporu sürdürmek için, %1$sRapor yönetimi%2$s bölümüne gidin ve seçilmiş rapor için Oynat üzerine tıklayın.", + "NoDataMessagePausedStateNonAdminUser": "Bu rapor duraklatılmış. Rapor sürdürülene kadar yeni veriler bulunmayacak. Rapor için izinlere gerek duyuyorsanız, lütfen Matomo süper kullanıcınızla görüşün.", + "NoDataNotArchivedYet": "Rapor yakın zamanda eklenmiş ya da güncellenmiş ise verilerin günlük raporlar bölümünde görülebilmesi için bir kaç saat geçmesi gerekebilir. Raporun eklenme tarihinden önceki tarihlere ait veriler kullanılamaz.", + "NoDataNotArchivedYetReprocess": "Bununla birlikte bir sistem yöneticisi Matomo üzerinde %1$sraporları yeniden işleyerek%2$s bu verilerin kullanılabilmesini sağlayabilir.", + "NoDataRemovedMetrics": "Bu rapor, yalnızca seçilmiş aralık (%s) tarafından desteklenmeyen ölçümleri içerdiğinden herhangi bir veri görüntülenemiyor.", + "NoMeasurableAssignedYet": "Henüz bir ölçülebilir atanmamış, lütfen yukarıdaki seçiciden bir ölçülebilir seçin.", + "NoSegmentationJoinExceptionMessage": "Geçerli boyut sıralaması veya kombinasyonu uyumlu değil. %1$sAyrıntılı bilgi alın%2$s.", + "OrderSubCategoryReports": "Alt kategori raporları sıralaması", + "OrderSubCategoryReportsDescription": "İsteğe bağlı: Bu özel rapor görüntülenirken alt kategorilerin görüntülenme sıralamasını ayarlayın.", + "PauseExceptionMessage": "Rapor duraklatılamadı. Site eşleşmiyor", + "PauseReportConfirm": "Bu raporu duraklatmak, raporun arşivlenmesini siz yeniden raporu başlatana kadar durdurur. Bu raporu duraklatmak istediğinize emin misiniz?", + "PauseReportInfo": "Bu raporu duraklatmak, siz yeniden başlatana kadar raporun arşivlenmesini durdurur.", + "PausedReport": "Rapor duraklatıldı.", + "Preview": "Ön izleme", + "PreviewDate": "Bu ön izleme %s verilerine göre hazırlanmıştır.", + "PreviewReport": "Rapor ön izlemesi", + "PreviewReportExceedsMaximumTimeFrameValueAllowed": "Rapor ön izlemesi için en fazla zaman aralığı 24 saatten büyük olamaz.", + "PreviewReportInvalidTimeFrameValues": "Rapor ön izlemesi için ayarlanmış zaman aralığı değerleri geçersiz. Saniye, dakika, saat ve gün değerlerine izin verilir.", + "PreviewSupportsDimension": "Ön izlemede en çok %s boyut görüntülenebilir.", + "ProductsWithX": "%s ile ürünler", + "QuotaReachedForX": "Kota sınırınıza ulaştınız. Kullanım aşımı nedeniyle %1$s kopyalanamıyor. Kotanızı yönetmek için kullanılmayan veya daha az önemli %2$s ögesini silmeyi değerlendirin.", + "RemoveDimension": "Boyutu kaldır", + "RemoveMetric": "Ölçümü kaldır", + "RemovedMetrics": "%s aralığı için doğru hesaplanamadığından bu ölçümler çıkarıldı.", + "ReportAllWebsitesHelp": "Bu raporun hangi siteler için kullanılacağını belirtin. Bu raporun tüm sitelerde kullanılabilmesi için \"Tüm siteler\" olarak da seçebilirsiniz (gelecekte eklenecek siteler dahil).", + "ReportAllWebsitesNonSuperUserHelp": "Bu raporun kullanılabileceği siteyi seçin.", + "ReportAvailableToAllWebsites": "Bu rapor tüm siteler için kullanılabilir.", + "ReportAvailableToMultipleWebsites": "Bu rapor, birden çok site için kullanılabilir.", + "ReportCategory": "Rapor kategorisi", + "ReportCategoryHelp": "Bu raporun, rapor menüsünde hangi ana kategori altında bulunacağını belirtin.", + "ReportContent": "Rapor içeriği", + "ReportCreated": "Rapor eklendi.", + "ReportDescriptionHelp": "Rapor başlığının yanındaki yardım simgesine tıklandığında görüntülenecek isteğe bağlı açıklama. Raporun amacını açıklayan bilgiler yazılabilir.", + "ReportDimensionsHelpExtended": "Ek boyutlara gerek duyuyorsanız, sınırı artırabilirsiniz. %1$sAyrıntılı bilgi alın%2$s.", + "ReportDimensionsHelpNew": "Bir boyut genellikle özellikleri tanımlar ve bir raporun ilk sütununda görüntülenir. Birden fazla boyut seçebilirsiniz (Tarayıcı, Ülke, İl gibi). En az bir boyut seçilmelidir.", + "ReportEditNotAllowedAllWebsitesUpdated": "Bu rapor tüm siteler için kullanılabileceğinden, yalnızca süper kullanıcı izinlerine sahip bir kullanıcı bu raporu düzenleyebilir.", + "ReportEditNotAllowedMultipleWebsitesAccessIssue": "Bu rapor birden çok site için kullanılabileceğinden, yalnızca tüm sitelere erişimi olan bir süper kullanıcı veya yönetici onu değiştirebilir.", + "ReportHasFilterApplied": "Bu rapora şu süzgeç uygulandı: %s.", + "ReportInPausedStateAdmin": "Bu rapor duraklatılmış. Rapor sürdürülene kadar arşivleme yapılmayacak. Raporu sürdürmek için, %1$sRapor yönetimi%2$s bölümüne gidin ve seçilmiş rapor için Oynat üzerine tıklayın.", + "ReportMetricsHelp": "Ölçümler, işlemlerin sayısal değerlerini belirtir. Örneğin sitenin trafiğini arttırmak istiyorsanız, \"Ziyaretler\" ölçümünü, sitesinin hızını arttırmak istiyorsanız \"Ortalama üretilme süresi\" ölçümünü eklemelisiniz.", + "ReportName": "Rapor adı", + "ReportNameHelp": "Bu raporun hangi ad altında bulunacağını belirler.", + "ReportPage": "Rapor sayfası", + "ReportSegmentHelp": "Süzgeçler bir raporun alt gruplarının oluşturulmasını sağlar. Örneğin bu raporu yalnızca mobil aygıtları görüntüleyecek şekilde süzebilirsiniz.", + "ReportSubcategory": "Rapor alt kategorisi", + "ReportSubcategoryHelp": "Bu raporu istediğiniz bir rapor sayfasına ekleyebilirsiniz. Bir alt kategori seçmezseniz seçilmiş kategori altına rapor adı ile yeni bir rapor sayfası eklenir.", + "ReportType": "Rapor türü", + "ReportUpdated": "Rapor güncellendi.", + "ResumeExceptionMessage": "Rapor sürdürülemedi. Site eşleşmiyor", + "ResumeReportConfirm": "Bu raporu sürdürdüğünüzde, arşivleme bugünden itibaren yeniden başlar. Bu özel raporu sürdürmek istediğinize emin misiniz?", + "ResumeReportInfo": "Bu raporu sürdürdüğünüzde, arşivleme bugünden itibaren yeniden başlar.", + "ResumedReport": "Rapor sürdürüldü.", + "SelectMeasurablesMatchingSearch": "ya da şu ifadeyi içeren tüm ölçülebilirleri ekleyin", + "SourceCustomReportLookupError": "Kaynak özel raporuna bakılırken beklenmeyen bir sorun çıktı. Lütfen yeniden deneyin. Sorun sürerse yöneticinizle veya destek ekibinizle görüşün.", + "Type": "Tür", + "Unlock": "Kilidi aç", + "UpdatingData": "Veriler güncelleniyor…", + "ViewReportInfo": "Raporu görüntüle", + "WarningOnUpdateReportMightGetLost": "Bu özel raporu güncellediğinizde, bu özel rapor için daha önce üretilmiş raporlar kullanılamayacak.", + "WarningOnUpdateReportMightGetLostBrowserArchivingDisabled": "Bu özel raporu güncellediğinizde, daha önce üretilmiş raporlar kullanılamayacak ve son %1$s ayın raporları yeniden üretilecek.", + "WarningProductRevenueMetricDependency": "Ürün geliri ölçümleri için aynı raporda eşlik edecek bir ürün adeti ölçümü gereklidir. Eşlik edecek ölçüm otomatik olarak eklendi. %1$sAyrıntılı bilgi alın%2$s.", + "WarningRequiresUnlock": "Bu rapor içeriğinde değişiklik yapmak için \"Kilidini açmalısınız\". Rapor yapılandırmasını değiştirip güncelledikten sonra, yeni rapor verilerinin üretilmesi gerekir ve daha önce üretilmiş raporlar artık kullanılamaz.", + "WarningRequiresUnlockBrowserArchivingDisabled": "Bu rapor içeriğinde değişiklik yapmak için \"Kilidini açmalısınız\". Rapor yapılandırmasını değiştirip güncelledikten sonra, son %1$s ayın yeni rapor verileri yeniden üretilecek ve daha önce üretilmiş raporlar artık kullanılamayacak.", + "WebsiteName": "Site adı" + } +} diff --git a/files/plugin-CustomReports-5.5.0/lang/tzm.json b/files/plugin-CustomReports-5.5.0/lang/tzm.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/tzm.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/uk.json b/files/plugin-CustomReports-5.5.0/lang/uk.json new file mode 100644 index 0000000..5b3fb0a --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/uk.json @@ -0,0 +1,5 @@ +{ + "CustomReports": { + "CustomReports": "Спеціальні звіти" + } +} diff --git a/files/plugin-CustomReports-5.5.0/lang/ur.json b/files/plugin-CustomReports-5.5.0/lang/ur.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/ur.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/vi.json b/files/plugin-CustomReports-5.5.0/lang/vi.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/vi.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/zh-cn.json b/files/plugin-CustomReports-5.5.0/lang/zh-cn.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/zh-cn.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-CustomReports-5.5.0/lang/zh-tw.json b/files/plugin-CustomReports-5.5.0/lang/zh-tw.json new file mode 100644 index 0000000..7f8e2d7 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/lang/zh-tw.json @@ -0,0 +1,12 @@ +{ + "CustomReports": { + "Category": "分類", + "ErrorNotAnArray": "「%1$s」必須是陣列。", + "ErrorXNotProvided": "請輸入「%1$s」的值。", + "ErrorXNotWhitelisted": "「%1$s」的值不允許,使用以下其一:%2$s。", + "ErrorXTooLong": "「%1$s」過長,最多允許 %2$s 個字元。", + "Filter": "過濾", + "UpdatingData": "數據更新中…", + "ViewReportInfo": "查看報表" + } +} diff --git a/files/plugin-CustomReports-5.5.0/phpcs.xml b/files/plugin-CustomReports-5.5.0/phpcs.xml new file mode 100644 index 0000000..7208e4b --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/phpcs.xml @@ -0,0 +1,36 @@ + + + + Matomo Coding Standard for CustomReports plugin + + + + . + + tests/javascript/* + */vendor/* + + + + + + + + tests/* + + + + + Updates/* + + + + + tests/* + + + + + tests/* + + \ No newline at end of file diff --git a/files/plugin-CustomReports-5.5.0/phpstan.neon b/files/plugin-CustomReports-5.5.0/phpstan.neon new file mode 100644 index 0000000..8ec3d36 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/phpstan.neon @@ -0,0 +1,19 @@ +parameters: + level: 0 + phpVersion: 70200 + tmpDir: /tmp/phpstan/CustomReports/main + paths: + - . + excludePaths: + - tests/* + bootstrapFiles: + - ../../bootstrap-phpstan.php + universalObjectCratesClasses: + - Piwik\Config + - Piwik\View + - Piwik\ViewDataTable\Config + scanDirectories: + # ../../ does not actually seem to give us anything + # that ../plugins/ does not, but including it for + # completeness. It does not seem to slow down performance. + - ../../ diff --git a/files/plugin-CustomReports-5.5.0/phpstan/phpstan.created.neon b/files/plugin-CustomReports-5.5.0/phpstan/phpstan.created.neon new file mode 100644 index 0000000..084eb8d --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/phpstan/phpstan.created.neon @@ -0,0 +1,5 @@ +includes: + - ../phpstan.neon +parameters: + level: 9 + tmpDir: /tmp/phpstan/CustomReports/created diff --git a/files/plugin-CustomReports-5.5.0/phpstan/phpstan.modified.neon b/files/plugin-CustomReports-5.5.0/phpstan/phpstan.modified.neon new file mode 100644 index 0000000..ac4783d --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/phpstan/phpstan.modified.neon @@ -0,0 +1,5 @@ +includes: + - ../phpstan.neon +parameters: + level: 1 + tmpDir: /tmp/phpstan/CustomReports/modified diff --git a/files/plugin-CustomReports-5.5.0/plugin.json b/files/plugin-CustomReports-5.5.0/plugin.json new file mode 100644 index 0000000..3ad38a2 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/plugin.json @@ -0,0 +1,31 @@ +{ + "name": "CustomReports", + "description": "Standard reports answer common analytics questions. Custom Reports answers your questions. Build custom reporting views tailored to your goals, KPIs, and business requirements.", + "version": "5.5.0", + "theme": false, + "require": { + "matomo": ">=5.0.0-rc5,<6.0.0-b1" + }, + "authors": [ + { + "name": "InnoCraft", + "email": "contact@innocraft.com", + "homepage": "https:\/\/www.innocraft.com" + } + ], + "price": { + "base": 220 + }, + "preview": { + "video_url": "https://www.youtube-nocookie.com/embed/Ss0jtGLaeDM", + "demo_url": "https://demo.matomo.cloud/index.php?module=CoreHome&action=index&idSite=1&period=day&date=yesterday#?idSite=1&period=day&date=yesterday&segment=&category=CustomReports_CustomReports&subcategory=2" + }, + "homepage": "https:\/\/www.innocraft.com", + "license": "InnoCraft EULA", + "keywords": [ + "custom", + "reports", + "filter", + "segment" + ] +} diff --git a/files/plugin-CustomReports-5.5.0/pull_request_template.md b/files/plugin-CustomReports-5.5.0/pull_request_template.md new file mode 100644 index 0000000..211089b --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/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/files/plugin-CustomReports-5.5.0/templates/manage.twig b/files/plugin-CustomReports-5.5.0/templates/manage.twig new file mode 100644 index 0000000..0c10e1f --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/templates/manage.twig @@ -0,0 +1,21 @@ +{% extends 'admin.twig' %} + +{% block topcontrols %} +
+
+
+{% endblock %} + +{% block content %} +
+{% endblock %} \ No newline at end of file diff --git a/files/plugin-CustomReports-5.5.0/vue/dist/CustomReports.umd.js b/files/plugin-CustomReports-5.5.0/vue/dist/CustomReports.umd.js new file mode 100644 index 0000000..e802d8e --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/vue/dist/CustomReports.umd.js @@ -0,0 +1,2731 @@ +(function webpackUniversalModuleDefinition(root, factory) { + if(typeof exports === 'object' && typeof module === 'object') + module.exports = factory(require("CoreHome"), require("vue"), require("CorePluginsAdmin"), require("SegmentEditor")); + else if(typeof define === 'function' && define.amd) + define(["CoreHome", , "CorePluginsAdmin", "SegmentEditor"], factory); + else if(typeof exports === 'object') + exports["CustomReports"] = factory(require("CoreHome"), require("vue"), require("CorePluginsAdmin"), require("SegmentEditor")); + else + root["CustomReports"] = factory(root["CoreHome"], root["Vue"], root["CorePluginsAdmin"], root["SegmentEditor"]); +})((typeof self !== 'undefined' ? self : this), function(__WEBPACK_EXTERNAL_MODULE__19dc__, __WEBPACK_EXTERNAL_MODULE__8bbf__, __WEBPACK_EXTERNAL_MODULE_a5a2__, __WEBPACK_EXTERNAL_MODULE_f06f__) { +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/CustomReports/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__; + +/***/ }), + +/***/ "f06f": +/***/ (function(module, exports) { + +module.exports = __WEBPACK_EXTERNAL_MODULE_f06f__; + +/***/ }), + +/***/ "fae3": +/***/ (function(module, __webpack_exports__, __webpack_require__) { + +"use strict"; +// ESM COMPAT FLAG +__webpack_require__.r(__webpack_exports__); + +// EXPORTS +__webpack_require__.d(__webpack_exports__, "KpiCardView", function() { return /* reexport */ KpiCardView; }); +__webpack_require__.d(__webpack_exports__, "ReportEdit", function() { return /* reexport */ Edit; }); +__webpack_require__.d(__webpack_exports__, "ReportsList", function() { return /* reexport */ List; }); +__webpack_require__.d(__webpack_exports__, "ReportsManage", function() { return /* reexport */ Manage; }); + +// 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/CustomReports/vue/src/KpiCardView/KpiCardView.vue?vue&type=template&id=753e96c3 + +const _hoisted_1 = { + class: "metric-sparkline" +}; +const _hoisted_2 = { + class: "metric-value" +}; +const _hoisted_3 = ["title"]; +const _hoisted_4 = ["title"]; +function render(_ctx, _cache, $props, $setup, $data, $options) { + const _component_Sparkline = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("Sparkline"); + 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"])(["singleMetricView customReportKpiCard", { + 'loading': _ctx.isLoading + }]) + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_1, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_Sparkline, { + params: _ctx.sparklineParams + }, null, 8, ["params"])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_2, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { + title: _ctx.metricDocumentation + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("strong", null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.metricValue), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createTextVNode"])(" " + Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])((_ctx.metricTranslation || '').toLowerCase()), 1)], 8, _hoisted_3), _ctx.pastValue !== null ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", { + key: 0, + class: "metricEvolution", + title: _ctx.translate('General_EvolutionSummaryGeneric', _ctx.metricValue, _ctx.currentPeriod, _ctx.pastValue, _ctx.pastPeriod, _ctx.metricChangePercent) + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(_ctx.evolutionClass) + }, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.metricChangePercent), 3)], 8, _hoisted_4)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)])], 2); +} +// CONCATENATED MODULE: ./plugins/CustomReports/vue/src/KpiCardView/KpiCardView.vue?vue&type=template&id=753e96c3 + +// 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/CustomReports/vue/src/KpiCardView/KpiCardView.vue?vue&type=script&lang=ts + + +function getPastPeriodStr() { + const { + startDate + } = external_CoreHome_["Range"].getLastNRange(external_CoreHome_["Matomo"].period, 2, external_CoreHome_["Matomo"].currentDateString); + const dateRange = external_CoreHome_["Periods"].get(external_CoreHome_["Matomo"].period).parse(startDate).getDateRange(); + return `${Object(external_CoreHome_["format"])(dateRange[0])},${Object(external_CoreHome_["format"])(dateRange[1])}`; +} +/* harmony default export */ var KpiCardViewvue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + props: { + idCustomReport: { + type: [String, Number], + required: true + }, + metric: { + type: String, + required: true + }, + metricTranslation: String, + metricDocumentation: String, + lowerIsBetter: Boolean + }, + components: { + Sparkline: external_CoreHome_["Sparkline"] + }, + setup(props) { + const isLoading = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])(false); + const responses = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])(null); + function getMetricFromResponse(response) { + if (!response) { + return null; + } + const row = Array.isArray(response) ? response[0] : response; + if (!row) { + // a metric that has no data for the requested period is treated as 0 so an + // evolution down to zero is still calculated + return 0; + } + const value = row[props.metric] !== undefined ? row[props.metric] : row.value; + return value || 0; + } + const metricValueUnformatted = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + if (!responses.value) { + return null; + } + return getMetricFromResponse(responses.value[1]); + }); + const pastValueUnformatted = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + if (!responses.value) { + return null; + } + return getMetricFromResponse(responses.value[2]); + }); + const evolutionClass = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + if (metricValueUnformatted.value === null || pastValueUnformatted.value === null || metricValueUnformatted.value === pastValueUnformatted.value) { + return []; + } + // arrow direction always reflects the actual value change, while the colour + // (positive/negative) reflects whether that change is good or bad for the metric + const increased = metricValueUnformatted.value > pastValueUnformatted.value; + const isPositive = props.lowerIsBetter ? !increased : increased; + return [increased ? 'evolution-up' : 'evolution-down', isPositive ? 'positive-evolution' : 'negative-evolution']; + }); + const metricChangePercent = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + if (metricValueUnformatted.value === null || metricValueUnformatted.value === undefined || pastValueUnformatted.value === null || pastValueUnformatted.value === undefined) { + return null; + } + const currentValue = typeof metricValueUnformatted.value === 'string' ? parseFloat(metricValueUnformatted.value) : metricValueUnformatted.value; + const pastValue = typeof pastValueUnformatted.value === 'string' ? parseFloat(pastValueUnformatted.value) : pastValueUnformatted.value; + const evolution = external_CoreHome_["Matomo"].helper.calculateEvolution(currentValue, pastValue); + return `${(evolution * 100).toFixed(2)} %`; + }); + const pastValue = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + if (!responses.value) { + return null; + } + return getMetricFromResponse(responses.value[3]); + }); + const metricValue = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + if (!responses.value) { + return null; + } + return getMetricFromResponse(responses.value[0]); + }); + const currentPeriod = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + if (external_CoreHome_["Matomo"].startDateString === external_CoreHome_["Matomo"].endDateString) { + return external_CoreHome_["Matomo"].endDateString; + } + return `${external_CoreHome_["Matomo"].startDateString}, ${external_CoreHome_["Matomo"].endDateString}`; + }); + const sparklineParams = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => ({ + module: 'CustomReports', + action: 'getCustomReport', + idCustomReport: props.idCustomReport, + columns: props.metric + })); + const pastPeriod = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + if (external_CoreHome_["Matomo"].period === 'range') { + return undefined; + } + return getPastPeriodStr(); + }); + function getLastPeriodDate() { + const range = external_CoreHome_["Range"].getLastNRange(external_CoreHome_["Matomo"].period, 2, external_CoreHome_["Matomo"].currentDateString); + return Object(external_CoreHome_["format"])(range.startDate); + } + function fetchData() { + isLoading.value = true; + const method = 'CustomReports.getCustomReport'; + const extraParams = { + idCustomReport: props.idCustomReport, + columns: props.metric + }; + const promises = []; + // first request for formatted data + promises.push(external_CoreHome_["AjaxHelper"].fetch(Object.assign({ + method, + format_metrics: 'all' + }, extraParams))); + if (external_CoreHome_["Matomo"].period !== 'range') { + // second request for unformatted data so we can calculate evolution + promises.push(external_CoreHome_["AjaxHelper"].fetch(Object.assign({ + method, + format_metrics: '0' + }, extraParams))); + // third request for past data (unformatted) + promises.push(external_CoreHome_["AjaxHelper"].fetch(Object.assign({ + method, + date: getLastPeriodDate(), + format_metrics: '0' + }, extraParams))); + // fourth request for past data (formatted for tooltip display) + promises.push(external_CoreHome_["AjaxHelper"].fetch(Object.assign({ + method, + date: getLastPeriodDate(), + format_metrics: 'all' + }, extraParams))); + } + return Promise.all(promises).then(r => { + responses.value = r; + isLoading.value = false; + }); + } + fetchData(); + return { + isLoading, + metricValue, + metricValueUnformatted, + pastValueUnformatted, + evolutionClass, + metricChangePercent, + pastValue, + sparklineParams, + pastPeriod, + currentPeriod + }; + } +})); +// CONCATENATED MODULE: ./plugins/CustomReports/vue/src/KpiCardView/KpiCardView.vue?vue&type=script&lang=ts + +// CONCATENATED MODULE: ./plugins/CustomReports/vue/src/KpiCardView/KpiCardView.vue + + + +KpiCardViewvue_type_script_lang_ts.render = render + +/* harmony default export */ var KpiCardView = (KpiCardViewvue_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/CustomReports/vue/src/Reports/Edit.vue?vue&type=template&id=df804736 + +const Editvue_type_template_id_df804736_hoisted_1 = { + class: "loadingPiwik" +}; +const Editvue_type_template_id_df804736_hoisted_2 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("img", { + src: "plugins/Morpheus/images/loading-blue.gif" +}, null, -1); +const Editvue_type_template_id_df804736_hoisted_3 = { + class: "loadingPiwik" +}; +const Editvue_type_template_id_df804736_hoisted_4 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("img", { + src: "plugins/Morpheus/images/loading-blue.gif" +}, null, -1); +const _hoisted_5 = { + class: "alert alert-warning" +}; +const _hoisted_6 = { + key: 0 +}; +const _hoisted_7 = { + key: 1 +}; +const _hoisted_8 = { + key: 0, + class: "alert alert-warning" +}; +const _hoisted_9 = ["innerHTML"]; +const _hoisted_10 = { + key: 1 +}; +const _hoisted_11 = { + name: "name" +}; +const _hoisted_12 = { + name: "description" +}; +const _hoisted_13 = { + class: "form-group row" +}; +const _hoisted_14 = { + class: "col s12" +}; +const _hoisted_15 = { + class: "col s12 m6" +}; +const _hoisted_16 = { + for: "all_websites", + class: "siteSelectorLabel" +}; +const _hoisted_17 = { + class: "sites_autocomplete" +}; +const _hoisted_18 = { + class: "col s12 m6" +}; +const _hoisted_19 = { + class: "form-help" +}; +const _hoisted_20 = { + key: 0, + class: "inline-help" +}; +const _hoisted_21 = { + key: 1, + class: "inline-help" +}; +const _hoisted_22 = { + key: 0, + class: "col s12 m6" +}; +const _hoisted_23 = { + key: 0 +}; +const _hoisted_24 = { + for: "websitecontains" +}; +const _hoisted_25 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("br", null, null, -1); +const _hoisted_26 = ["placeholder"]; +const _hoisted_27 = ["disabled", "value"]; +const _hoisted_28 = { + key: 1 +}; +const _hoisted_29 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("br", null, null, -1); +const _hoisted_30 = { + class: "entityTable" +}; +const _hoisted_31 = { + class: "siteId" +}; +const _hoisted_32 = { + class: "siteName" +}; +const _hoisted_33 = { + key: 0, + class: "siteAction" +}; +const _hoisted_34 = { + colspan: "3" +}; +const _hoisted_35 = { + key: 0, + class: "siteAction" +}; +const _hoisted_36 = ["onClick"]; +const _hoisted_37 = { + class: "form-group row" +}; +const _hoisted_38 = { + class: "col s12" +}; +const _hoisted_39 = { + class: "unlockAlert alert alert-info" +}; +const _hoisted_40 = { + key: 0 +}; +const _hoisted_41 = { + key: 1 +}; +const _hoisted_42 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("br", null, null, -1); +const _hoisted_43 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("br", null, null, -1); +const _hoisted_44 = ["value"]; +const _hoisted_45 = { + class: "alertUnlocked alert alert-warning" +}; +const _hoisted_46 = { + key: 0 +}; +const _hoisted_47 = { + key: 1 +}; +const _hoisted_48 = { + name: "reportType" +}; +const _hoisted_49 = { + class: "form-group row" +}; +const _hoisted_50 = { + class: "col s12 m6 dimensionsGroup" +}; +const _hoisted_51 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("br", null, null, -1); +const _hoisted_52 = { + class: "groupValueSelect", + name: "dimensions" +}; +const _hoisted_53 = ["title", "onClick"]; +const _hoisted_54 = { + class: "groupValueSelect addDimension", + name: "dimensions" +}; +const _hoisted_55 = { + class: "col s12 m6" +}; +const _hoisted_56 = { + class: "form-help" +}; +const _hoisted_57 = ["innerHTML"]; +const _hoisted_58 = { + class: "form-group row" +}; +const _hoisted_59 = { + class: "col s12 m6 metricsGroup" +}; +const _hoisted_60 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("br", null, null, -1); +const _hoisted_61 = { + class: "groupValueSelect", + name: "metrics" +}; +const _hoisted_62 = ["title", "onClick"]; +const _hoisted_63 = { + class: "groupValueSelect addMetric", + name: "metrics" +}; +const _hoisted_64 = { + class: "col s12 m6" +}; +const _hoisted_65 = { + class: "form-help" +}; +const _hoisted_66 = { + class: "inline-help" +}; +const _hoisted_67 = ["innerHTML"]; +const _hoisted_68 = { + class: "form-group row segmentFilterGroup" +}; +const _hoisted_69 = { + class: "col s12" +}; +const _hoisted_70 = { + style: { + "margin": "8px 0", + "display": "inline-block" + } +}; +const _hoisted_71 = { + class: "form-group row" +}; +const _hoisted_72 = { + class: "col s12" +}; +const _hoisted_73 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("br", null, null, -1); +const _hoisted_74 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("br", null, null, -1); +const _hoisted_75 = { + name: "reportCategories" +}; +const _hoisted_76 = { + name: "reportSubcategories" +}; +const _hoisted_77 = { + class: "alert alert-warning" +}; +const _hoisted_78 = { + key: 0 +}; +const _hoisted_79 = { + key: 1 +}; +const _hoisted_80 = { + class: "alert alert-warning" +}; +const _hoisted_81 = { + key: 0 +}; +const _hoisted_82 = { + key: 1 +}; +const _hoisted_83 = { + key: 0, + class: "form-group row" +}; +const _hoisted_84 = ["textContent"]; +const _hoisted_85 = { + class: "col s12 m6" +}; +const _hoisted_86 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { + class: "icon-menu-hamburger drag-icon" +}, null, -1); +const _hoisted_87 = { + class: "col s12 m6" +}; +const _hoisted_88 = { + class: "form-help" +}; +const _hoisted_89 = ["textContent"]; +const _hoisted_90 = { + class: "entityCancel" +}; +const _hoisted_91 = { + class: "ui-confirm", + id: "confirmUnlockReport", + ref: "confirmUnlockReport" +}; +const _hoisted_92 = { + key: 0 +}; +const _hoisted_93 = { + key: 1 +}; +const _hoisted_94 = ["value"]; +const _hoisted_95 = ["value"]; +const _hoisted_96 = { + class: "ui-confirm", + id: "infoReportIsLocked", + ref: "infoReportIsLocked" +}; +const _hoisted_97 = { + key: 0 +}; +const _hoisted_98 = { + key: 1 +}; +const _hoisted_99 = ["value"]; +const _hoisted_100 = ["value"]; +function Editvue_type_template_id_df804736_render(_ctx, _cache, $props, $setup, $data, $options) { + const _component_Field = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("Field"); + const _component_SiteSelector = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("SiteSelector"); + const _component_SegmentGenerator = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("SegmentGenerator"); + const _component_SaveButton = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("SaveButton"); + const _component_DraggableList = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("DraggableList"); + 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, { + class: "editReport", + "content-title": _ctx.contentTitle + }, { + default: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withCtx"])(() => { + var _ctx$report$metrics, _ctx$report$dimension, _ctx$report$subcatego; + return [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("p", null, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", Editvue_type_template_id_df804736_hoisted_1, [Editvue_type_template_id_df804736_hoisted_2, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createTextVNode"])(" " + Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('General_LoadingData')), 1)])], 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"])("p", null, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", Editvue_type_template_id_df804736_hoisted_3, [Editvue_type_template_id_df804736_hoisted_4, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createTextVNode"])(" " + Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_UpdatingData')), 1)])], 512), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], _ctx.isUpdating]]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_5, [_ctx.multipleSites.length && !_ctx.report.allowedToEdit ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", _hoisted_6, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_ReportEditNotAllowedMultipleWebsitesAccessIssue')), 1)) : (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", _hoisted_7, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_ReportEditNotAllowedAllWebsitesUpdated')), 1))], 512), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], !_ctx.canEdit]]), _ctx.report.status === 'paused' ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", _hoisted_8, [_ctx.report.allowedToEdit ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", { + key: 0, + innerHTML: _ctx.getPausedStateAdminMessage + }, null, 8, _hoisted_9)) : (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", _hoisted_10, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_NoDataMessagePausedStateNonAdminUser')), 1))])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("form", { + onSubmit: _cache[15] || (_cache[15] = $event => _ctx.edit ? _ctx.updateReport() : _ctx.createReport()) + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", null, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_11, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_Field, { + uicontrol: "text", + name: "name", + "model-value": _ctx.report.name, + "onUpdate:modelValue": _cache[0] || (_cache[0] = $event => { + _ctx.report.name = $event; + _ctx.setValueHasChanged(); + }), + title: _ctx.translate('General_Name'), + maxlength: 50, + disabled: !_ctx.canEdit, + placeholder: _ctx.translate('CustomReports_FieldNamePlaceholder'), + "inline-help": _ctx.translate('CustomReports_ReportNameHelpText') + }, null, 8, ["model-value", "title", "disabled", "placeholder", "inline-help"])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_12, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_Field, { + uicontrol: "textarea", + name: "description", + "model-value": _ctx.report.description, + "onUpdate:modelValue": _cache[1] || (_cache[1] = $event => { + _ctx.report.description = $event; + _ctx.setValueHasChanged(); + }), + title: `${_ctx.translate('General_Description')} (optional)`, + maxlength: 1000, + disabled: !_ctx.canEdit, + "ui-control-attributes": { + class: 'compact-textarea' + }, + placeholder: _ctx.translate('CustomReports_FieldDescriptionPlaceholder'), + "inline-help": _ctx.getDescriptionHelpText + }, null, 8, ["model-value", "title", "disabled", "placeholder", "inline-help"])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_13, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("h3", _hoisted_14, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_ApplyTo')), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_15, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", null, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("label", _hoisted_16, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('General_Website')), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_17, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_SiteSelector, { + id: "all_websites", + "model-value": _ctx.report.site, + "onUpdate:modelValue": _cache[2] || (_cache[2] = $event => { + _ctx.report.site = $event; + _ctx.setWebsiteChanged($event); + }), + "show-all-sites-item": _ctx.isSuperUser, + "switch-site-on-select": false, + "show-selected-site": true + }, null, 8, ["model-value", "show-all-sites-item"])])])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_18, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_19, [_ctx.isSuperUser ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", _hoisted_20, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_ReportAllWebsitesHelp')), 1)) : (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", _hoisted_21, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_ReportAllWebsitesNonSuperUserHelp')), 1))])]), _ctx.report.site.id !== 'all' && _ctx.report.site.id !== '0' && _ctx.report.site.id !== 0 ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", _hoisted_22, [_ctx.report.allowedToEdit ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", _hoisted_23, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", _hoisted_24, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_SelectMeasurablesMatchingSearch')), 1), _hoisted_25, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("input", { + class: "control_text customReportSearchMeasurablesField", + type: "text", + id: "websitecontains", + "onUpdate:modelValue": _cache[3] || (_cache[3] = $event => _ctx.containsText = $event), + placeholder: _ctx.translate('General_Search') + }, null, 8, _hoisted_26), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vModelText"], _ctx.containsText]]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("input", { + style: { + "margin-left": "3.5px" + }, + disabled: !_ctx.containsText, + class: "btn customReportSearchFindMeasurables", + type: "button", + onClick: _cache[4] || (_cache[4] = $event => _ctx.addSitesContaining(_ctx.containsText)), + value: _ctx.translate('CustomReports_FindMeasurables') + }, null, 8, _hoisted_27)])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), _ctx.report.allowedToEdit || _ctx.multipleSites.length ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", _hoisted_28, [_hoisted_29, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("table", _hoisted_30, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("thead", null, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("tr", null, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("th", _hoisted_31, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('General_Id')), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("th", _hoisted_32, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('General_Name')), 1), _ctx.report.allowedToEdit ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("th", _hoisted_33, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('General_Remove')), 1)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("tbody", null, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("tr", null, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("td", _hoisted_34, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_NoMeasurableAssignedYet')), 1)], 512), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], !_ctx.multipleSites.length]]), (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.multipleSites, (site, index) => { + 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"])("tr", { + key: index, + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])({ + selectedSiteRow: _ctx.isSelectedSiteRow(site) + }) + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("td", null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(site.idsite), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("td", null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(site.name), 1), _ctx.report.allowedToEdit ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("td", _hoisted_35, [_ctx.canRemoveSite(site) ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", { + key: 0, + class: "icon-minus table-action", + onClick: $event => _ctx.removeSite(site) + }, null, 8, _hoisted_36)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)], 2)), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], _ctx.multipleSites.length > 0]]); + }), 128))])])])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_37, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("h3", _hoisted_38, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_ReportContent')), 1)]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_39, [_ctx.browserArchivingDisabled && _ctx.reArchiveLastN ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", _hoisted_40, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_WarningRequiresUnlockBrowserArchivingDisabled', _ctx.reArchiveLastN)), 1)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), !_ctx.browserArchivingDisabled || !_ctx.reArchiveLastN ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", _hoisted_41, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_WarningRequiresUnlock')), 1)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), _hoisted_42, _hoisted_43, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("input", { + type: "button", + class: "btn unlockReport", + onClick: _cache[5] || (_cache[5] = $event => _ctx.unlockReport()), + value: _ctx.translate('CustomReports_Unlock') + }, null, 8, _hoisted_44)], 512), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], _ctx.isLocked]]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_45, [_ctx.browserArchivingDisabled && _ctx.reArchiveLastN ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", _hoisted_46, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_WarningOnUpdateReportMightGetLostBrowserArchivingDisabled', _ctx.reArchiveLastN)), 1)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), !_ctx.browserArchivingDisabled || !_ctx.reArchiveLastN ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", _hoisted_47, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_WarningOnUpdateReportMightGetLost')), 1)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)], 512), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], _ctx.isUnlocked]]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_48, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_Field, { + uicontrol: "radio", + name: "reportType", + "model-value": _ctx.report.report_type, + "onUpdate:modelValue": _cache[6] || (_cache[6] = $event => _ctx.setReportTypeHasChanged($event)), + title: _ctx.translate('CustomReports_ReportType'), + disabled: !_ctx.canEdit, + options: _ctx.reportTypes + }, null, 8, ["model-value", "title", "disabled", "options"])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_49, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_50, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("label", null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_Dimensions')), 1), _hoisted_51, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", 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.report.dimensions, (dimension, dimIndex) => { + 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"])(`selectedDimension selectedDimension${dimIndex}`), + key: dimIndex + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_52, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_Field, { + uicontrol: "expandable-select", + name: "dimensions", + "model-value": dimension, + "onUpdate:modelValue": $event => _ctx.changeDimension($event, dimIndex), + title: _ctx.dimensionsReadable[dimension] || dimension, + "full-width": true, + options: _ctx.dimensions + }, null, 8, ["model-value", "onUpdate:modelValue", "title", "options"])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { + class: "icon-minus", + title: _ctx.translate('CustomReports_RemoveDimension'), + onClick: $event => _ctx.removeDimension(dimIndex) + }, null, 8, _hoisted_53)], 2); + }), 128)), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_54, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_Field, { + uicontrol: "expandable-select", + name: "dimensions", + "model-value": '', + "onUpdate:modelValue": _cache[7] || (_cache[7] = $event => { + _ctx.addDimension($event); + }), + title: _ctx.translate('CustomReports_AddDimension'), + "full-width": true, + options: _ctx.dimensions + }, null, 8, ["title", "options"])], 512), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], _ctx.report.dimensions.length < _ctx.maxDimensions]])])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_55, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_56, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { + class: "inline-help", + innerHTML: _ctx.$sanitize(_ctx.getDimensionsHelpText) + }, null, 8, _hoisted_57)])])], 512), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], _ctx.typeNeedsDimensions]]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_58, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_59, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("label", null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('General_Metrics')), 1), _hoisted_60, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", 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.visibleMetrics, (metric, metricIndex) => { + 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"])(`selectedMetric selectedMetric${metricIndex}`), + key: metricIndex + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_61, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_Field, { + uicontrol: "expandable-select", + name: "metrics", + "model-value": metric, + "onUpdate:modelValue": $event => _ctx.changeMetric($event, metricIndex), + title: _ctx.metricsReadable[metric] || metric, + "full-width": true, + options: _ctx.metrics + }, null, 8, ["model-value", "onUpdate:modelValue", "title", "options"])]), !_ctx.isSingleMetricType ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", { + key: 0, + class: "icon-minus", + title: _ctx.translate('CustomReports_RemoveMetric'), + onClick: $event => _ctx.removeMetric(metricIndex) + }, null, 8, _hoisted_62)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)], 2); + }), 128)), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_63, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_Field, { + uicontrol: "expandable-select", + name: "metrics", + "model-value": '', + "onUpdate:modelValue": _cache[8] || (_cache[8] = $event => { + _ctx.addMetric($event); + }), + title: _ctx.translate('CustomReports_AddMetric'), + "full-width": true, + options: _ctx.metrics + }, null, 8, ["title", "options"])], 512), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], !_ctx.isSingleMetricType]])])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_64, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_65, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", _hoisted_66, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate(_ctx.isSingleMetricType ? 'CustomReports_ReportSingleMetricHelp' : 'CustomReports_ReportMetricsHelp')), 1)])])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", { + class: "alert alert-warning", + innerHTML: _ctx.getProductRevenueDependencyMessage + }, null, 8, _hoisted_67), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], _ctx.dependencyAdded]]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_68, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_69, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", null, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("label", _hoisted_70, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_Filter')), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("p", null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_ReportSegmentHelp')), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", null, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_SegmentGenerator, { + "model-value": _ctx.report.segment_filter, + "onUpdate:modelValue": _cache[9] || (_cache[9] = $event => _ctx.setSegmentFilterHasChanged($event)), + idsite: _ctx.report.site.id + }, null, 8, ["model-value", "idsite"])])])])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_71, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_72, [_hoisted_73, _hoisted_74, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_SaveButton, { + class: "showPreviewButton", + disabled: !((_ctx$report$metrics = _ctx.report.metrics) !== null && _ctx$report$metrics !== void 0 && _ctx$report$metrics.length) || !((_ctx$report$dimension = _ctx.report.dimensions) !== null && _ctx$report$dimension !== void 0 && _ctx$report$dimension.length), + onConfirm: _cache[10] || (_cache[10] = $event => _ctx.showPreview()), + value: _ctx.translate('CustomReports_PreviewReport') + }, null, 8, ["disabled", "value"])])], 512), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], _ctx.report.report_type === 'table']]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_75, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_Field, { + uicontrol: "select", + name: "reportCategories", + "model-value": _ctx.report.category.id, + "onUpdate:modelValue": _cache[11] || (_cache[11] = $event => { + _ctx.report.category.id = $event; + _ctx.setValueHasChanged(); + }), + title: _ctx.translate('CustomReports_ReportCategory'), + disabled: !_ctx.canEdit, + options: _ctx.categories, + introduction: _ctx.translate('CustomReports_ReportPage'), + "inline-help": _ctx.translate('CustomReports_ReportCategoryHelp') + }, null, 8, ["model-value", "title", "disabled", "options", "introduction", "inline-help"])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_76, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_Field, { + uicontrol: "select", + name: "reportSubcategories", + "model-value": (_ctx$report$subcatego = _ctx.report.subcategory) === null || _ctx$report$subcatego === void 0 ? void 0 : _ctx$report$subcatego.id, + "onUpdate:modelValue": _cache[12] || (_cache[12] = $event => { + _ctx.setSubcategory($event); + _ctx.setValueHasChanged(); + }), + title: _ctx.translate('CustomReports_ReportSubcategory'), + disabled: !_ctx.canEdit, + options: _ctx.subcategories[_ctx.report.category.id], + "inline-help": _ctx.translate('CustomReports_ReportSubcategoryHelp') + }, null, 8, ["model-value", "title", "disabled", "options", "inline-help"])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_77, [_ctx.browserArchivingDisabled && _ctx.reArchiveLastN ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", _hoisted_78, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_WarningOnUpdateReportMightGetLostBrowserArchivingDisabled', _ctx.reArchiveLastN)), 1)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), !_ctx.browserArchivingDisabled || !_ctx.reArchiveLastN ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", _hoisted_79, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_WarningOnUpdateReportMightGetLost')), 1)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)], 512), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], _ctx.isUnlocked]]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_80, [_ctx.multipleSites.length && !_ctx.report.allowedToEdit ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", _hoisted_81, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_ReportEditNotAllowedMultipleWebsitesAccessIssue')), 1)) : (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", _hoisted_82, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_ReportEditNotAllowedAllWebsitesUpdated')), 1))], 512), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], !_ctx.canEdit]]), _ctx.childReports.length ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", _hoisted_83, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("h3", { + class: "col s12", + textContent: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_OrderSubCategoryReports')) + }, null, 8, _hoisted_84), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_85, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_DraggableList, { + items: _ctx.childReports, + "item-key": "idcustomreport", + onReorder: _ctx.onChildReportsReorder + }, { + default: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withCtx"])(({ + item: childReport + }) => [_hoisted_86, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(childReport.name), 1)]), + _: 1 + }, 8, ["items", "onReorder"])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_87, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_88, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", { + class: "form-description", + textContent: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_OrderSubCategoryReportsDescription')) + }, null, 8, _hoisted_89)])])])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_SaveButton, { + class: "createButton", + onConfirm: _cache[13] || (_cache[13] = $event => _ctx.edit ? _ctx.updateReport() : _ctx.createReport()), + disabled: _ctx.isUpdating || !_ctx.isDirty, + saving: _ctx.isUpdating, + value: _ctx.saveButtonText + }, null, 8, ["disabled", "saving", "value"]), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], _ctx.canEdit]]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_90, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("a", { + onClick: _cache[14] || (_cache[14] = $event => _ctx.cancel()) + }, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('General_Cancel')), 1)])])], 32), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_91, [_ctx.browserArchivingDisabled && _ctx.reArchiveLastN ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("h2", _hoisted_92, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_ConfirmUnlockReportBrowserArchivingDisabled', _ctx.reArchiveLastN)), 1)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), !_ctx.browserArchivingDisabled || !_ctx.reArchiveLastN ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("h2", _hoisted_93, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_ConfirmUnlockReport')), 1)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("input", { + role: "yes", + type: "button", + value: _ctx.translate('General_Yes') + }, null, 8, _hoisted_94), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("input", { + role: "no", + type: "button", + value: _ctx.translate('General_No') + }, null, 8, _hoisted_95)], 512), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_96, [_ctx.browserArchivingDisabled && _ctx.reArchiveLastN ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("h2", _hoisted_97, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_InfoReportIsLockedBrowserArchivingDisabled', _ctx.reArchiveLastN)), 1)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), !_ctx.browserArchivingDisabled || !_ctx.reArchiveLastN ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("h2", _hoisted_98, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_InfoReportIsLocked')), 1)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("input", { + role: "unlock", + type: "button", + value: _ctx.translate('CustomReports_Unlock') + }, null, 8, _hoisted_99), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("input", { + role: "ok", + type: "button", + value: _ctx.translate('General_Cancel') + }, null, 8, _hoisted_100)], 512)]; + }), + _: 1 + }, 8, ["content-title"]); +} +// CONCATENATED MODULE: ./plugins/CustomReports/vue/src/Reports/Edit.vue?vue&type=template&id=df804736 + +// EXTERNAL MODULE: external "CorePluginsAdmin" +var external_CorePluginsAdmin_ = __webpack_require__("a5a2"); + +// EXTERNAL MODULE: external "SegmentEditor" +var external_SegmentEditor_ = __webpack_require__("f06f"); + +// 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/CustomReports/vue/src/Reports/DraggableList.vue?vue&type=script&setup=true&lang=ts + + +const DraggableListvue_type_script_setup_true_lang_ts_hoisted_1 = ["data-item-id", "draggable", "aria-grabbed", "onDragstart", "onDragover"]; + +/* harmony default export */ var DraggableListvue_type_script_setup_true_lang_ts = (/*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + __name: 'DraggableList', + props: { + items: null, + itemKey: null, + disabled: { + type: Boolean, + default: false + }, + handle: { + default: '' + }, + axis: { + default: 'y' + } + }, + emits: ["reorder"], + setup(__props, { + emit + }) { + const props = __props; + const SORT_TRIGGER_OFFSET = 0.1; + const orderedItems = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])([]); + const draggedId = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])(null); + const dragTargetId = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])(null); + const placeholderId = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])(null); + const dropSucceeded = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])(false); + const canDrag = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => !props.disabled && props.items.length > 1); + function getItemKey(item, index) { + if (typeof props.itemKey === 'function') return props.itemKey(item, index); + if (!item || typeof item !== 'object') return index; + const value = item[props.itemKey]; + if (typeof value === 'string' || typeof value === 'number') return value; + return index; + } + const sourceItems = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => props.items.map((item, index) => ({ + id: String(getItemKey(item, index)), + item, + sourceIndex: index + }))); + const itemKeySignature = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => sourceItems.value.map(entry => entry.id).join('\u0000')); + function syncOrderedItems() { + orderedItems.value = sourceItems.value.slice(); + } + function clearDragVisualState() { + draggedId.value = null; + dragTargetId.value = null; + placeholderId.value = null; + } + function resetDragState(shouldSync = false) { + clearDragVisualState(); + dropSucceeded.value = false; + if (shouldSync) syncOrderedItems(); + } + function matchesHandle(target, currentTarget) { + if (!props.handle) return true; + if (!(target instanceof Element)) return false; + const handleElement = target.closest(props.handle); + return !!handleElement && currentTarget.contains(handleElement); + } + function getOrderedIndex(itemId) { + return orderedItems.value.findIndex(entry => entry.id === itemId); + } + function getOrderedItemAt(index) { + return orderedItems.value[typeof index === 'number' ? index : Number(index)]; + } + function getDropPosition(event, element) { + const rect = element.getBoundingClientRect(); + const draggedIndex = draggedId.value ? getOrderedIndex(draggedId.value) : -1; + const hoveredIndex = dragTargetId.value ? getOrderedIndex(dragTargetId.value) : -1; + const isMovingForward = draggedIndex !== -1 && hoveredIndex !== -1 && draggedIndex < hoveredIndex; + const triggerOffset = isMovingForward ? SORT_TRIGGER_OFFSET : 1 - SORT_TRIGGER_OFFSET; + if (props.axis === 'x') { + return event.clientX < rect.left + rect.width * triggerOffset ? 'before' : 'after'; + } + return event.clientY < rect.top + rect.height * triggerOffset ? 'before' : 'after'; + } + function moveDraggedItem(targetId, position) { + if (!draggedId.value || draggedId.value === targetId) return; + const currentIndex = getOrderedIndex(draggedId.value); + const targetIndex = getOrderedIndex(targetId); + if (currentIndex === -1 || targetIndex === -1) return; + let insertionIndex = targetIndex + (position === 'after' ? 1 : 0); + if (currentIndex < insertionIndex) { + insertionIndex -= 1; + } + if (insertionIndex === currentIndex) return; + const nextItems = orderedItems.value.slice(); + const [movedItem] = nextItems.splice(currentIndex, 1); + nextItems.splice(insertionIndex, 0, movedItem); + orderedItems.value = nextItems; + } + function onDragStart(event, itemId) { + const itemElement = event.currentTarget; + if (!itemElement || !canDrag.value || !matchesHandle(event.target, itemElement)) { + event.preventDefault(); + return; + } + draggedId.value = itemId; + dragTargetId.value = itemId; + dropSucceeded.value = false; + placeholderId.value = null; + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', itemId); + } + window.setTimeout(() => { + if (draggedId.value === itemId) placeholderId.value = itemId; + }, 0); + } + function onDragStartForIndex(event, index) { + const orderedItem = getOrderedItemAt(index); + if (!orderedItem) { + event.preventDefault(); + return; + } + onDragStart(event, orderedItem.id); + } + function onDragOver(event, itemId) { + if (!draggedId.value || !canDrag.value) return; + event.preventDefault(); + const itemElement = event.currentTarget; + if (!itemElement) return; + dragTargetId.value = itemId; + moveDraggedItem(itemId, getDropPosition(event, itemElement)); + if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'; + } + function onDragOverForIndex(event, index) { + const orderedItem = getOrderedItemAt(index); + if (!orderedItem) return; + onDragOver(event, orderedItem.id); + } + function onDrop(event) { + if (!draggedId.value) return; + event.preventDefault(); + const reorderedIds = orderedItems.value.map(entry => entry.id); + if (reorderedIds && reorderedIds.join('\u0000') !== itemKeySignature.value) { + dropSucceeded.value = true; + emit('reorder', reorderedIds); + } + clearDragVisualState(); + } + function onDragEnd() { + if (dropSucceeded.value) { + dropSucceeded.value = false; + return; + } + resetDragState(true); + } + Object(external_commonjs_vue_commonjs2_vue_root_Vue_["watch"])([itemKeySignature, () => props.disabled], () => resetDragState(true), { + immediate: true + }); + return (_ctx, _cache) => { + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("ul", { + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(["draggableList", { + isDragging: draggedId.value !== null, + isDisabled: __props.disabled + }]) + }, [(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"])(orderedItems.value, (orderedItem, index) => { + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("li", { + key: orderedItem.id, + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(["draggableListItem", { + isDragged: orderedItem.id === placeholderId.value + }]), + "data-item-id": orderedItem.id, + draggable: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(canDrag), + "aria-grabbed": orderedItem.id === draggedId.value, + onDragstart: $event => onDragStartForIndex($event, index), + onDragover: $event => onDragOverForIndex($event, index), + onDrop: onDrop, + onDragend: onDragEnd + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["renderSlot"])(_ctx.$slots, "default", { + item: orderedItem.item, + index: orderedItem.sourceIndex + })], 42, DraggableListvue_type_script_setup_true_lang_ts_hoisted_1); + }), 128))], 2); + }; + } +})); +// CONCATENATED MODULE: ./plugins/CustomReports/vue/src/Reports/DraggableList.vue?vue&type=script&setup=true&lang=ts + +// CONCATENATED MODULE: ./plugins/CustomReports/vue/src/Reports/DraggableList.vue + + + +/* harmony default export */ var DraggableList = (DraggableListvue_type_script_setup_true_lang_ts); +// CONCATENATED MODULE: ./plugins/CustomReports/vue/src/CustomReports.store.ts +function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +/** + * Copyright (C) InnoCraft Ltd - All rights reserved. + * + * NOTICE: All information contained herein is, and remains the property of InnoCraft Ltd. + * The intellectual and technical concepts contained herein are protected by trade secret + * or copyright law. Redistribution of this information or reproduction of this material is + * strictly forbidden unless prior written permission is obtained from InnoCraft Ltd. + * + * You shall use this code only in accordance with the license agreement obtained from + * InnoCraft Ltd. + * + * @link https://www.innocraft.com/ + * @license For license details see https://www.innocraft.com/license + */ + + +function arrayFilterAndRemoveDuplicates(values) { + return [...new Set(values)].filter(v => !!v); +} +function formatExpandableList(listByCategories, subcategoryField, extraField) { + const list = []; + listByCategories.forEach(category => { + category[subcategoryField].forEach(value => { + list.push(Object.assign({ + group: category.category, + key: value.uniqueId, + value: value.name, + tooltip: value.description || undefined + }, extraField ? { + [extraField]: value[extraField] + } : {})); + }); + }); + return list; +} +const EMPTY_CAT = { + key: '', + value: '' +}; +class CustomReports_store_CustomReportsStore { + constructor() { + _defineProperty(this, "privateState", Object(external_commonjs_vue_commonjs2_vue_root_Vue_["reactive"])({ + reports: [], + reportTypesReadable: {}, + dimensionsReadable: {}, + metricsReadable: {}, + categories: [], + subcategories: {}, + isLoading: false, + isUpdating: false, + allMetrics: [], + allDimensions: [] + })); + _defineProperty(this, "state", Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => Object(external_commonjs_vue_commonjs2_vue_root_Vue_["readonly"])(this.privateState))); + _defineProperty(this, "fetchPromise", null); + _defineProperty(this, "availableReportTypesPromise", null); + _defineProperty(this, "dimensionsPromise", null); + _defineProperty(this, "dimensionsIdsiteLoaded", 0); + _defineProperty(this, "metricsPromise", null); + _defineProperty(this, "metricsIdsiteLoaded", 0); + _defineProperty(this, "categoriesPromise", null); + _defineProperty(this, "categoriesIdsiteLoaded", null); + } + reload() { + this.privateState.reports = []; + this.fetchPromise = null; + return this.fetchReports(); + } + cleanupSegmentDefinition(definition) { + let result = definition; + result = result.replace('\'', '%27'); + result = result.replace('&', '%26'); + return result; + } + getAvailableReportTypes() { + if (!this.availableReportTypesPromise) { + this.availableReportTypesPromise = external_CoreHome_["AjaxHelper"].fetch({ + method: 'CustomReports.getAvailableReportTypes', + filter_limit: '-1' + }).then(reportTypes => { + const reportTypeMap = {}; + reportTypes.forEach(rt => { + reportTypeMap[rt.key] = rt.value; + }); + this.privateState.reportTypesReadable = reportTypeMap; + }); + } + return this.availableReportTypesPromise.then(() => this.state.value.reportTypesReadable); + } + getAvailableDimensions(idSite) { + if (!this.dimensionsPromise || this.dimensionsIdsiteLoaded !== idSite) { + this.dimensionsIdsiteLoaded = idSite; + this.dimensionsPromise = external_CoreHome_["AjaxHelper"].fetch({ + method: 'CustomReports.getAvailableDimensions', + filter_limit: '-1', + idSite + }).then(dimensions => { + const dimensionMap = {}; + dimensions.forEach(category => { + category.dimensions.forEach(dimension => { + dimensionMap[dimension.uniqueId] = dimension.name; + }); + }); + this.privateState.dimensionsReadable = dimensionMap; + this.privateState.allDimensions = formatExpandableList(dimensions, 'dimensions', 'sqlSegment'); + }); + } + return this.dimensionsPromise.then(() => this.state.value.dimensionsReadable); + } + getAvailableMetrics(idSite) { + if (!this.metricsPromise || this.metricsIdsiteLoaded !== idSite) { + this.metricsIdsiteLoaded = idSite; + this.metricsPromise = external_CoreHome_["AjaxHelper"].fetch({ + method: 'CustomReports.getAvailableMetrics', + filter_limit: '-1', + idSite + }).then(metrics => { + const metricsMap = {}; + metrics.forEach(metricsCategory => { + metricsCategory.metrics.forEach(metric => { + metricsMap[metric.uniqueId] = metric.name; + }); + }); + this.privateState.metricsReadable = metricsMap; + this.privateState.allMetrics = formatExpandableList(metrics, 'metrics'); + }); + } + return this.metricsPromise.then(() => this.state.value.metricsReadable); + } + getAvailableCategories(idSite) { + const idSiteToUse = !idSite || idSite === 'all' ? external_CoreHome_["Matomo"].idSite : idSite; + if (!this.categoriesPromise || this.categoriesIdsiteLoaded !== idSite) { + this.categoriesPromise = external_CoreHome_["AjaxHelper"].fetch({ + method: 'CustomReports.getAvailableCategories', + filter_limit: '-1', + idSite: idSiteToUse + }).then(response => { + const categories = []; + const subcategories = {}; + response.forEach(category => { + categories.push({ + key: category.uniqueId, + value: category.name + }); + category.subcategories.forEach(subcat => { + subcategories[category.uniqueId] = subcategories[category.uniqueId] || [EMPTY_CAT]; + subcategories[category.uniqueId].push({ + key: subcat.uniqueId, + value: subcat.name + }); + }); + }); + this.privateState.categories = categories; + this.privateState.subcategories = subcategories; + }); + } + return this.categoriesPromise; + } + fetchReports() { + if (!this.fetchPromise) { + this.fetchPromise = external_CoreHome_["AjaxHelper"].fetch({ + method: 'CustomReports.getConfiguredReports', + filter_limit: '-1' + }); + } + this.privateState.isLoading = true; + this.privateState.reports = []; + return this.fetchPromise.then(reports => { + this.privateState.reports = reports.map(report => { + var _report$subcategory, _report$category; + let subcategoryLink = undefined; + if (report !== null && report !== void 0 && (_report$subcategory = report.subcategory) !== null && _report$subcategory !== void 0 && _report$subcategory.id) { + subcategoryLink = report.subcategory.id; + } else if ((report === null || report === void 0 || (_report$category = report.category) === null || _report$category === void 0 ? void 0 : _report$category.id) === 'CustomReports_CustomReports') { + subcategoryLink = report.idcustomreport; + } else { + subcategoryLink = report.name; + } + return Object.assign(Object.assign({}, report), {}, { + // report.idsite is falsey when report is set for all sites + linkIdSite: report.idsite ? report.idsite : external_CoreHome_["Matomo"].idSite, + subcategoryLink + }); + }); + return this.state.value.reports; + }).finally(() => { + this.privateState.isLoading = false; + }); + } + findReport(idCustomReport, isReload) { + // before going through an API request we first try to find it in loaded reports + const found = this.state.value.reports.find(r => parseInt(`${r.idcustomreport}`, 10) === idCustomReport); + if (found && !isReload) { + return Promise.resolve(found); + } + // otherwise we fetch it via API + this.privateState.isLoading = true; + return external_CoreHome_["AjaxHelper"].fetch({ + idCustomReport, + method: 'CustomReports.getConfiguredReport' + }).finally(() => { + this.privateState.isLoading = false; + }); + } + deleteReport(idCustomReport, idSite) { + this.privateState.isUpdating = true; + this.privateState.reports = []; + return external_CoreHome_["AjaxHelper"].fetch({ + idCustomReport, + idSite: `${idSite}`, + method: 'CustomReports.deleteCustomReport' + }, { + withTokenInUrl: true + }).then(() => ({ + type: 'success' + })).catch(e => ({ + type: 'error', + message: e.message || e + })).finally(() => { + this.privateState.isUpdating = false; + }); + } + pauseReport(idCustomReport, idSite) { + this.privateState.isUpdating = true; + this.privateState.reports = []; + return external_CoreHome_["AjaxHelper"].fetch({ + idCustomReport, + idSite: `${idSite}`, + method: 'CustomReports.pauseCustomReport' + }, { + withTokenInUrl: true + }).then(() => ({ + type: 'success' + })).catch(e => ({ + type: 'error', + message: e.message || e + })).finally(() => { + this.privateState.isUpdating = false; + }); + } + resumeReport(idCustomReport, idSite) { + this.privateState.isUpdating = true; + this.privateState.reports = []; + return external_CoreHome_["AjaxHelper"].fetch({ + idCustomReport, + idSite: `${idSite}`, + method: 'CustomReports.resumeCustomReport' + }, { + withTokenInUrl: true + }).then(() => ({ + type: 'success' + })).catch(e => ({ + type: 'error', + message: e.message || e + })).finally(() => { + this.privateState.isUpdating = false; + }); + } + createOrUpdateReport(report, method, childReportIds, multipleIdSites) { + var _report$category2, _report$subcategory2; + this.privateState.isUpdating = true; + return external_CoreHome_["AjaxHelper"].post({ + method, + idCustomReport: report.idcustomreport, + reportType: report.report_type, + name: report.name.trim(), + description: report.description.trim(), + segmentFilter: encodeURIComponent(report.segment_filter), + categoryId: (_report$category2 = report.category) === null || _report$category2 === void 0 ? void 0 : _report$category2.id, + subcategoryId: (_report$subcategory2 = report.subcategory) === null || _report$subcategory2 === void 0 ? void 0 : _report$subcategory2.id, + idSite: report.site.id, + subCategoryReportIds: childReportIds, + multipleIdSites + }, { + dimensionIds: arrayFilterAndRemoveDuplicates(report.dimensions), + metricIds: arrayFilterAndRemoveDuplicates(report.metrics) + }, { + withTokenInUrl: true + }).then(response => ({ + type: 'success', + response + })).catch(error => ({ + type: 'error', + message: error.message || error + })).finally(() => { + this.privateState.isUpdating = false; + }); + } +} +/* harmony default export */ var CustomReports_store = (new CustomReports_store_CustomReportsStore()); +// 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/CustomReports/vue/src/Reports/Edit.vue?vue&type=script&lang=ts + + + + + + +const notificationId = 'reportsmanagement'; +const productMetricNotificationId = 'reportsmanagementProductMetric'; +const DIMENSIONS_ALLOWED_TO_SHARE_SQL_SEGMENT = [['UserCountry.Continent', 'UserCountry.Country']]; +function Editvue_type_script_lang_ts_arrayFilterAndRemoveDuplicates(values) { + return [...new Set(values)].filter(v => !!v); +} +function canShareSqlSegment(selectedDimensionIds, dimensionId) { + return DIMENSIONS_ALLOWED_TO_SHARE_SQL_SEGMENT.some(group => group.includes(dimensionId) && selectedDimensionIds.some(selectedDimensionId => selectedDimensionId !== dimensionId && group.includes(selectedDimensionId))); +} +function makeDefaultReport() { + return { + dimensions: [], + site: { + id: external_CoreHome_["Matomo"].idSite, + name: external_CoreHome_["Matomo"].currentSiteName + }, + category: {} + }; +} +/* harmony default export */ var Editvue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + props: { + idCustomReport: Number, + browserArchivingDisabled: Boolean, + reArchiveLastN: Number, + maxDimensions: Number, + isCloud: Boolean + }, + components: { + ContentBlock: external_CoreHome_["ContentBlock"], + DraggableList: DraggableList, + Field: external_CorePluginsAdmin_["Field"], + SiteSelector: external_CoreHome_["SiteSelector"], + SegmentGenerator: external_SegmentEditor_["SegmentGenerator"], + SaveButton: external_CorePluginsAdmin_["SaveButton"] + }, + data() { + return { + isDirty: false, + report: makeDefaultReport(), + isLocked: false, + isUnlocked: false, + canEdit: true, + dependencyAdded: false, + childReports: [], + childReportIds: [], + containsText: '', + multipleSites: [], + multipleIdSites: [] + }; + }, + created() { + CustomReports_store.getAvailableReportTypes(); + this.init(); + }, + watch: { + idCustomReport(newValue) { + if (newValue === null) { + return; + } + this.init(); + } + }, + methods: { + initReportOptions() { + const idsite = parseInt(`${this.report.site.id}`, 10) || 'all'; + CustomReports_store.getAvailableDimensions(idsite); + CustomReports_store.getAvailableMetrics(idsite); + CustomReports_store.getAvailableCategories(idsite); + }, + syncChildReportIds(reports) { + const sourceReports = reports !== null && reports !== void 0 ? reports : this.childReports; + this.childReportIds = sourceReports.map(childReport => childReport.idcustomreport); + }, + doUnlock() { + this.isLocked = false; + this.isUnlocked = true; + }, + confirmReportIsLocked(callback) { + external_CoreHome_["Matomo"].helper.modalConfirm(this.$refs.infoReportIsLocked, { + unlock: () => { + this.doUnlock(); + if (callback) { + callback(); + } + } + }); + }, + removeAnyReportNotification() { + external_CoreHome_["NotificationsStore"].remove(notificationId); + external_CoreHome_["NotificationsStore"].remove(productMetricNotificationId); + external_CoreHome_["NotificationsStore"].remove('ajaxHelper'); + }, + showApiErrorMessage(errorMessage, responseType) { + if (errorMessage && responseType) { + this.removeAnyReportNotification(); + const elem = document.createElement('textarea'); + elem.innerHTML = errorMessage; + this.showNotification(elem.value, responseType, 'toast'); + } + }, + showNotification(message, context, type = null) { + const instanceId = external_CoreHome_["NotificationsStore"].show({ + message, + context, + id: notificationId, + type: type !== null && type !== void 0 ? type : 'transient', + prepend: true + }); + setTimeout(() => { + external_CoreHome_["NotificationsStore"].scrollToNotification(instanceId); + }, 100); + }, + showProductMetricNotification(message, shouldScrollToNotification) { + const instanceId = external_CoreHome_["NotificationsStore"].show({ + message, + context: 'warning', + id: productMetricNotificationId, + type: 'transient' + }); + if (!shouldScrollToNotification) { + return; + } + setTimeout(() => { + external_CoreHome_["NotificationsStore"].scrollToNotification(instanceId); + }, 100); + }, + showErrorFieldNotProvidedNotification(title) { + const message = Object(external_CoreHome_["translate"])('CustomReports_ErrorXNotProvided', [title]); + this.showNotification(message, 'error'); + }, + init() { + const { + idCustomReport + } = this; + this.canEdit = true; + this.report = makeDefaultReport(); + this.childReports = []; + this.syncChildReportIds([]); + this.multipleSites = []; + external_CoreHome_["Matomo"].helper.lazyScrollToContent(); + if (this.edit && idCustomReport) { + CustomReports_store.findReport(idCustomReport, true).then(report => { + var _this$report$child_re; + if (!report) { + return; + } + this.report = Object(external_CoreHome_["clone"])(report); + this.isLocked = true; + this.isUnlocked = false; + this.canEdit = true; + this.childReports = (_this$report$child_re = this.report.child_reports) !== null && _this$report$child_re !== void 0 ? _this$report$child_re : []; + this.syncChildReportIds(); + if (this.report.multipleIdSites && this.report.multipleIdSites.length && this.report.site.id !== 'all' && this.report.site.id !== '0' && this.report.site.id !== 0) { + this.multipleSites = this.report.multipleIdSites; + if (!this.report.allowedToEdit) { + this.canEdit = false; + this.isLocked = false; + } + } + let idSite = this.report.idsite; + if (idSite === 0 || idSite === '0' || idSite === 'all') { + // we need to make sure to send 'all' and not '0' as otherwise piwikApi would + // consider 0 as no value set and replace it with the current idsite. Also the + // site selector expects us to set 'all' instead of 0 + idSite = 'all'; + if (!this.isSuperUser) { + // a lock does not make sense because report cannot be changed anyway. we do not want + // to show a warning related to this in such a case + this.canEdit = false; + this.isLocked = false; + } + } + this.report.site = { + id: idSite, + name: this.report.site.name + }; + if (this.report.multipleIdSites && this.report.multipleIdSites.length && idSite !== 'all' && idSite !== '0' && idSite !== 0) { + this.multipleSites = this.report.multipleIdSites; + } else if (idSite !== 'all' && idSite !== '0' && idSite !== 0) { + this.multipleSites = [{ + idsite: idSite, + name: this.report.site.name + }]; + } + this.isDirty = false; + this.initReportOptions(); + }); + return; + } + if (this.create) { + this.report = { + idsite: external_CoreHome_["Matomo"].idSite, + site: { + id: external_CoreHome_["Matomo"].idSite, + name: external_CoreHome_["Matomo"].currentSiteName || external_CoreHome_["Matomo"].siteName + }, + name: '', + description: '', + dimensions: [], + metrics: ['nb_visits'], + report_type: 'table', + category: { + id: 'CustomReports_CustomReports' + }, + subcategory: null, + segment_filter: '', + child_reports: [], + allowedToEdit: true + }; + this.multipleSites = [{ + idsite: this.report.site.id, + name: this.report.site.name + }]; + this.isLocked = false; + this.canEdit = true; + this.isDirty = false; + this.childReports = []; + this.syncChildReportIds([]); + this.initReportOptions(); + } + }, + onChildReportsReorder(order) { + const reportsById = new Map(this.childReports.map(childReport => [`${childReport.idcustomreport}`, childReport])); + const reorderedReports = order.map(id => reportsById.get(id)).filter(childReport => !!childReport); + if (reorderedReports.length !== this.childReports.length) { + return; + } + this.childReports = reorderedReports; + this.syncChildReportIds(reorderedReports); + this.setValueHasChanged(); + }, + cancel() { + const newParams = Object.assign({}, external_CoreHome_["MatomoUrl"].hashParsed.value); + delete newParams.idCustomReport; + external_CoreHome_["MatomoUrl"].updateHash(newParams); + }, + unlockReport() { + if (!this.report) { + return; + } + if (this.isLocked) { + external_CoreHome_["Matomo"].helper.modalConfirm(this.$refs.confirmUnlockReport, { + yes: () => { + this.doUnlock(); + } + }); + } + }, + createReport() { + const method = 'CustomReports.addCustomReport'; + this.removeAnyReportNotification(); + if (!this.checkRequiredFieldsAreSet()) { + return; + } + this.multipleIdSites = []; + if (this.multipleSites && this.multipleSites.length && this.report.site.id !== 'all' && this.report.site.id !== '0' && this.report.site.id !== 0) { + this.multipleIdSites.push(this.report.site.id); + this.multipleSites.forEach(item => { + const idSite = item.idsite; + if (!this.multipleIdSites.some(existing => `${existing}` === `${idSite}`)) { + this.multipleIdSites.push(idSite); + } + }); + } + CustomReports_store.createOrUpdateReport(this.report, method, this.childReportIds, this.multipleIdSites).then(response => { + if (!response || response.type === 'error' || !response.response) { + var _response$message, _response$type; + this.showApiErrorMessage((_response$message = response.message) !== null && _response$message !== void 0 ? _response$message : '', (_response$type = response.type) !== null && _response$type !== void 0 ? _response$type : 'error'); + return; + } + this.isDirty = false; + const idCustomReport = response.response.value; + if (this.report.site) { + const idSite = this.report.site.id; + if (idSite && idSite !== 'all' && `${idSite}` !== `${external_CoreHome_["Matomo"].idSite}`) { + // when creating a report for a different site... + // we need to reload this page for a different idsite, otherwise the report won't + // be found + external_CoreHome_["MatomoUrl"].updateUrl(Object.assign(Object.assign({}, external_CoreHome_["MatomoUrl"].urlParsed.value), {}, { + idSite + }), Object.assign(Object.assign({}, external_CoreHome_["MatomoUrl"].hashParsed.value), {}, { + idCustomReport + })); + return; + } + } + CustomReports_store.reload().then(() => { + if (external_CoreHome_["Matomo"].helper.isReportingPage()) { + external_CoreHome_["Matomo"].postEvent('updateReportingMenu'); + } + external_CoreHome_["MatomoUrl"].updateHash(Object.assign(Object.assign({}, external_CoreHome_["MatomoUrl"].hashParsed.value), {}, { + idCustomReport + })); + setTimeout(() => { + this.showNotification(Object(external_CoreHome_["translate"])('CustomReports_ReportCreated'), response.type); + }, 200); + }); + }); + }, + showPreview() { + var _this$report$site, _this$report$dimensio, _this$report$metrics; + if (!this.isProductRevenueDependencyMet(true)) { + return; + } + const idSite = (_this$report$site = this.report.site) !== null && _this$report$site !== void 0 && _this$report$site.id && this.report.site.id !== 'all' ? this.report.site.id : external_CoreHome_["Matomo"].idSite; + const hasDimensions = ((_this$report$dimensio = this.report.dimensions) === null || _this$report$dimensio === void 0 ? void 0 : _this$report$dimensio.length) && this.report.report_type && this.report.report_type !== 'evolution'; + const dimensions = hasDimensions ? this.report.dimensions.join(',') : undefined; + const hasMetrics = !!((_this$report$metrics = this.report.metrics) !== null && _this$report$metrics !== void 0 && _this$report$metrics.length); + const metrics = hasMetrics ? this.report.metrics.join(',') : undefined; + const url = external_CoreHome_["MatomoUrl"].stringify({ + module: 'CustomReports', + action: 'previewReport', + period: 'day', + date: 'today', + idSite, + report_type: this.report.report_type, + dimensions, + metrics, + segment: this.report.segment_filter || undefined + }); + const title = Object(external_CoreHome_["translate"])('CustomReports_Preview'); + window.Piwik_Popover.createPopupAndLoadUrl(url, title, 'customReportPreview'); + }, + setValueHasChanged() { + this.isDirty = true; + }, + addDimension(dimension) { + if (!this.report || !dimension) { + return; + } + if (this.isLocked) { + this.confirmReportIsLocked(() => { + this.addDimension(dimension); + }); + return; + } + if (!this.report.dimensions) { + this.report.dimensions = []; + } + this.report.dimensions = [...this.report.dimensions, dimension]; + this.setValueHasChanged(); + }, + changeDimension(dimension, index) { + var _this$report$dimensio2; + if (!this.report || !dimension) { + return; + } + if (this.isLocked) { + this.confirmReportIsLocked(() => { + this.changeDimension(dimension, index); + }); + return; + } + if (!((_this$report$dimensio2 = this.report.dimensions) !== null && _this$report$dimensio2 !== void 0 && _this$report$dimensio2[index])) { + return; + } + this.report.dimensions = [...this.report.dimensions]; + this.report.dimensions[index] = dimension; + this.setValueHasChanged(); + }, + changeMetric(metric, index) { + var _this$report$metrics2; + this.dependencyAdded = false; + if (!this.report || !metric) { + return; + } + if (this.isLocked) { + this.confirmReportIsLocked(() => { + this.changeMetric(metric, index); + }); + return; + } + if (!((_this$report$metrics2 = this.report.metrics) !== null && _this$report$metrics2 !== void 0 && _this$report$metrics2[index])) { + return; + } + this.report.metrics = [...this.report.metrics]; + this.report.metrics[index] = metric; + this.setValueHasChanged(); + this.addMetricIfMissingDependency(metric); + }, + setWebsiteChanged(newValue) { + this.setValueHasChanged(); + this.initReportOptions(); + if (this.report.site.id === 'all' || this.report.site.id === '0' || this.report.site.id === 0) { + this.multipleSites = []; + } else if (this.report.allowedToEdit && !this.isSiteIncludedAlready(`${newValue.id}`) && this.multipleSites) { + this.multipleSites.push({ + idsite: newValue.id, + name: newValue.name + }); + } + }, + removeDimension(index) { + if (this.isLocked) { + this.confirmReportIsLocked(() => { + this.removeDimension(index); + }); + return; + } + window.$('div.ui-tooltip[role="tooltip"]:not([style*="display: none"])').remove(); + if (index > -1) { + this.report.dimensions = [...this.report.dimensions]; + this.report.dimensions.splice(index, 1); + this.setValueHasChanged(); + } + }, + addMetric(metric) { + this.dependencyAdded = false; + if (!this.report || !metric) { + return; + } + if (!this.report.metrics) { + this.report.metrics = []; + } + if (this.isLocked) { + this.confirmReportIsLocked(() => { + this.addMetric(metric); + }); + return; + } + this.report.metrics = [...this.report.metrics, metric]; + this.setValueHasChanged(); + this.addMetricIfMissingDependency(metric); + }, + addMetricIfMissingDependency(metric) { + // If the metric isn't Product Revenue or the dependency is already met, return + if (!['sum_product_revenue', 'avg_product_revenue'].includes(metric) || this.doesReportIncludeProductQuantityMetric()) { + return; + } + const dependency = metric === 'avg_product_revenue' ? 'avg_ecommerce_productquantity' : 'sum_ecommerce_productquantity'; + this.addMetric(dependency); + this.dependencyAdded = true; + }, + removeMetric(index) { + this.dependencyAdded = false; + if (this.isLocked) { + this.confirmReportIsLocked(() => { + this.removeMetric(index); + }); + return; + } + window.$('div.ui-tooltip[role="tooltip"]:not([style*="display: none"])').remove(); + if (index > -1) { + this.report.metrics = [...this.report.metrics]; + this.report.metrics.splice(index, 1); + this.setValueHasChanged(); + } + }, + setReportTypeHasChanged(newReportType) { + if (this.report && this.isLocked) { + if (newReportType !== this.report.report_type) { + this.confirmReportIsLocked(() => { + this.report.report_type = newReportType; + this.setValueHasChanged(); + }); + } + } else { + this.report.report_type = newReportType; + this.setValueHasChanged(); + } + }, + setSegmentFilterHasChanged(newSegmentFilter) { + if (this.report && this.isLocked) { + if (newSegmentFilter !== this.report.segment_filter) { + this.confirmReportIsLocked(() => { + this.report.segment_filter = newSegmentFilter; + this.setValueHasChanged(); + }); + } + } else { + this.report.segment_filter = newSegmentFilter; + this.setValueHasChanged(); + } + }, + updateReport() { + this.removeAnyReportNotification(); + if (!this.checkRequiredFieldsAreSet()) { + return; + } + const selectedSiteId = this.report.site.id; + const isAllWebsites = selectedSiteId === 'all' || selectedSiteId === '0' || selectedSiteId === 0; + this.multipleIdSites = []; + if (this.multipleSites && this.multipleSites.length > 1 && !isAllWebsites) { + this.multipleIdSites.push(selectedSiteId); + this.multipleSites.forEach(item => { + const idSite = item.idsite; + if (!this.multipleIdSites.some(existing => `${existing}` === `${idSite}`)) { + this.multipleIdSites.push(idSite); + } + }); + } + const requestSiteId = this.multipleIdSites.length ? this.report.idsite : selectedSiteId; + const method = 'CustomReports.updateCustomReport'; + CustomReports_store.createOrUpdateReport(Object.assign(Object.assign({}, this.report), {}, { + site: Object.assign(Object.assign({}, this.report.site), {}, { + id: requestSiteId + }) + }), method, this.childReportIds, this.multipleIdSites).then(response => { + if (!response || response.type === 'error') { + var _response$message2, _response$type2; + this.showApiErrorMessage((_response$message2 = response.message) !== null && _response$message2 !== void 0 ? _response$message2 : '', (_response$type2 = response.type) !== null && _response$type2 !== void 0 ? _response$type2 : 'error'); + return; + } + const idSite = this.report.site.id; + const currentSiteRemainsAccessible = this.multipleIdSites.some(siteId => `${siteId}` === `${external_CoreHome_["Matomo"].idSite}`); + this.isDirty = false; + this.canEdit = true; + if (idSite && idSite !== 'all' && `${idSite}` !== `${external_CoreHome_["Matomo"].idSite}` && !currentSiteRemainsAccessible) { + // when moving a report from one site to another... + // we need to reload this page for a different idsite, otherwise the report won't be found + external_CoreHome_["MatomoUrl"].updateUrl(Object.assign(Object.assign({}, external_CoreHome_["MatomoUrl"].urlParsed.value), {}, { + idSite + }), Object.assign({}, external_CoreHome_["MatomoUrl"].hashParsed.value)); + return; + } + CustomReports_store.reload().then(() => { + this.init(); + }); + this.showNotification(Object(external_CoreHome_["translate"])('CustomReports_ReportUpdated'), response.type); + }); + }, + checkRequiredFieldsAreSet() { + var _this$report$metrics3; + if (!this.report.name) { + const title = Object(external_CoreHome_["translate"])('General_Name'); + this.showErrorFieldNotProvidedNotification(title); + return false; + } + if (this.typeNeedsDimensions) { + var _this$report$dimensio3; + if (!((_this$report$dimensio3 = this.report.dimensions) !== null && _this$report$dimensio3 !== void 0 && _this$report$dimensio3.length) || !Editvue_type_script_lang_ts_arrayFilterAndRemoveDuplicates(this.report.dimensions).length) { + const title = Object(external_CoreHome_["translate"])('CustomReports_ErrorMissingDimension'); + this.showNotification(title, 'error'); + return false; + } + } + if (!((_this$report$metrics3 = this.report.metrics) !== null && _this$report$metrics3 !== void 0 && _this$report$metrics3.length) || !Editvue_type_script_lang_ts_arrayFilterAndRemoveDuplicates(this.report.metrics).length) { + const title = Object(external_CoreHome_["translate"])('CustomReports_ErrorMissingMetric'); + this.showNotification(title, 'error'); + return false; + } + // Don't fail validation since we automatically add the dependency + this.isProductRevenueDependencyMet(false); + return true; + }, + setSubcategory(subcategoryId) { + this.report.subcategory = this.report.subcategory || { + id: '' + }; + this.report.subcategory.id = subcategoryId; + }, + isProductRevenueDependencyMet(shouldScrollToNotification) { + const linkString = Object(external_CoreHome_["externalLink"])('https://matomo.org/faq/custom-reports/why-is-there-an-error-when-i-try-to-run-a-custom-report-with-the-product-revenue-metric/'); + const notificationText = Object(external_CoreHome_["translate"])('CustomReports_WarningProductRevenueMetricDependency', linkString, ''); + if (this.report.metrics.includes('sum_product_revenue') && !this.doesReportIncludeProductQuantityMetric()) { + this.addMetric('sum_ecommerce_productquantity'); + this.showProductMetricNotification(notificationText, shouldScrollToNotification); + return false; + } + if (this.report.metrics.includes('avg_product_revenue') && !this.doesReportIncludeProductQuantityMetric()) { + this.addMetric('avg_ecommerce_productquantity'); + this.showProductMetricNotification(notificationText, shouldScrollToNotification); + return false; + } + return true; + }, + doesReportIncludeProductQuantityMetric() { + return this.report.metrics.includes('sum_ecommerce_productquantity') || this.report.metrics.includes('avg_ecommerce_productquantity'); + }, + isSiteIncludedAlready(idSite) { + if (this.multipleSites && this.multipleSites.length) { + return this.multipleSites.some(item => `${item.idsite}` === `${idSite}`); + } + return false; + }, + isSelectedSiteRow(site) { + return !!this.multipleSites && this.multipleSites.length > 1 && `${site.idsite}` === `${this.report.site.id}`; + }, + canRemoveSite(site) { + const isOriginalSite = `${site.idsite}` === `${this.report.idsite}`; + if (this.multipleSites && this.multipleSites.length <= 1) { + return false; + } + return !(this.edit && isOriginalSite); + }, + removeSite(site) { + if (this.multipleSites) { + if (!this.canRemoveSite(site)) { + return; + } + const isSelectedSite = `${site.idsite}` === `${this.report.site.id}`; + const removedIndex = this.multipleSites.findIndex(item => item.idsite === site.idsite); + this.isDirty = true; + this.multipleSites = this.multipleSites.filter(item => item.idsite !== site.idsite); + if (isSelectedSite && this.multipleSites.length) { + const previousSite = removedIndex > 0 ? this.multipleSites[removedIndex - 1] : null; + const nextSelectedSite = previousSite || this.multipleSites[0]; + this.report.site = { + id: nextSelectedSite.idsite, + name: nextSelectedSite.name + }; + this.initReportOptions(); + } + } + }, + addSitesContaining(searchTerm) { + if (!searchTerm) { + return; + } + const displaySearchTerm = `"${external_CoreHome_["Matomo"].helper.escape(external_CoreHome_["Matomo"].helper.htmlEntities(searchTerm))}"`; + external_CoreHome_["AjaxHelper"].fetch({ + method: 'SitesManager.getSitesWithAdminAccess', + pattern: searchTerm, + filter_limit: -1 + }).then(sites => { + if (!sites || !sites.length) { + const sitesToAdd = `
+

${Object(external_CoreHome_["translate"])('CustomReports_MatchingSearchNotFound', displaySearchTerm)}

+ +
`; + external_CoreHome_["Matomo"].helper.modalConfirm(sitesToAdd); + return; + } + const newSites = []; + const alreadyAddedSites = []; + sites.forEach(site => { + const siteName = window.vueSanitize(external_CoreHome_["Matomo"].helper.htmlEntities(site.name)); + const siteTitle = `${siteName} (id ${parseInt(`${site.idsite}`, 10)})
`; + if (this.isSiteIncludedAlready(`${site.idsite}`)) { + alreadyAddedSites.push(siteTitle); + } else { + newSites.push(siteTitle); + } + }); + let title = Object(external_CoreHome_["translate"])('CustomReports_MatchingSearchConfirmTitle', newSites.length); + if (alreadyAddedSites.length) { + const text = Object(external_CoreHome_["translate"])('CustomReports_MatchingSearchConfirmTitleAlreadyAdded', alreadyAddedSites.length); + title += ` (${text})`; + } + let sitesToAdd = `

${title}

+ ${Object(external_CoreHome_["translate"])('CustomReports_MatchingSearchMatchedAdd', newSites.length, displaySearchTerm)}: +

`; + sitesToAdd += newSites.join(''); + if (alreadyAddedSites.length) { + const text = Object(external_CoreHome_["translate"])('CustomReports_MatchingSearchMatchedAlreadyAdded', alreadyAddedSites.length, displaySearchTerm); + sitesToAdd += `
${text}:

${alreadyAddedSites.join('')}`; + } + sitesToAdd += `

+ +
`; + external_CoreHome_["Matomo"].helper.modalConfirm(sitesToAdd, { + yes: () => { + sites.forEach(site => { + if (this.multipleSites) { + if (!this.isSiteIncludedAlready(`${site.idsite}`)) { + this.isDirty = true; + this.multipleSites.push({ + idsite: site.idsite, + name: site.name + }); + } + } + }); + this.containsText = ''; + } + }); + }); + } + }, + computed: { + isSuperUser() { + return !!external_CoreHome_["Matomo"].hasSuperUserAccess; + }, + allMetrics() { + return CustomReports_store.state.value.allMetrics; + }, + metrics() { + var _this$report; + // if any of the page generation times in the report is used than allow all of them + // otherwise don't show any of thme. + const pageGenerationMetrics = ['pageviews_with_generation_time', 'avg_page_generation_time', 'max_actions_pagegenerationtime', 'sum_actions_pagegenerationtime']; + const hasPageGenerationMetric = (((_this$report = this.report) === null || _this$report === void 0 ? void 0 : _this$report.metrics) || []).some(m => pageGenerationMetrics.indexOf(m) >= 0); + const shouldRemoveGenerationTime = !hasPageGenerationMetric; + return this.allMetrics.filter(m => { + if (!m) { + return false; + } + if (shouldRemoveGenerationTime && pageGenerationMetrics.indexOf(m.key) >= 0) { + return false; + } + return true; + }); + }, + allDimensions() { + return CustomReports_store.state.value.allDimensions; + }, + reportTypes() { + return CustomReports_store.state.value.reportTypesReadable; + }, + isSingleMetricType() { + var _this$report2; + return ((_this$report2 = this.report) === null || _this$report2 === void 0 ? void 0 : _this$report2.report_type) === 'kpicard'; + }, + typeNeedsDimensions() { + var _this$report3, _this$report4; + return ((_this$report3 = this.report) === null || _this$report3 === void 0 ? void 0 : _this$report3.report_type) !== 'evolution' && ((_this$report4 = this.report) === null || _this$report4 === void 0 ? void 0 : _this$report4.report_type) !== 'kpicard'; + }, + visibleMetrics() { + var _this$report5; + const reportMetrics = ((_this$report5 = this.report) === null || _this$report5 === void 0 ? void 0 : _this$report5.metrics) || []; + // a KPI card only uses a single metric; any further already selected metrics stay + // hidden and are discarded when the report is saved + return this.isSingleMetricType ? reportMetrics.slice(0, 1) : reportMetrics; + }, + create() { + return !this.idCustomReport; + }, + edit() { + return !this.create; + }, + editTitle() { + return this.create ? 'CustomReports_CreateNewReport' : 'CustomReports_EditReport'; + }, + contentTitle() { + return Object(external_CoreHome_["translate"])(this.editTitle, this.report.name ? `"${this.report.name}"` : ''); + }, + categories() { + return CustomReports_store.state.value.categories; + }, + subcategories() { + return CustomReports_store.state.value.subcategories; + }, + dimensions() { + var _this$report6; + const result = [...this.allDimensions]; + if (!((_this$report6 = this.report) !== null && _this$report6 !== void 0 && _this$report6.dimensions)) { + return result; + } + const hasPageGenerationDimension = this.report.dimensions.indexOf('Actions.PageGenerationTime') !== -1; + // we do not allow to select eg grouping by "Page URL, Clicked URL" as it wouldn't show any + // data + const usedSqlSegments = this.report.dimensions.map(dimensionId => { + var _result$find; + return (_result$find = result.find(dim => dim.key === dimensionId)) === null || _result$find === void 0 ? void 0 : _result$find.sqlSegment; + }).filter(sqlSegment => !!sqlSegment); + // make sure these dimensions cannot be selected a second time + for (let j = 0; j < result.length; j += 1) { + const dim = result[j]; + if (!hasPageGenerationDimension && dim.key === 'Actions.PageGenerationTime') { + // we only show this metric if it was used before already in the report + result.splice(j, 1); + j -= 1; + break; + } + if (dim.sqlSegment && usedSqlSegments.indexOf(dim.sqlSegment) > -1 && !canShareSqlSegment(this.report.dimensions, dim.key) && this.report.dimensions.indexOf(dim.key) === -1) { + // we want to make sure to not show incompatible dimensions but we still want to show an + // already selected dimension again so users can eg easily swap dimensions etc. + result.splice(j, 1); + j -= 1; + } + } + return result; + }, + isLoading() { + return CustomReports_store.state.value.isLoading; + }, + isUpdating() { + return CustomReports_store.state.value.isUpdating; + }, + dimensionsReadable() { + return CustomReports_store.state.value.dimensionsReadable; + }, + metricsReadable() { + return CustomReports_store.state.value.metricsReadable; + }, + saveButtonText() { + return this.edit ? Object(external_CoreHome_["translate"])('CoreUpdater_UpdateTitle') : Object(external_CoreHome_["translate"])('CustomReports_CreateNewReport'); + }, + getProductRevenueDependencyMessage() { + const linkString = Object(external_CoreHome_["externalLink"])('https://matomo.org/faq/custom-reports/why-is-there-an-error-when-i-try-to-run-a-custom-report-with-the-product-revenue-metric/'); + return Object(external_CoreHome_["translate"])('CustomReports_WarningProductRevenueMetricDependency', linkString, ''); + }, + getPausedStateAdminMessage() { + const url = `?${external_CoreHome_["MatomoUrl"].stringify(Object.assign(Object.assign({}, external_CoreHome_["MatomoUrl"].urlParsed.value), {}, { + module: 'CustomReports', + action: 'manage' + }))}`; + return Object(external_CoreHome_["translate"])('CustomReports_ReportInPausedStateAdmin', ``, ''); + }, + getDescriptionHelpText() { + const linkString = Object(external_CoreHome_["externalLink"])('https://matomo.org/faq/custom-reports/how-to-add-an-information-icon-to-the-title-of-a-custom-report/'); + return Object(external_CoreHome_["translate"])('CustomReports_ReportDescriptionHelpText', linkString, ''); + }, + getDimensionsHelpText() { + const helpText = Object(external_CoreHome_["translate"])('CustomReports_ReportDimensionsHelpNew'); + const extended = this.getDimensionsHelpTextExtended; + if (extended) { + return `${helpText}

${extended}`; + } + return helpText; + }, + getDimensionsHelpTextExtended() { + if (this.isCloud) { + return ''; + } + const linkString = Object(external_CoreHome_["externalLink"])('https://matomo.org/faq/custom-reports/faq_25655/'); + return Object(external_CoreHome_["translate"])('CustomReports_ReportDimensionsHelpExtended', linkString, ''); + } + } +})); +// CONCATENATED MODULE: ./plugins/CustomReports/vue/src/Reports/Edit.vue?vue&type=script&lang=ts + +// CONCATENATED MODULE: ./plugins/CustomReports/vue/src/Reports/Edit.vue + + + +Editvue_type_script_lang_ts.render = Editvue_type_template_id_df804736_render + +/* harmony default export */ var Edit = (Editvue_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/CustomReports/vue/src/Reports/List.vue?vue&type=template&id=4270553c + +const Listvue_type_template_id_4270553c_hoisted_1 = { + class: "reportSearchFilter" +}; +const Listvue_type_template_id_4270553c_hoisted_2 = { + class: "index" +}; +const Listvue_type_template_id_4270553c_hoisted_3 = { + class: "name" +}; +const Listvue_type_template_id_4270553c_hoisted_4 = { + class: "description" +}; +const Listvue_type_template_id_4270553c_hoisted_5 = { + class: "reportType" +}; +const Listvue_type_template_id_4270553c_hoisted_6 = { + class: "reportCategory" +}; +const Listvue_type_template_id_4270553c_hoisted_7 = { + class: "action" +}; +const Listvue_type_template_id_4270553c_hoisted_8 = { + colspan: "7" +}; +const Listvue_type_template_id_4270553c_hoisted_9 = { + class: "loadingPiwik" +}; +const Listvue_type_template_id_4270553c_hoisted_10 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("img", { + src: "plugins/Morpheus/images/loading-blue.gif" +}, null, -1); +const Listvue_type_template_id_4270553c_hoisted_11 = { + colspan: "7" +}; +const Listvue_type_template_id_4270553c_hoisted_12 = ["id"]; +const Listvue_type_template_id_4270553c_hoisted_13 = { + class: "index" +}; +const Listvue_type_template_id_4270553c_hoisted_14 = { + class: "name" +}; +const Listvue_type_template_id_4270553c_hoisted_15 = ["title"]; +const Listvue_type_template_id_4270553c_hoisted_16 = ["title"]; +const Listvue_type_template_id_4270553c_hoisted_17 = ["title"]; +const Listvue_type_template_id_4270553c_hoisted_18 = ["title"]; +const Listvue_type_template_id_4270553c_hoisted_19 = ["title"]; +const Listvue_type_template_id_4270553c_hoisted_20 = { + class: "reportType" +}; +const Listvue_type_template_id_4270553c_hoisted_21 = ["title"]; +const Listvue_type_template_id_4270553c_hoisted_22 = { + key: 0 +}; +const Listvue_type_template_id_4270553c_hoisted_23 = ["title", "onClick"]; +const Listvue_type_template_id_4270553c_hoisted_24 = ["title", "onClick"]; +const Listvue_type_template_id_4270553c_hoisted_25 = ["title", "onClick"]; +const Listvue_type_template_id_4270553c_hoisted_26 = ["title", "href"]; +const Listvue_type_template_id_4270553c_hoisted_27 = { + key: 0 +}; +const Listvue_type_template_id_4270553c_hoisted_28 = ["title", "onClick"]; +const Listvue_type_template_id_4270553c_hoisted_29 = { + class: "tableActionBar" +}; +const Listvue_type_template_id_4270553c_hoisted_30 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { + class: "icon-add" +}, null, -1); +const Listvue_type_template_id_4270553c_hoisted_31 = { + class: "ui-confirm", + ref: "confirmDeleteReport" +}; +const Listvue_type_template_id_4270553c_hoisted_32 = ["value"]; +const Listvue_type_template_id_4270553c_hoisted_33 = ["value"]; +const Listvue_type_template_id_4270553c_hoisted_34 = { + class: "ui-confirm", + ref: "confirmPauseReport" +}; +const Listvue_type_template_id_4270553c_hoisted_35 = ["value"]; +const Listvue_type_template_id_4270553c_hoisted_36 = ["value"]; +const Listvue_type_template_id_4270553c_hoisted_37 = { + class: "ui-confirm", + ref: "confirmResumeReport" +}; +const Listvue_type_template_id_4270553c_hoisted_38 = ["value"]; +const Listvue_type_template_id_4270553c_hoisted_39 = ["value"]; +function Listvue_type_template_id_4270553c_render(_ctx, _cache, $props, $setup, $data, $options) { + const _component_Field = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("Field"); + const _component_EntityDuplicatorAction = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("EntityDuplicatorAction"); + const _component_ContentBlock = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("ContentBlock"); + const _component_EntityDuplicatorModal = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("EntityDuplicatorModal"); + 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"])(external_commonjs_vue_commonjs2_vue_root_Vue_["Fragment"], null, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", null, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_ContentBlock, { + "content-title": _ctx.translate('CustomReports_ManageReports'), + feature: _ctx.translate('CustomReports_ManageReports') + }, { + default: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withCtx"])(() => [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("p", null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_CustomReportIntroduction')), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", Listvue_type_template_id_4270553c_hoisted_1, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_Field, { + uicontrol: "text", + name: "reportSearch", + title: _ctx.translate('General_Search'), + modelValue: _ctx.searchFilter, + "onUpdate:modelValue": _cache[0] || (_cache[0] = $event => _ctx.searchFilter = $event) + }, null, 8, ["title", "modelValue"]), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], _ctx.reports.length > 0]])]), 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", null, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("thead", null, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("tr", null, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("th", Listvue_type_template_id_4270553c_hoisted_2, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('General_Id')), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("th", Listvue_type_template_id_4270553c_hoisted_3, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('General_Name')), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("th", Listvue_type_template_id_4270553c_hoisted_4, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('General_Description')), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("th", Listvue_type_template_id_4270553c_hoisted_5, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_Type')), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("th", Listvue_type_template_id_4270553c_hoisted_6, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_Category')), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("th", Listvue_type_template_id_4270553c_hoisted_7, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('General_Actions')), 1)])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("tbody", null, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("tr", null, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("td", Listvue_type_template_id_4270553c_hoisted_8, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", Listvue_type_template_id_4270553c_hoisted_9, [Listvue_type_template_id_4270553c_hoisted_10, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createTextVNode"])(" " + Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('General_LoadingData')), 1)])])], 512), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], _ctx.isLoading || _ctx.isUpdating]]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("tr", null, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("td", Listvue_type_template_id_4270553c_hoisted_11, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_NoCustomReportsFound')), 1)], 512), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], !_ctx.isLoading && _ctx.reports.length == 0]]), (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.sortedReports, report => { + var _report$subcategory; + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("tr", { + id: `report${report.idcustomreport}`, + class: "customReports", + key: report.idcustomreport + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("td", Listvue_type_template_id_4270553c_hoisted_13, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(report.idcustomreport), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("td", Listvue_type_template_id_4270553c_hoisted_14, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createTextVNode"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(report.name) + " ", 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { + class: "icon-locked", + title: _ctx.translate('CustomReports_ReportEditNotAllowedAllWebsitesUpdated') + }, null, 8, Listvue_type_template_id_4270553c_hoisted_15), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], !report.idsite && !_ctx.isSuperUser]]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { + class: "icon-info2", + title: _ctx.translate('CustomReports_ReportAvailableToAllWebsites') + }, null, 8, Listvue_type_template_id_4270553c_hoisted_16), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], !report.idsite && _ctx.isSuperUser]]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { + class: "icon-locked", + title: _ctx.translate('CustomReports_ReportEditNotAllowedMultipleWebsitesAccessIssue') + }, null, 8, Listvue_type_template_id_4270553c_hoisted_17), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], !report.allowedToEdit && _ctx.isMultiSiteReport(report)]]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { + class: "icon-info2", + title: _ctx.translate('CustomReports_ReportAvailableToMultipleWebsites') + }, null, 8, Listvue_type_template_id_4270553c_hoisted_18), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], report.allowedToEdit && _ctx.isMultiSiteReport(report)]])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("td", { + class: "description", + title: _ctx.htmlEntities(report.description) + }, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.truncate(report.description.trim(), 60)), 9, Listvue_type_template_id_4270553c_hoisted_19), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("td", Listvue_type_template_id_4270553c_hoisted_20, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.reportTypesReadable[report.report_type]), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("td", { + class: "reportCategory", + title: _ctx.htmlEntities(report.category.name) + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createTextVNode"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.truncate(report.category.name.trim(), 60)) + " ", 1), (_report$subcategory = report.subcategory) !== null && _report$subcategory !== void 0 && _report$subcategory.name ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", Listvue_type_template_id_4270553c_hoisted_22, " - " + Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.truncate(report.subcategory.name.trim(), 60)), 1)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)], 8, Listvue_type_template_id_4270553c_hoisted_21), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("td", { + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])({ + 'action': true, + 'duplicate-available': _ctx.isEntityDuplicatorAvailable + }) + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("a", { + class: "table-action icon-pause", + title: _ctx.translate('CustomReports_PauseReportInfo'), + onClick: $event => _ctx.pauseReport(report) + }, null, 8, Listvue_type_template_id_4270553c_hoisted_23), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], (report.idsite && !_ctx.isMultiSiteReport(report) || report.allowedToEdit) && report.status === 'active']]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("a", { + class: "table-action icon-play", + title: _ctx.translate('CustomReports_ResumeReportInfo'), + onClick: $event => _ctx.resumeReport(report) + }, null, 8, Listvue_type_template_id_4270553c_hoisted_24), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], (report.idsite && !_ctx.isMultiSiteReport(report) || report.allowedToEdit) && report.status === 'paused']]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("a", { + class: "table-action icon-edit", + title: _ctx.translate('CustomReports_EditReport'), + onClick: $event => _ctx.editReport(report.idcustomreport) + }, null, 8, Listvue_type_template_id_4270553c_hoisted_25), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("a", { + target: "_blank", + class: "table-action icon-show", + title: _ctx.translate('CustomReports_ViewReportInfo'), + href: _ctx.getViewReportLink(report) + }, null, 8, Listvue_type_template_id_4270553c_hoisted_26), _ctx.isEntityDuplicatorAvailable && (report.idsite && !_ctx.isMultiSiteReport(report) || report.allowedToEdit) ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", Listvue_type_template_id_4270553c_hoisted_27, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_EntityDuplicatorAction, { + actionFormData: { + idCustomReport: report.idcustomreport + }, + modalStore: _ctx.entityDuplicatorStore, + isActionVisible: _ctx.showDuplicatorAction, + isActionEnabled: _ctx.enableDuplicatorAction, + tooltipTextOverrideDisabled: _ctx.translate('CustomReports_QuotaReachedForX', _ctx.translate('CustomReports_CustomReport'), _ctx.translate('CustomReports_CustomReports')), + extraClasses: ['customreport-duplicate-action', `customreport-${report.idcustomreport}`] + }, null, 8, ["actionFormData", "modalStore", "isActionVisible", "isActionEnabled", "tooltipTextOverrideDisabled", "extraClasses"])])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("a", { + class: "table-action icon-delete", + title: _ctx.translate('CustomReports_DeleteReportInfo'), + onClick: $event => _ctx.deleteReport(report) + }, null, 8, Listvue_type_template_id_4270553c_hoisted_28), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], report.idsite && !_ctx.isMultiSiteReport(report) || report.allowedToEdit]])], 2)], 8, Listvue_type_template_id_4270553c_hoisted_12); + }), 128))])])), [[_directive_content_table]]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", Listvue_type_template_id_4270553c_hoisted_29, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("a", { + class: "createNewReport", + onClick: _cache[1] || (_cache[1] = $event => _ctx.createReport()) + }, [Listvue_type_template_id_4270553c_hoisted_30, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createTextVNode"])(" " + Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_CreateNewReport')), 1)])])]), + _: 1 + }, 8, ["content-title", "feature"]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", Listvue_type_template_id_4270553c_hoisted_31, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("h2", null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_DeleteReportConfirm')), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("input", { + role: "yes", + type: "button", + value: _ctx.translate('General_Yes') + }, null, 8, Listvue_type_template_id_4270553c_hoisted_32), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("input", { + role: "no", + type: "button", + value: _ctx.translate('General_No') + }, null, 8, Listvue_type_template_id_4270553c_hoisted_33)], 512), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", Listvue_type_template_id_4270553c_hoisted_34, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("h2", null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_PauseReportConfirm')), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("input", { + role: "yes", + type: "button", + value: _ctx.translate('General_Yes') + }, null, 8, Listvue_type_template_id_4270553c_hoisted_35), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("input", { + role: "no", + type: "button", + value: _ctx.translate('General_No') + }, null, 8, Listvue_type_template_id_4270553c_hoisted_36)], 512), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", Listvue_type_template_id_4270553c_hoisted_37, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("h2", null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CustomReports_ResumeReportConfirm')), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("input", { + role: "yes", + type: "button", + value: _ctx.translate('General_Yes') + }, null, 8, Listvue_type_template_id_4270553c_hoisted_38), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("input", { + role: "no", + type: "button", + value: _ctx.translate('General_No') + }, null, 8, Listvue_type_template_id_4270553c_hoisted_39)], 512)]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_EntityDuplicatorModal, { + modalStore: _ctx.entityDuplicatorStore + }, null, 8, ["modalStore"])], 64); +} +// CONCATENATED MODULE: ./plugins/CustomReports/vue/src/Reports/List.vue?vue&type=template&id=4270553c + +// CONCATENATED MODULE: ./plugins/CustomReports/vue/src/truncateText2.ts +/** + * Copyright (C) InnoCraft Ltd - All rights reserved. + * + * NOTICE: All information contained herein is, and remains the property of InnoCraft Ltd. + * The intellectual and technical concepts contained herein are protected by trade secret + * or copyright law. Redistribution of this information or reproduction of this material is + * strictly forbidden unless prior written permission is obtained from InnoCraft Ltd. + * + * You shall use this code only in accordance with the license agreement obtained from + * InnoCraft Ltd. + * + * @link https://www.innocraft.com/ + * @license For license details see https://www.innocraft.com/license + */ +function truncateText2(text, length) { + if (text && text.length > length) { + return `${text.substr(0, length - 3)}...`; + } + return text; +} +// 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/CustomReports/vue/src/Reports/List.vue?vue&type=script&lang=ts + + + + + +// Load these separately in case the version of core doesn't have the components yet +const EntityDuplicatorModal = Object(external_CoreHome_["useExternalPluginComponent"])('CoreHome', 'EntityDuplicatorModal'); +const EntityDuplicatorAction = Object(external_CoreHome_["useExternalPluginComponent"])('CoreHome', 'EntityDuplicatorAction'); +// Load the class similar to useExternalPluginComponent, but for something other than a component +let EntityDuplicatorStore = undefined; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +Object(external_CoreHome_["importPluginUmd"])('CoreHome').then(module => { + EntityDuplicatorStore = module === null || module === void 0 ? void 0 : module.EntityDuplicatorStore; +}); +const Listvue_type_script_lang_ts_notificationId = 'customreportmanagementlist'; +/* harmony default export */ var Listvue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + props: {}, + components: { + ContentBlock: external_CoreHome_["ContentBlock"], + Field: external_CorePluginsAdmin_["Field"], + EntityDuplicatorModal, + EntityDuplicatorAction + }, + directives: { + ContentTable: external_CoreHome_["ContentTable"] + }, + data() { + return { + searchFilter: '', + showDuplicatorAction: true, + enableDuplicatorAction: false, + entityDuplicatorStore: typeof EntityDuplicatorStore !== 'undefined' ? EntityDuplicatorStore.buildStoreInstance('CustomReports_CustomReport', { + method: 'CustomReports.duplicateCustomReport', + requiredFields: ['idSite', 'idDestinationSites', 'idCustomReport'] + }) : undefined + }; + }, + created() { + CustomReports_store.getAvailableReportTypes(); + CustomReports_store.fetchReports(); + }, + mounted() { + // Check whether adding customreport is allowed. It will disable the action if it isn't + this.checkIsAddingCustomReportsAllowed(); + // If the adapter is defined, cast it to the expected type and override any necessary methods. + if (this.entityDuplicatorStore) { + const adapter = this.entityDuplicatorStore.adapter; + adapter.onSuccessCallback = response => new Promise(resolve => { + var _response$additionalD, _response$additionalD2, _response$additionalD3; + // Check if reloading is even necessary. If the current site isn't a destination skip reload + const idSite = (_response$additionalD = response.additionalData) === null || _response$additionalD === void 0 ? void 0 : _response$additionalD.idSite; + if ((_response$additionalD2 = response.additionalData) !== null && _response$additionalD2 !== void 0 && _response$additionalD2.idSite && Array.isArray((_response$additionalD3 = response.additionalData) === null || _response$additionalD3 === void 0 ? void 0 : _response$additionalD3.idDestinationSites) && !response.additionalData.idDestinationSites.some(id => +id === +idSite)) { + return resolve(); + } + return CustomReports_store.reload().then(() => resolve()); + }); + } + }, + methods: { + createReport() { + this.editReport(0); + }, + checkIsAddingCustomReportsAllowed() { + // Post event so that Billing,etc. can indicate whether adding another customreport is allowed + const parameters = { + isAllowed: true + }; + external_CoreHome_["Matomo"].postEvent('CustomReports.initAddCustomReport', parameters); + this.enableDuplicatorAction = parameters && parameters.isAllowed === true; + return !this.enableDuplicatorAction; + }, + editReport(idCustomReport) { + external_CoreHome_["MatomoUrl"].updateHash(Object.assign(Object.assign({}, external_CoreHome_["MatomoUrl"].hashParsed.value), {}, { + idCustomReport + })); + }, + pauseReport(report) { + external_CoreHome_["Matomo"].helper.modalConfirm(this.$refs.confirmPauseReport, { + yes: () => { + CustomReports_store.pauseReport(report.idcustomreport, report.idsite).then(response => { + if (!response || response.type === 'error') { + CustomReports_store.reload(); + return; + } + CustomReports_store.reload().then(() => { + this.showNotification(this.translate('CustomReports_PausedReport'), 'success'); + }); + external_CoreHome_["Matomo"].postEvent('updateReportingMenu'); + }); + } + }); + }, + resumeReport(report) { + external_CoreHome_["Matomo"].helper.modalConfirm(this.$refs.confirmResumeReport, { + yes: () => { + CustomReports_store.resumeReport(report.idcustomreport, report.idsite).then(response => { + if (!response || response.type === 'error') { + CustomReports_store.reload(); + return; + } + CustomReports_store.reload().then(() => { + this.showNotification(this.translate('CustomReports_ResumedReport'), 'success'); + }); + external_CoreHome_["Matomo"].postEvent('updateReportingMenu'); + }); + } + }); + }, + showNotification(message, context, type = null) { + const instanceId = external_CoreHome_["NotificationsStore"].show({ + message, + context, + id: Listvue_type_script_lang_ts_notificationId, + type: type !== null ? type : 'toast' + }); + setTimeout(() => { + external_CoreHome_["NotificationsStore"].scrollToNotification(instanceId); + }, 200); + }, + deleteReport(report) { + external_CoreHome_["Matomo"].helper.modalConfirm(this.$refs.confirmDeleteReport, { + yes: () => { + CustomReports_store.deleteReport(report.idcustomreport, report.idsite).then(() => { + CustomReports_store.reload(); + external_CoreHome_["Matomo"].postEvent('updateReportingMenu'); + }); + } + }); + }, + getViewReportLink(report) { + return `?${external_CoreHome_["MatomoUrl"].stringify({ + module: 'CoreHome', + action: 'index', + idSite: report.linkIdSite, + period: 'day', + date: 'yesterday' + })}#?${external_CoreHome_["MatomoUrl"].stringify({ + category: report.category.id, + idSite: report.linkIdSite, + date: external_CoreHome_["MatomoUrl"].parsed.value.date, + period: external_CoreHome_["MatomoUrl"].parsed.value.period, + segment: external_CoreHome_["MatomoUrl"].parsed.value.segment, + subcategory: report.subcategoryLink + })}`; + }, + truncate: truncateText2, + htmlEntities(v) { + return external_CoreHome_["Matomo"].helper.htmlEntities(v); + }, + isMultiSiteReport(report) { + return report.multiple_idsites && report.multiple_idsites.split(','); + } + }, + computed: { + isSuperUser() { + return external_CoreHome_["Matomo"].hasSuperUserAccess; + }, + reports() { + return CustomReports_store.state.value.reports; + }, + sortedReports() { + const searchFilter = this.searchFilter.toLowerCase(); + // look through string properties of custom reports for values that have searchFilter in them + // (mimics angularjs filter() filter) + const result = [...this.reports].filter(h => Object.keys(h).some(propName => { + const entity = h; + return typeof entity[propName] === 'string' && entity[propName].toLowerCase().indexOf(searchFilter) !== -1; + })); + result.sort((lhs, rhs) => { + const lhsId = parseInt(`${lhs.idcustomreport}`, 10); + const rhsId = parseInt(`${rhs.idcustomreport}`, 10); + return lhsId - rhsId; + }); + return result; + }, + isLoading() { + return CustomReports_store.state.value.isLoading; + }, + isUpdating() { + return CustomReports_store.state.value.isUpdating; + }, + reportTypesReadable() { + return CustomReports_store.state.value.reportTypesReadable; + }, + isEntityDuplicatorAvailable() { + return typeof EntityDuplicatorStore !== 'undefined'; + } + } +})); +// CONCATENATED MODULE: ./plugins/CustomReports/vue/src/Reports/List.vue?vue&type=script&lang=ts + +// CONCATENATED MODULE: ./plugins/CustomReports/vue/src/Reports/List.vue + + + +Listvue_type_script_lang_ts.render = Listvue_type_template_id_4270553c_render + +/* harmony default export */ var List = (Listvue_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/CustomReports/vue/src/Reports/Manage.vue?vue&type=template&id=03955ed8 + +const Managevue_type_template_id_03955ed8_hoisted_1 = { + class: "manageReports" +}; +const Managevue_type_template_id_03955ed8_hoisted_2 = { + key: 0 +}; +const Managevue_type_template_id_03955ed8_hoisted_3 = { + key: 1 +}; +function Managevue_type_template_id_03955ed8_render(_ctx, _cache, $props, $setup, $data, $options) { + const _component_CustomReportsList = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("CustomReportsList"); + const _component_CustomReportsEdit = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("CustomReportsEdit"); + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", Managevue_type_template_id_03955ed8_hoisted_1, [!_ctx.editMode ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", Managevue_type_template_id_03955ed8_hoisted_2, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_CustomReportsList)])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), _ctx.editMode ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", Managevue_type_template_id_03955ed8_hoisted_3, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_CustomReportsEdit, { + "id-custom-report": _ctx.idCustomReport, + "browser-archiving-disabled": _ctx.browserArchivingDisabled, + "re-archive-last-n": _ctx.reArchiveLastN, + "max-dimensions": _ctx.maxDimensions, + "is-cloud": _ctx.isCloud + }, null, 8, ["id-custom-report", "browser-archiving-disabled", "re-archive-last-n", "max-dimensions", "is-cloud"])])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)]); +} +// CONCATENATED MODULE: ./plugins/CustomReports/vue/src/Reports/Manage.vue?vue&type=template&id=03955ed8 + +// 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/CustomReports/vue/src/Reports/Manage.vue?vue&type=script&lang=ts + + + + +/* harmony default export */ var Managevue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + props: { + browserArchivingDisabled: Boolean, + reArchiveLastN: Number, + maxDimensions: Number, + isCloud: Boolean + }, + components: { + CustomReportsList: List, + CustomReportsEdit: Edit + }, + data() { + return { + editMode: false, + idCustomReport: null + }; + }, + watch: { + editMode() { + // when changing edit modes, the tooltip can sometimes get stuck on the screen + $('.ui-tooltip').remove(); + } + }, + created() { + // doing this in a watch because we don't want to post an event in a computed property + Object(external_commonjs_vue_commonjs2_vue_root_Vue_["watch"])(() => external_CoreHome_["MatomoUrl"].hashParsed.value.idCustomReport, idCustomReport => { + this.initState(idCustomReport); + }); + this.initState(external_CoreHome_["MatomoUrl"].hashParsed.value.idCustomReport); + }, + methods: { + removeAnyReportNotification(shouldHideProductMetricNotification = true) { + external_CoreHome_["NotificationsStore"].remove('reportsmanagement'); + if (shouldHideProductMetricNotification) { + external_CoreHome_["NotificationsStore"].remove('reportsmanagementProductMetric'); + } + }, + initState(idCustomReport) { + if (idCustomReport) { + if (idCustomReport === '0') { + const parameters = { + isAllowed: true + }; + external_CoreHome_["Matomo"].postEvent('CustomReports.initAddReport', parameters); + if (parameters && !parameters.isAllowed) { + this.editMode = false; + this.idCustomReport = null; + return; + } + } + this.editMode = true; + this.idCustomReport = parseInt(idCustomReport, 10); + } else { + this.editMode = false; + this.idCustomReport = null; + } + this.removeAnyReportNotification(!idCustomReport); + } + } +})); +// CONCATENATED MODULE: ./plugins/CustomReports/vue/src/Reports/Manage.vue?vue&type=script&lang=ts + +// CONCATENATED MODULE: ./plugins/CustomReports/vue/src/Reports/Manage.vue + + + +Managevue_type_script_lang_ts.render = Managevue_type_template_id_03955ed8_render + +/* harmony default export */ var Manage = (Managevue_type_script_lang_ts); +// CONCATENATED MODULE: ./plugins/CustomReports/vue/src/index.ts +/** + * Copyright (C) InnoCraft Ltd - All rights reserved. + * + * NOTICE: All information contained herein is, and remains the property of InnoCraft Ltd. + * The intellectual and technical concepts contained herein are protected by trade secret + * or copyright law. Redistribution of this information or reproduction of this material is + * strictly forbidden unless prior written permission is obtained from InnoCraft Ltd. + * + * You shall use this code only in accordance with the license agreement obtained from + * InnoCraft Ltd. + * + * @link https://www.innocraft.com/ + * @license For license details see https://www.innocraft.com/license + */ + + + + +// CONCATENATED MODULE: ./node_modules/@vue/cli-service/lib/commands/build/entry-lib-no-default.js + + + + +/***/ }) + +/******/ }); +}); +//# sourceMappingURL=CustomReports.umd.js.map \ No newline at end of file diff --git a/files/plugin-CustomReports-5.5.0/vue/dist/CustomReports.umd.min.js b/files/plugin-CustomReports-5.5.0/vue/dist/CustomReports.umd.min.js new file mode 100644 index 0000000..35106d5 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/vue/dist/CustomReports.umd.min.js @@ -0,0 +1,44 @@ +(function(e,t){"object"===typeof exports&&"object"===typeof module?module.exports=t(require("CoreHome"),require("vue"),require("CorePluginsAdmin"),require("SegmentEditor")):"function"===typeof define&&define.amd?define(["CoreHome",,"CorePluginsAdmin","SegmentEditor"],t):"object"===typeof exports?exports["CustomReports"]=t(require("CoreHome"),require("vue"),require("CorePluginsAdmin"),require("SegmentEditor")):e["CustomReports"]=t(e["CoreHome"],e["Vue"],e["CorePluginsAdmin"],e["SegmentEditor"])})("undefined"!==typeof self?self:this,(function(e,t,i,o){return function(e){var t={};function i(o){if(t[o])return t[o].exports;var r=t[o]={i:o,l:!1,exports:{}};return e[o].call(r.exports,r,r.exports,i),r.l=!0,r.exports}return i.m=e,i.c=t,i.d=function(e,t,o){i.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:o})},i.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.t=function(e,t){if(1&t&&(e=i(e)),8&t)return e;if(4&t&&"object"===typeof e&&e&&e.__esModule)return e;var o=Object.create(null);if(i.r(o),Object.defineProperty(o,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var r in e)i.d(o,r,function(t){return e[t]}.bind(null,r));return o},i.n=function(e){var t=e&&e.__esModule?function(){return e["default"]}:function(){return e};return i.d(t,"a",t),t},i.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},i.p="plugins/CustomReports/vue/dist/",i(i.s="fae3")}({"19dc":function(t,i){t.exports=e},"8bbf":function(e,i){e.exports=t},a5a2:function(e,t){e.exports=i},f06f:function(e,t){e.exports=o},fae3:function(e,t,i){"use strict";if(i.r(t),i.d(t,"KpiCardView",(function(){return h})),i.d(t,"ReportEdit",(function(){return kt})),i.d(t,"ReportsList",(function(){return fi})),i.d(t,"ReportsManage",(function(){return Ei})),"undefined"!==typeof window){var o=window.document.currentScript,r=o&&o.src.match(/(.+\/)[^/]+\.js(\?.*)?$/);r&&(i.p=r[1])}var s=i("8bbf");const n={class:"metric-sparkline"},a={class:"metric-value"},l=["title"],c=["title"];function d(e,t,i,o,r,d){const p=Object(s["resolveComponent"])("Sparkline");return Object(s["openBlock"])(),Object(s["createElementBlock"])("div",{class:Object(s["normalizeClass"])(["singleMetricView customReportKpiCard",{loading:e.isLoading}])},[Object(s["createElementVNode"])("div",n,[Object(s["createVNode"])(p,{params:e.sparklineParams},null,8,["params"])]),Object(s["createElementVNode"])("div",a,[Object(s["createElementVNode"])("span",{title:e.metricDocumentation},[Object(s["createElementVNode"])("strong",null,Object(s["toDisplayString"])(e.metricValue),1),Object(s["createTextVNode"])(" "+Object(s["toDisplayString"])((e.metricTranslation||"").toLowerCase()),1)],8,l),null!==e.pastValue?(Object(s["openBlock"])(),Object(s["createElementBlock"])("span",{key:0,class:"metricEvolution",title:e.translate("General_EvolutionSummaryGeneric",e.metricValue,e.currentPeriod,e.pastValue,e.pastPeriod,e.metricChangePercent)},[Object(s["createElementVNode"])("span",{class:Object(s["normalizeClass"])(e.evolutionClass)},Object(s["toDisplayString"])(e.metricChangePercent),3)],8,c)):Object(s["createCommentVNode"])("",!0)])],2)}var p=i("19dc");function u(){const{startDate:e}=p["Range"].getLastNRange(p["Matomo"].period,2,p["Matomo"].currentDateString),t=p["Periods"].get(p["Matomo"].period).parse(e).getDateRange();return`${Object(p["format"])(t[0])},${Object(p["format"])(t[1])}`}var m=Object(s["defineComponent"])({props:{idCustomReport:{type:[String,Number],required:!0},metric:{type:String,required:!0},metricTranslation:String,metricDocumentation:String,lowerIsBetter:Boolean},components:{Sparkline:p["Sparkline"]},setup(e){const t=Object(s["ref"])(!1),i=Object(s["ref"])(null);function o(t){if(!t)return null;const i=Array.isArray(t)?t[0]:t;if(!i)return 0;const o=void 0!==i[e.metric]?i[e.metric]:i.value;return o||0}const r=Object(s["computed"])(()=>i.value?o(i.value[1]):null),n=Object(s["computed"])(()=>i.value?o(i.value[2]):null),a=Object(s["computed"])(()=>{if(null===r.value||null===n.value||r.value===n.value)return[];const t=r.value>n.value,i=e.lowerIsBetter?!t:t;return[t?"evolution-up":"evolution-down",i?"positive-evolution":"negative-evolution"]}),l=Object(s["computed"])(()=>{if(null===r.value||void 0===r.value||null===n.value||void 0===n.value)return null;const e="string"===typeof r.value?parseFloat(r.value):r.value,t="string"===typeof n.value?parseFloat(n.value):n.value,i=p["Matomo"].helper.calculateEvolution(e,t);return(100*i).toFixed(2)+" %"}),c=Object(s["computed"])(()=>i.value?o(i.value[3]):null),d=Object(s["computed"])(()=>i.value?o(i.value[0]):null),m=Object(s["computed"])(()=>p["Matomo"].startDateString===p["Matomo"].endDateString?p["Matomo"].endDateString:`${p["Matomo"].startDateString}, ${p["Matomo"].endDateString}`),h=Object(s["computed"])(()=>({module:"CustomReports",action:"getCustomReport",idCustomReport:e.idCustomReport,columns:e.metric})),b=Object(s["computed"])(()=>{if("range"!==p["Matomo"].period)return u()});function v(){const e=p["Range"].getLastNRange(p["Matomo"].period,2,p["Matomo"].currentDateString);return Object(p["format"])(e.startDate)}function g(){t.value=!0;const o="CustomReports.getCustomReport",r={idCustomReport:e.idCustomReport,columns:e.metric},s=[];return s.push(p["AjaxHelper"].fetch(Object.assign({method:o,format_metrics:"all"},r))),"range"!==p["Matomo"].period&&(s.push(p["AjaxHelper"].fetch(Object.assign({method:o,format_metrics:"0"},r))),s.push(p["AjaxHelper"].fetch(Object.assign({method:o,date:v(),format_metrics:"0"},r))),s.push(p["AjaxHelper"].fetch(Object.assign({method:o,date:v(),format_metrics:"all"},r)))),Promise.all(s).then(e=>{i.value=e,t.value=!1})}return g(),{isLoading:t,metricValue:d,metricValueUnformatted:r,pastValueUnformatted:n,evolutionClass:a,metricChangePercent:l,pastValue:c,sparklineParams:h,pastPeriod:b,currentPeriod:m}}});m.render=d;var h=m;const b={class:"loadingPiwik"},v=Object(s["createElementVNode"])("img",{src:"plugins/Morpheus/images/loading-blue.gif"},null,-1),g={class:"loadingPiwik"},j=Object(s["createElementVNode"])("img",{src:"plugins/Morpheus/images/loading-blue.gif"},null,-1),O={class:"alert alert-warning"},f={key:0},R={key:1},y={key:0,class:"alert alert-warning"},C=["innerHTML"],S={key:1},N={name:"name"},E={name:"description"},k={class:"form-group row"},V={class:"col s12"},D={class:"col s12 m6"},w={for:"all_websites",class:"siteSelectorLabel"},_={class:"sites_autocomplete"},M={class:"col s12 m6"},A={class:"form-help"},B={key:0,class:"inline-help"},T={key:1,class:"inline-help"},x={key:0,class:"col s12 m6"},I={key:0},L={for:"websitecontains"},U=Object(s["createElementVNode"])("br",null,null,-1),P=["placeholder"],H=["disabled","value"],G={key:1},F=Object(s["createElementVNode"])("br",null,null,-1),q={class:"entityTable"},W={class:"siteId"},z={class:"siteName"},Y={key:0,class:"siteAction"},K={colspan:"3"},Q={key:0,class:"siteAction"},X=["onClick"],J={class:"form-group row"},Z={class:"col s12"},ee={class:"unlockAlert alert alert-info"},te={key:0},ie={key:1},oe=Object(s["createElementVNode"])("br",null,null,-1),re=Object(s["createElementVNode"])("br",null,null,-1),se=["value"],ne={class:"alertUnlocked alert alert-warning"},ae={key:0},le={key:1},ce={name:"reportType"},de={class:"form-group row"},pe={class:"col s12 m6 dimensionsGroup"},ue=Object(s["createElementVNode"])("br",null,null,-1),me={class:"groupValueSelect",name:"dimensions"},he=["title","onClick"],be={class:"groupValueSelect addDimension",name:"dimensions"},ve={class:"col s12 m6"},ge={class:"form-help"},je=["innerHTML"],Oe={class:"form-group row"},fe={class:"col s12 m6 metricsGroup"},Re=Object(s["createElementVNode"])("br",null,null,-1),ye={class:"groupValueSelect",name:"metrics"},Ce=["title","onClick"],Se={class:"groupValueSelect addMetric",name:"metrics"},Ne={class:"col s12 m6"},Ee={class:"form-help"},ke={class:"inline-help"},Ve=["innerHTML"],De={class:"form-group row segmentFilterGroup"},we={class:"col s12"},_e={style:{margin:"8px 0",display:"inline-block"}},Me={class:"form-group row"},Ae={class:"col s12"},Be=Object(s["createElementVNode"])("br",null,null,-1),Te=Object(s["createElementVNode"])("br",null,null,-1),xe={name:"reportCategories"},Ie={name:"reportSubcategories"},Le={class:"alert alert-warning"},Ue={key:0},Pe={key:1},He={class:"alert alert-warning"},Ge={key:0},Fe={key:1},qe={key:0,class:"form-group row"},$e=["textContent"],We={class:"col s12 m6"},ze=Object(s["createElementVNode"])("span",{class:"icon-menu-hamburger drag-icon"},null,-1),Ye={class:"col s12 m6"},Ke={class:"form-help"},Qe=["textContent"],Xe={class:"entityCancel"},Je={class:"ui-confirm",id:"confirmUnlockReport",ref:"confirmUnlockReport"},Ze={key:0},et={key:1},tt=["value"],it=["value"],ot={class:"ui-confirm",id:"infoReportIsLocked",ref:"infoReportIsLocked"},rt={key:0},st={key:1},nt=["value"],at=["value"];function lt(e,t,i,o,r,n){const a=Object(s["resolveComponent"])("Field"),l=Object(s["resolveComponent"])("SiteSelector"),c=Object(s["resolveComponent"])("SegmentGenerator"),d=Object(s["resolveComponent"])("SaveButton"),p=Object(s["resolveComponent"])("DraggableList"),u=Object(s["resolveComponent"])("ContentBlock");return Object(s["openBlock"])(),Object(s["createBlock"])(u,{class:"editReport","content-title":e.contentTitle},{default:Object(s["withCtx"])(()=>{var i,o,r;return[Object(s["withDirectives"])(Object(s["createElementVNode"])("p",null,[Object(s["createElementVNode"])("span",b,[v,Object(s["createTextVNode"])(" "+Object(s["toDisplayString"])(e.translate("General_LoadingData")),1)])],512),[[s["vShow"],e.isLoading]]),Object(s["withDirectives"])(Object(s["createElementVNode"])("p",null,[Object(s["createElementVNode"])("span",g,[j,Object(s["createTextVNode"])(" "+Object(s["toDisplayString"])(e.translate("CustomReports_UpdatingData")),1)])],512),[[s["vShow"],e.isUpdating]]),Object(s["withDirectives"])(Object(s["createElementVNode"])("div",O,[e.multipleSites.length&&!e.report.allowedToEdit?(Object(s["openBlock"])(),Object(s["createElementBlock"])("span",f,Object(s["toDisplayString"])(e.translate("CustomReports_ReportEditNotAllowedMultipleWebsitesAccessIssue")),1)):(Object(s["openBlock"])(),Object(s["createElementBlock"])("span",R,Object(s["toDisplayString"])(e.translate("CustomReports_ReportEditNotAllowedAllWebsitesUpdated")),1))],512),[[s["vShow"],!e.canEdit]]),"paused"===e.report.status?(Object(s["openBlock"])(),Object(s["createElementBlock"])("div",y,[e.report.allowedToEdit?(Object(s["openBlock"])(),Object(s["createElementBlock"])("span",{key:0,innerHTML:e.getPausedStateAdminMessage},null,8,C)):(Object(s["openBlock"])(),Object(s["createElementBlock"])("span",S,Object(s["toDisplayString"])(e.translate("CustomReports_NoDataMessagePausedStateNonAdminUser")),1))])):Object(s["createCommentVNode"])("",!0),Object(s["createElementVNode"])("form",{onSubmit:t[15]||(t[15]=t=>e.edit?e.updateReport():e.createReport())},[Object(s["createElementVNode"])("div",null,[Object(s["createElementVNode"])("div",N,[Object(s["createVNode"])(a,{uicontrol:"text",name:"name","model-value":e.report.name,"onUpdate:modelValue":t[0]||(t[0]=t=>{e.report.name=t,e.setValueHasChanged()}),title:e.translate("General_Name"),maxlength:50,disabled:!e.canEdit,placeholder:e.translate("CustomReports_FieldNamePlaceholder"),"inline-help":e.translate("CustomReports_ReportNameHelpText")},null,8,["model-value","title","disabled","placeholder","inline-help"])]),Object(s["createElementVNode"])("div",E,[Object(s["createVNode"])(a,{uicontrol:"textarea",name:"description","model-value":e.report.description,"onUpdate:modelValue":t[1]||(t[1]=t=>{e.report.description=t,e.setValueHasChanged()}),title:e.translate("General_Description")+" (optional)",maxlength:1e3,disabled:!e.canEdit,"ui-control-attributes":{class:"compact-textarea"},placeholder:e.translate("CustomReports_FieldDescriptionPlaceholder"),"inline-help":e.getDescriptionHelpText},null,8,["model-value","title","disabled","placeholder","inline-help"])]),Object(s["createElementVNode"])("div",k,[Object(s["createElementVNode"])("h3",V,Object(s["toDisplayString"])(e.translate("CustomReports_ApplyTo")),1),Object(s["createElementVNode"])("div",D,[Object(s["createElementVNode"])("div",null,[Object(s["createElementVNode"])("label",w,Object(s["toDisplayString"])(e.translate("General_Website")),1),Object(s["createElementVNode"])("div",_,[Object(s["createVNode"])(l,{id:"all_websites","model-value":e.report.site,"onUpdate:modelValue":t[2]||(t[2]=t=>{e.report.site=t,e.setWebsiteChanged(t)}),"show-all-sites-item":e.isSuperUser,"switch-site-on-select":!1,"show-selected-site":!0},null,8,["model-value","show-all-sites-item"])])])]),Object(s["createElementVNode"])("div",M,[Object(s["createElementVNode"])("div",A,[e.isSuperUser?(Object(s["openBlock"])(),Object(s["createElementBlock"])("span",B,Object(s["toDisplayString"])(e.translate("CustomReports_ReportAllWebsitesHelp")),1)):(Object(s["openBlock"])(),Object(s["createElementBlock"])("span",T,Object(s["toDisplayString"])(e.translate("CustomReports_ReportAllWebsitesNonSuperUserHelp")),1))])]),"all"!==e.report.site.id&&"0"!==e.report.site.id&&0!==e.report.site.id?(Object(s["openBlock"])(),Object(s["createElementBlock"])("div",x,[e.report.allowedToEdit?(Object(s["openBlock"])(),Object(s["createElementBlock"])("div",I,[Object(s["createElementVNode"])("span",L,Object(s["toDisplayString"])(e.translate("CustomReports_SelectMeasurablesMatchingSearch")),1),U,Object(s["withDirectives"])(Object(s["createElementVNode"])("input",{class:"control_text customReportSearchMeasurablesField",type:"text",id:"websitecontains","onUpdate:modelValue":t[3]||(t[3]=t=>e.containsText=t),placeholder:e.translate("General_Search")},null,8,P),[[s["vModelText"],e.containsText]]),Object(s["createElementVNode"])("input",{style:{"margin-left":"3.5px"},disabled:!e.containsText,class:"btn customReportSearchFindMeasurables",type:"button",onClick:t[4]||(t[4]=t=>e.addSitesContaining(e.containsText)),value:e.translate("CustomReports_FindMeasurables")},null,8,H)])):Object(s["createCommentVNode"])("",!0),e.report.allowedToEdit||e.multipleSites.length?(Object(s["openBlock"])(),Object(s["createElementBlock"])("div",G,[F,Object(s["createElementVNode"])("table",q,[Object(s["createElementVNode"])("thead",null,[Object(s["createElementVNode"])("tr",null,[Object(s["createElementVNode"])("th",W,Object(s["toDisplayString"])(e.translate("General_Id")),1),Object(s["createElementVNode"])("th",z,Object(s["toDisplayString"])(e.translate("General_Name")),1),e.report.allowedToEdit?(Object(s["openBlock"])(),Object(s["createElementBlock"])("th",Y,Object(s["toDisplayString"])(e.translate("General_Remove")),1)):Object(s["createCommentVNode"])("",!0)])]),Object(s["createElementVNode"])("tbody",null,[Object(s["withDirectives"])(Object(s["createElementVNode"])("tr",null,[Object(s["createElementVNode"])("td",K,Object(s["toDisplayString"])(e.translate("CustomReports_NoMeasurableAssignedYet")),1)],512),[[s["vShow"],!e.multipleSites.length]]),(Object(s["openBlock"])(!0),Object(s["createElementBlock"])(s["Fragment"],null,Object(s["renderList"])(e.multipleSites,(t,i)=>Object(s["withDirectives"])((Object(s["openBlock"])(),Object(s["createElementBlock"])("tr",{key:i,class:Object(s["normalizeClass"])({selectedSiteRow:e.isSelectedSiteRow(t)})},[Object(s["createElementVNode"])("td",null,Object(s["toDisplayString"])(t.idsite),1),Object(s["createElementVNode"])("td",null,Object(s["toDisplayString"])(t.name),1),e.report.allowedToEdit?(Object(s["openBlock"])(),Object(s["createElementBlock"])("td",Q,[e.canRemoveSite(t)?(Object(s["openBlock"])(),Object(s["createElementBlock"])("span",{key:0,class:"icon-minus table-action",onClick:i=>e.removeSite(t)},null,8,X)):Object(s["createCommentVNode"])("",!0)])):Object(s["createCommentVNode"])("",!0)],2)),[[s["vShow"],e.multipleSites.length>0]])),128))])])])):Object(s["createCommentVNode"])("",!0)])):Object(s["createCommentVNode"])("",!0)]),Object(s["createElementVNode"])("div",J,[Object(s["createElementVNode"])("h3",Z,Object(s["toDisplayString"])(e.translate("CustomReports_ReportContent")),1)]),Object(s["withDirectives"])(Object(s["createElementVNode"])("div",ee,[e.browserArchivingDisabled&&e.reArchiveLastN?(Object(s["openBlock"])(),Object(s["createElementBlock"])("span",te,Object(s["toDisplayString"])(e.translate("CustomReports_WarningRequiresUnlockBrowserArchivingDisabled",e.reArchiveLastN)),1)):Object(s["createCommentVNode"])("",!0),e.browserArchivingDisabled&&e.reArchiveLastN?Object(s["createCommentVNode"])("",!0):(Object(s["openBlock"])(),Object(s["createElementBlock"])("span",ie,Object(s["toDisplayString"])(e.translate("CustomReports_WarningRequiresUnlock")),1)),oe,re,Object(s["createElementVNode"])("input",{type:"button",class:"btn unlockReport",onClick:t[5]||(t[5]=t=>e.unlockReport()),value:e.translate("CustomReports_Unlock")},null,8,se)],512),[[s["vShow"],e.isLocked]]),Object(s["withDirectives"])(Object(s["createElementVNode"])("div",ne,[e.browserArchivingDisabled&&e.reArchiveLastN?(Object(s["openBlock"])(),Object(s["createElementBlock"])("span",ae,Object(s["toDisplayString"])(e.translate("CustomReports_WarningOnUpdateReportMightGetLostBrowserArchivingDisabled",e.reArchiveLastN)),1)):Object(s["createCommentVNode"])("",!0),e.browserArchivingDisabled&&e.reArchiveLastN?Object(s["createCommentVNode"])("",!0):(Object(s["openBlock"])(),Object(s["createElementBlock"])("span",le,Object(s["toDisplayString"])(e.translate("CustomReports_WarningOnUpdateReportMightGetLost")),1))],512),[[s["vShow"],e.isUnlocked]]),Object(s["createElementVNode"])("div",ce,[Object(s["createVNode"])(a,{uicontrol:"radio",name:"reportType","model-value":e.report.report_type,"onUpdate:modelValue":t[6]||(t[6]=t=>e.setReportTypeHasChanged(t)),title:e.translate("CustomReports_ReportType"),disabled:!e.canEdit,options:e.reportTypes},null,8,["model-value","title","disabled","options"])]),Object(s["withDirectives"])(Object(s["createElementVNode"])("div",de,[Object(s["createElementVNode"])("div",pe,[Object(s["createElementVNode"])("label",null,Object(s["toDisplayString"])(e.translate("CustomReports_Dimensions")),1),ue,Object(s["createElementVNode"])("div",null,[(Object(s["openBlock"])(!0),Object(s["createElementBlock"])(s["Fragment"],null,Object(s["renderList"])(e.report.dimensions,(t,i)=>(Object(s["openBlock"])(),Object(s["createElementBlock"])("div",{class:Object(s["normalizeClass"])("selectedDimension selectedDimension"+i),key:i},[Object(s["createElementVNode"])("div",me,[Object(s["createVNode"])(a,{uicontrol:"expandable-select",name:"dimensions","model-value":t,"onUpdate:modelValue":t=>e.changeDimension(t,i),title:e.dimensionsReadable[t]||t,"full-width":!0,options:e.dimensions},null,8,["model-value","onUpdate:modelValue","title","options"])]),Object(s["createElementVNode"])("span",{class:"icon-minus",title:e.translate("CustomReports_RemoveDimension"),onClick:t=>e.removeDimension(i)},null,8,he)],2))),128)),Object(s["withDirectives"])(Object(s["createElementVNode"])("div",be,[Object(s["createVNode"])(a,{uicontrol:"expandable-select",name:"dimensions","model-value":"","onUpdate:modelValue":t[7]||(t[7]=t=>{e.addDimension(t)}),title:e.translate("CustomReports_AddDimension"),"full-width":!0,options:e.dimensions},null,8,["title","options"])],512),[[s["vShow"],e.report.dimensions.length(Object(s["openBlock"])(),Object(s["createElementBlock"])("div",{class:Object(s["normalizeClass"])("selectedMetric selectedMetric"+i),key:i},[Object(s["createElementVNode"])("div",ye,[Object(s["createVNode"])(a,{uicontrol:"expandable-select",name:"metrics","model-value":t,"onUpdate:modelValue":t=>e.changeMetric(t,i),title:e.metricsReadable[t]||t,"full-width":!0,options:e.metrics},null,8,["model-value","onUpdate:modelValue","title","options"])]),e.isSingleMetricType?Object(s["createCommentVNode"])("",!0):(Object(s["openBlock"])(),Object(s["createElementBlock"])("span",{key:0,class:"icon-minus",title:e.translate("CustomReports_RemoveMetric"),onClick:t=>e.removeMetric(i)},null,8,Ce))],2))),128)),Object(s["withDirectives"])(Object(s["createElementVNode"])("div",Se,[Object(s["createVNode"])(a,{uicontrol:"expandable-select",name:"metrics","model-value":"","onUpdate:modelValue":t[8]||(t[8]=t=>{e.addMetric(t)}),title:e.translate("CustomReports_AddMetric"),"full-width":!0,options:e.metrics},null,8,["title","options"])],512),[[s["vShow"],!e.isSingleMetricType]])])]),Object(s["createElementVNode"])("div",Ne,[Object(s["createElementVNode"])("div",Ee,[Object(s["createElementVNode"])("span",ke,Object(s["toDisplayString"])(e.translate(e.isSingleMetricType?"CustomReports_ReportSingleMetricHelp":"CustomReports_ReportMetricsHelp")),1)])])]),Object(s["withDirectives"])(Object(s["createElementVNode"])("div",{class:"alert alert-warning",innerHTML:e.getProductRevenueDependencyMessage},null,8,Ve),[[s["vShow"],e.dependencyAdded]]),Object(s["createElementVNode"])("div",De,[Object(s["createElementVNode"])("div",we,[Object(s["createElementVNode"])("div",null,[Object(s["createElementVNode"])("label",_e,Object(s["toDisplayString"])(e.translate("CustomReports_Filter")),1),Object(s["createElementVNode"])("p",null,Object(s["toDisplayString"])(e.translate("CustomReports_ReportSegmentHelp")),1),Object(s["createElementVNode"])("div",null,[Object(s["createVNode"])(c,{"model-value":e.report.segment_filter,"onUpdate:modelValue":t[9]||(t[9]=t=>e.setSegmentFilterHasChanged(t)),idsite:e.report.site.id},null,8,["model-value","idsite"])])])])]),Object(s["withDirectives"])(Object(s["createElementVNode"])("div",Me,[Object(s["createElementVNode"])("div",Ae,[Be,Te,Object(s["createVNode"])(d,{class:"showPreviewButton",disabled:!(null!==(i=e.report.metrics)&&void 0!==i&&i.length)||!(null!==(o=e.report.dimensions)&&void 0!==o&&o.length),onConfirm:t[10]||(t[10]=t=>e.showPreview()),value:e.translate("CustomReports_PreviewReport")},null,8,["disabled","value"])])],512),[[s["vShow"],"table"===e.report.report_type]]),Object(s["createElementVNode"])("div",xe,[Object(s["createVNode"])(a,{uicontrol:"select",name:"reportCategories","model-value":e.report.category.id,"onUpdate:modelValue":t[11]||(t[11]=t=>{e.report.category.id=t,e.setValueHasChanged()}),title:e.translate("CustomReports_ReportCategory"),disabled:!e.canEdit,options:e.categories,introduction:e.translate("CustomReports_ReportPage"),"inline-help":e.translate("CustomReports_ReportCategoryHelp")},null,8,["model-value","title","disabled","options","introduction","inline-help"])]),Object(s["createElementVNode"])("div",Ie,[Object(s["createVNode"])(a,{uicontrol:"select",name:"reportSubcategories","model-value":null===(r=e.report.subcategory)||void 0===r?void 0:r.id,"onUpdate:modelValue":t[12]||(t[12]=t=>{e.setSubcategory(t),e.setValueHasChanged()}),title:e.translate("CustomReports_ReportSubcategory"),disabled:!e.canEdit,options:e.subcategories[e.report.category.id],"inline-help":e.translate("CustomReports_ReportSubcategoryHelp")},null,8,["model-value","title","disabled","options","inline-help"])]),Object(s["withDirectives"])(Object(s["createElementVNode"])("div",Le,[e.browserArchivingDisabled&&e.reArchiveLastN?(Object(s["openBlock"])(),Object(s["createElementBlock"])("span",Ue,Object(s["toDisplayString"])(e.translate("CustomReports_WarningOnUpdateReportMightGetLostBrowserArchivingDisabled",e.reArchiveLastN)),1)):Object(s["createCommentVNode"])("",!0),e.browserArchivingDisabled&&e.reArchiveLastN?Object(s["createCommentVNode"])("",!0):(Object(s["openBlock"])(),Object(s["createElementBlock"])("span",Pe,Object(s["toDisplayString"])(e.translate("CustomReports_WarningOnUpdateReportMightGetLost")),1))],512),[[s["vShow"],e.isUnlocked]]),Object(s["withDirectives"])(Object(s["createElementVNode"])("div",He,[e.multipleSites.length&&!e.report.allowedToEdit?(Object(s["openBlock"])(),Object(s["createElementBlock"])("span",Ge,Object(s["toDisplayString"])(e.translate("CustomReports_ReportEditNotAllowedMultipleWebsitesAccessIssue")),1)):(Object(s["openBlock"])(),Object(s["createElementBlock"])("span",Fe,Object(s["toDisplayString"])(e.translate("CustomReports_ReportEditNotAllowedAllWebsitesUpdated")),1))],512),[[s["vShow"],!e.canEdit]]),e.childReports.length?(Object(s["openBlock"])(),Object(s["createElementBlock"])("div",qe,[Object(s["createElementVNode"])("h3",{class:"col s12",textContent:Object(s["toDisplayString"])(e.translate("CustomReports_OrderSubCategoryReports"))},null,8,$e),Object(s["createElementVNode"])("div",We,[Object(s["createVNode"])(p,{items:e.childReports,"item-key":"idcustomreport",onReorder:e.onChildReportsReorder},{default:Object(s["withCtx"])(({item:e})=>[ze,Object(s["createElementVNode"])("span",null,Object(s["toDisplayString"])(e.name),1)]),_:1},8,["items","onReorder"])]),Object(s["createElementVNode"])("div",Ye,[Object(s["createElementVNode"])("div",Ke,[Object(s["createElementVNode"])("div",{class:"form-description",textContent:Object(s["toDisplayString"])(e.translate("CustomReports_OrderSubCategoryReportsDescription"))},null,8,Qe)])])])):Object(s["createCommentVNode"])("",!0),Object(s["withDirectives"])(Object(s["createVNode"])(d,{class:"createButton",onConfirm:t[13]||(t[13]=t=>e.edit?e.updateReport():e.createReport()),disabled:e.isUpdating||!e.isDirty,saving:e.isUpdating,value:e.saveButtonText},null,8,["disabled","saving","value"]),[[s["vShow"],e.canEdit]]),Object(s["createElementVNode"])("div",Xe,[Object(s["createElementVNode"])("a",{onClick:t[14]||(t[14]=t=>e.cancel())},Object(s["toDisplayString"])(e.translate("General_Cancel")),1)])])],32),Object(s["createElementVNode"])("div",Je,[e.browserArchivingDisabled&&e.reArchiveLastN?(Object(s["openBlock"])(),Object(s["createElementBlock"])("h2",Ze,Object(s["toDisplayString"])(e.translate("CustomReports_ConfirmUnlockReportBrowserArchivingDisabled",e.reArchiveLastN)),1)):Object(s["createCommentVNode"])("",!0),e.browserArchivingDisabled&&e.reArchiveLastN?Object(s["createCommentVNode"])("",!0):(Object(s["openBlock"])(),Object(s["createElementBlock"])("h2",et,Object(s["toDisplayString"])(e.translate("CustomReports_ConfirmUnlockReport")),1)),Object(s["createElementVNode"])("input",{role:"yes",type:"button",value:e.translate("General_Yes")},null,8,tt),Object(s["createElementVNode"])("input",{role:"no",type:"button",value:e.translate("General_No")},null,8,it)],512),Object(s["createElementVNode"])("div",ot,[e.browserArchivingDisabled&&e.reArchiveLastN?(Object(s["openBlock"])(),Object(s["createElementBlock"])("h2",rt,Object(s["toDisplayString"])(e.translate("CustomReports_InfoReportIsLockedBrowserArchivingDisabled",e.reArchiveLastN)),1)):Object(s["createCommentVNode"])("",!0),e.browserArchivingDisabled&&e.reArchiveLastN?Object(s["createCommentVNode"])("",!0):(Object(s["openBlock"])(),Object(s["createElementBlock"])("h2",st,Object(s["toDisplayString"])(e.translate("CustomReports_InfoReportIsLocked")),1)),Object(s["createElementVNode"])("input",{role:"unlock",type:"button",value:e.translate("CustomReports_Unlock")},null,8,nt),Object(s["createElementVNode"])("input",{role:"ok",type:"button",value:e.translate("General_Cancel")},null,8,at)],512)]}),_:1},8,["content-title"])}var ct=i("a5a2"),dt=i("f06f");const pt=["data-item-id","draggable","aria-grabbed","onDragstart","onDragover"];var ut=Object(s["defineComponent"])({__name:"DraggableList",props:{items:null,itemKey:null,disabled:{type:Boolean,default:!1},handle:{default:""},axis:{default:"y"}},emits:["reorder"],setup(e,{emit:t}){const i=e,o=.1,r=Object(s["ref"])([]),n=Object(s["ref"])(null),a=Object(s["ref"])(null),l=Object(s["ref"])(null),c=Object(s["ref"])(!1),d=Object(s["computed"])(()=>!i.disabled&&i.items.length>1);function p(e,t){if("function"===typeof i.itemKey)return i.itemKey(e,t);if(!e||"object"!==typeof e)return t;const o=e[i.itemKey];return"string"===typeof o||"number"===typeof o?o:t}const u=Object(s["computed"])(()=>i.items.map((e,t)=>({id:String(p(e,t)),item:e,sourceIndex:t}))),m=Object(s["computed"])(()=>u.value.map(e=>e.id).join("\0"));function h(){r.value=u.value.slice()}function b(){n.value=null,a.value=null,l.value=null}function v(e=!1){b(),c.value=!1,e&&h()}function g(e,t){if(!i.handle)return!0;if(!(e instanceof Element))return!1;const o=e.closest(i.handle);return!!o&&t.contains(o)}function j(e){return r.value.findIndex(t=>t.id===e)}function O(e){return r.value["number"===typeof e?e:Number(e)]}function f(e,t){const r=t.getBoundingClientRect(),s=n.value?j(n.value):-1,l=a.value?j(a.value):-1,c=-1!==s&&-1!==l&&s{n.value===t&&(l.value=t)},0)):e.preventDefault()}function C(e,t){const i=O(t);i?y(e,i.id):e.preventDefault()}function S(e,t){if(!n.value||!d.value)return;e.preventDefault();const i=e.currentTarget;i&&(a.value=t,R(t,f(e,i)),e.dataTransfer&&(e.dataTransfer.dropEffect="move"))}function N(e,t){const i=O(t);i&&S(e,i.id)}function E(e){if(!n.value)return;e.preventDefault();const i=r.value.map(e=>e.id);i&&i.join("\0")!==m.value&&(c.value=!0,t("reorder",i)),b()}function k(){c.value?c.value=!1:v(!0)}return Object(s["watch"])([m,()=>i.disabled],()=>v(!0),{immediate:!0}),(t,i)=>(Object(s["openBlock"])(),Object(s["createElementBlock"])("ul",{class:Object(s["normalizeClass"])(["draggableList",{isDragging:null!==n.value,isDisabled:e.disabled}])},[(Object(s["openBlock"])(!0),Object(s["createElementBlock"])(s["Fragment"],null,Object(s["renderList"])(r.value,(e,i)=>(Object(s["openBlock"])(),Object(s["createElementBlock"])("li",{key:e.id,class:Object(s["normalizeClass"])(["draggableListItem",{isDragged:e.id===l.value}]),"data-item-id":e.id,draggable:Object(s["unref"])(d),"aria-grabbed":e.id===n.value,onDragstart:e=>C(e,i),onDragover:e=>N(e,i),onDrop:E,onDragend:k},[Object(s["renderSlot"])(t.$slots,"default",{item:e.item,index:e.sourceIndex})],42,pt))),128))],2))}}),mt=ut;function ht(e,t,i){return t in e?Object.defineProperty(e,t,{value:i,enumerable:!0,configurable:!0,writable:!0}):e[t]=i,e} +/** + * Copyright (C) InnoCraft Ltd - All rights reserved. + * + * NOTICE: All information contained herein is, and remains the property of InnoCraft Ltd. + * The intellectual and technical concepts contained herein are protected by trade secret + * or copyright law. Redistribution of this information or reproduction of this material is + * strictly forbidden unless prior written permission is obtained from InnoCraft Ltd. + * + * You shall use this code only in accordance with the license agreement obtained from + * InnoCraft Ltd. + * + * @link https://www.innocraft.com/ + * @license For license details see https://www.innocraft.com/license + */function bt(e){return[...new Set(e)].filter(e=>!!e)}function vt(e,t,i){const o=[];return e.forEach(e=>{e[t].forEach(t=>{o.push(Object.assign({group:e.category,key:t.uniqueId,value:t.name,tooltip:t.description||void 0},i?{[i]:t[i]}:{}))})}),o}const gt={key:"",value:""};class jt{constructor(){ht(this,"privateState",Object(s["reactive"])({reports:[],reportTypesReadable:{},dimensionsReadable:{},metricsReadable:{},categories:[],subcategories:{},isLoading:!1,isUpdating:!1,allMetrics:[],allDimensions:[]})),ht(this,"state",Object(s["computed"])(()=>Object(s["readonly"])(this.privateState))),ht(this,"fetchPromise",null),ht(this,"availableReportTypesPromise",null),ht(this,"dimensionsPromise",null),ht(this,"dimensionsIdsiteLoaded",0),ht(this,"metricsPromise",null),ht(this,"metricsIdsiteLoaded",0),ht(this,"categoriesPromise",null),ht(this,"categoriesIdsiteLoaded",null)}reload(){return this.privateState.reports=[],this.fetchPromise=null,this.fetchReports()}cleanupSegmentDefinition(e){let t=e;return t=t.replace("'","%27"),t=t.replace("&","%26"),t}getAvailableReportTypes(){return this.availableReportTypesPromise||(this.availableReportTypesPromise=p["AjaxHelper"].fetch({method:"CustomReports.getAvailableReportTypes",filter_limit:"-1"}).then(e=>{const t={};e.forEach(e=>{t[e.key]=e.value}),this.privateState.reportTypesReadable=t})),this.availableReportTypesPromise.then(()=>this.state.value.reportTypesReadable)}getAvailableDimensions(e){return this.dimensionsPromise&&this.dimensionsIdsiteLoaded===e||(this.dimensionsIdsiteLoaded=e,this.dimensionsPromise=p["AjaxHelper"].fetch({method:"CustomReports.getAvailableDimensions",filter_limit:"-1",idSite:e}).then(e=>{const t={};e.forEach(e=>{e.dimensions.forEach(e=>{t[e.uniqueId]=e.name})}),this.privateState.dimensionsReadable=t,this.privateState.allDimensions=vt(e,"dimensions","sqlSegment")})),this.dimensionsPromise.then(()=>this.state.value.dimensionsReadable)}getAvailableMetrics(e){return this.metricsPromise&&this.metricsIdsiteLoaded===e||(this.metricsIdsiteLoaded=e,this.metricsPromise=p["AjaxHelper"].fetch({method:"CustomReports.getAvailableMetrics",filter_limit:"-1",idSite:e}).then(e=>{const t={};e.forEach(e=>{e.metrics.forEach(e=>{t[e.uniqueId]=e.name})}),this.privateState.metricsReadable=t,this.privateState.allMetrics=vt(e,"metrics")})),this.metricsPromise.then(()=>this.state.value.metricsReadable)}getAvailableCategories(e){const t=e&&"all"!==e?e:p["Matomo"].idSite;return this.categoriesPromise&&this.categoriesIdsiteLoaded===e||(this.categoriesPromise=p["AjaxHelper"].fetch({method:"CustomReports.getAvailableCategories",filter_limit:"-1",idSite:t}).then(e=>{const t=[],i={};e.forEach(e=>{t.push({key:e.uniqueId,value:e.name}),e.subcategories.forEach(t=>{i[e.uniqueId]=i[e.uniqueId]||[gt],i[e.uniqueId].push({key:t.uniqueId,value:t.name})})}),this.privateState.categories=t,this.privateState.subcategories=i})),this.categoriesPromise}fetchReports(){return this.fetchPromise||(this.fetchPromise=p["AjaxHelper"].fetch({method:"CustomReports.getConfiguredReports",filter_limit:"-1"})),this.privateState.isLoading=!0,this.privateState.reports=[],this.fetchPromise.then(e=>(this.privateState.reports=e.map(e=>{var t,i;let o=void 0;return o=null!==e&&void 0!==e&&null!==(t=e.subcategory)&&void 0!==t&&t.id?e.subcategory.id:"CustomReports_CustomReports"===(null===e||void 0===e||null===(i=e.category)||void 0===i?void 0:i.id)?e.idcustomreport:e.name,Object.assign(Object.assign({},e),{},{linkIdSite:e.idsite?e.idsite:p["Matomo"].idSite,subcategoryLink:o})}),this.state.value.reports)).finally(()=>{this.privateState.isLoading=!1})}findReport(e,t){const i=this.state.value.reports.find(t=>parseInt(""+t.idcustomreport,10)===e);return i&&!t?Promise.resolve(i):(this.privateState.isLoading=!0,p["AjaxHelper"].fetch({idCustomReport:e,method:"CustomReports.getConfiguredReport"}).finally(()=>{this.privateState.isLoading=!1}))}deleteReport(e,t){return this.privateState.isUpdating=!0,this.privateState.reports=[],p["AjaxHelper"].fetch({idCustomReport:e,idSite:""+t,method:"CustomReports.deleteCustomReport"},{withTokenInUrl:!0}).then(()=>({type:"success"})).catch(e=>({type:"error",message:e.message||e})).finally(()=>{this.privateState.isUpdating=!1})}pauseReport(e,t){return this.privateState.isUpdating=!0,this.privateState.reports=[],p["AjaxHelper"].fetch({idCustomReport:e,idSite:""+t,method:"CustomReports.pauseCustomReport"},{withTokenInUrl:!0}).then(()=>({type:"success"})).catch(e=>({type:"error",message:e.message||e})).finally(()=>{this.privateState.isUpdating=!1})}resumeReport(e,t){return this.privateState.isUpdating=!0,this.privateState.reports=[],p["AjaxHelper"].fetch({idCustomReport:e,idSite:""+t,method:"CustomReports.resumeCustomReport"},{withTokenInUrl:!0}).then(()=>({type:"success"})).catch(e=>({type:"error",message:e.message||e})).finally(()=>{this.privateState.isUpdating=!1})}createOrUpdateReport(e,t,i,o){var r,s;return this.privateState.isUpdating=!0,p["AjaxHelper"].post({method:t,idCustomReport:e.idcustomreport,reportType:e.report_type,name:e.name.trim(),description:e.description.trim(),segmentFilter:encodeURIComponent(e.segment_filter),categoryId:null===(r=e.category)||void 0===r?void 0:r.id,subcategoryId:null===(s=e.subcategory)||void 0===s?void 0:s.id,idSite:e.site.id,subCategoryReportIds:i,multipleIdSites:o},{dimensionIds:bt(e.dimensions),metricIds:bt(e.metrics)},{withTokenInUrl:!0}).then(e=>({type:"success",response:e})).catch(e=>({type:"error",message:e.message||e})).finally(()=>{this.privateState.isUpdating=!1})}}var Ot=new jt;const ft="reportsmanagement",Rt="reportsmanagementProductMetric",yt=[["UserCountry.Continent","UserCountry.Country"]];function Ct(e){return[...new Set(e)].filter(e=>!!e)}function St(e,t){return yt.some(i=>i.includes(t)&&e.some(e=>e!==t&&i.includes(e)))}function Nt(){return{dimensions:[],site:{id:p["Matomo"].idSite,name:p["Matomo"].currentSiteName},category:{}}}var Et=Object(s["defineComponent"])({props:{idCustomReport:Number,browserArchivingDisabled:Boolean,reArchiveLastN:Number,maxDimensions:Number,isCloud:Boolean},components:{ContentBlock:p["ContentBlock"],DraggableList:mt,Field:ct["Field"],SiteSelector:p["SiteSelector"],SegmentGenerator:dt["SegmentGenerator"],SaveButton:ct["SaveButton"]},data(){return{isDirty:!1,report:Nt(),isLocked:!1,isUnlocked:!1,canEdit:!0,dependencyAdded:!1,childReports:[],childReportIds:[],containsText:"",multipleSites:[],multipleIdSites:[]}},created(){Ot.getAvailableReportTypes(),this.init()},watch:{idCustomReport(e){null!==e&&this.init()}},methods:{initReportOptions(){const e=parseInt(""+this.report.site.id,10)||"all";Ot.getAvailableDimensions(e),Ot.getAvailableMetrics(e),Ot.getAvailableCategories(e)},syncChildReportIds(e){const t=null!==e&&void 0!==e?e:this.childReports;this.childReportIds=t.map(e=>e.idcustomreport)},doUnlock(){this.isLocked=!1,this.isUnlocked=!0},confirmReportIsLocked(e){p["Matomo"].helper.modalConfirm(this.$refs.infoReportIsLocked,{unlock:()=>{this.doUnlock(),e&&e()}})},removeAnyReportNotification(){p["NotificationsStore"].remove(ft),p["NotificationsStore"].remove(Rt),p["NotificationsStore"].remove("ajaxHelper")},showApiErrorMessage(e,t){if(e&&t){this.removeAnyReportNotification();const i=document.createElement("textarea");i.innerHTML=e,this.showNotification(i.value,t,"toast")}},showNotification(e,t,i=null){const o=p["NotificationsStore"].show({message:e,context:t,id:ft,type:null!==i&&void 0!==i?i:"transient",prepend:!0});setTimeout(()=>{p["NotificationsStore"].scrollToNotification(o)},100)},showProductMetricNotification(e,t){const i=p["NotificationsStore"].show({message:e,context:"warning",id:Rt,type:"transient"});t&&setTimeout(()=>{p["NotificationsStore"].scrollToNotification(i)},100)},showErrorFieldNotProvidedNotification(e){const t=Object(p["translate"])("CustomReports_ErrorXNotProvided",[e]);this.showNotification(t,"error")},init(){const{idCustomReport:e}=this;this.canEdit=!0,this.report=Nt(),this.childReports=[],this.syncChildReportIds([]),this.multipleSites=[],p["Matomo"].helper.lazyScrollToContent(),this.edit&&e?Ot.findReport(e,!0).then(e=>{var t;if(!e)return;this.report=Object(p["clone"])(e),this.isLocked=!0,this.isUnlocked=!1,this.canEdit=!0,this.childReports=null!==(t=this.report.child_reports)&&void 0!==t?t:[],this.syncChildReportIds(),this.report.multipleIdSites&&this.report.multipleIdSites.length&&"all"!==this.report.site.id&&"0"!==this.report.site.id&&0!==this.report.site.id&&(this.multipleSites=this.report.multipleIdSites,this.report.allowedToEdit||(this.canEdit=!1,this.isLocked=!1));let i=this.report.idsite;0!==i&&"0"!==i&&"all"!==i||(i="all",this.isSuperUser||(this.canEdit=!1,this.isLocked=!1)),this.report.site={id:i,name:this.report.site.name},this.report.multipleIdSites&&this.report.multipleIdSites.length&&"all"!==i&&"0"!==i&&0!==i?this.multipleSites=this.report.multipleIdSites:"all"!==i&&"0"!==i&&0!==i&&(this.multipleSites=[{idsite:i,name:this.report.site.name}]),this.isDirty=!1,this.initReportOptions()}):this.create&&(this.report={idsite:p["Matomo"].idSite,site:{id:p["Matomo"].idSite,name:p["Matomo"].currentSiteName||p["Matomo"].siteName},name:"",description:"",dimensions:[],metrics:["nb_visits"],report_type:"table",category:{id:"CustomReports_CustomReports"},subcategory:null,segment_filter:"",child_reports:[],allowedToEdit:!0},this.multipleSites=[{idsite:this.report.site.id,name:this.report.site.name}],this.isLocked=!1,this.canEdit=!0,this.isDirty=!1,this.childReports=[],this.syncChildReportIds([]),this.initReportOptions())},onChildReportsReorder(e){const t=new Map(this.childReports.map(e=>[""+e.idcustomreport,e])),i=e.map(e=>t.get(e)).filter(e=>!!e);i.length===this.childReports.length&&(this.childReports=i,this.syncChildReportIds(i),this.setValueHasChanged())},cancel(){const e=Object.assign({},p["MatomoUrl"].hashParsed.value);delete e.idCustomReport,p["MatomoUrl"].updateHash(e)},unlockReport(){this.report&&this.isLocked&&p["Matomo"].helper.modalConfirm(this.$refs.confirmUnlockReport,{yes:()=>{this.doUnlock()}})},createReport(){const e="CustomReports.addCustomReport";this.removeAnyReportNotification(),this.checkRequiredFieldsAreSet()&&(this.multipleIdSites=[],this.multipleSites&&this.multipleSites.length&&"all"!==this.report.site.id&&"0"!==this.report.site.id&&0!==this.report.site.id&&(this.multipleIdSites.push(this.report.site.id),this.multipleSites.forEach(e=>{const t=e.idsite;this.multipleIdSites.some(e=>""+e===""+t)||this.multipleIdSites.push(t)})),Ot.createOrUpdateReport(this.report,e,this.childReportIds,this.multipleIdSites).then(e=>{var t,i;if(!e||"error"===e.type||!e.response)return void this.showApiErrorMessage(null!==(t=e.message)&&void 0!==t?t:"",null!==(i=e.type)&&void 0!==i?i:"error");this.isDirty=!1;const o=e.response.value;if(this.report.site){const e=this.report.site.id;if(e&&"all"!==e&&""+e!==""+p["Matomo"].idSite)return void p["MatomoUrl"].updateUrl(Object.assign(Object.assign({},p["MatomoUrl"].urlParsed.value),{},{idSite:e}),Object.assign(Object.assign({},p["MatomoUrl"].hashParsed.value),{},{idCustomReport:o}))}Ot.reload().then(()=>{p["Matomo"].helper.isReportingPage()&&p["Matomo"].postEvent("updateReportingMenu"),p["MatomoUrl"].updateHash(Object.assign(Object.assign({},p["MatomoUrl"].hashParsed.value),{},{idCustomReport:o})),setTimeout(()=>{this.showNotification(Object(p["translate"])("CustomReports_ReportCreated"),e.type)},200)})}))},showPreview(){var e,t,i;if(!this.isProductRevenueDependencyMet(!0))return;const o=null!==(e=this.report.site)&&void 0!==e&&e.id&&"all"!==this.report.site.id?this.report.site.id:p["Matomo"].idSite,r=(null===(t=this.report.dimensions)||void 0===t?void 0:t.length)&&this.report.report_type&&"evolution"!==this.report.report_type,s=r?this.report.dimensions.join(","):void 0,n=!(null===(i=this.report.metrics)||void 0===i||!i.length),a=n?this.report.metrics.join(","):void 0,l=p["MatomoUrl"].stringify({module:"CustomReports",action:"previewReport",period:"day",date:"today",idSite:o,report_type:this.report.report_type,dimensions:s,metrics:a,segment:this.report.segment_filter||void 0}),c=Object(p["translate"])("CustomReports_Preview");window.Piwik_Popover.createPopupAndLoadUrl(l,c,"customReportPreview")},setValueHasChanged(){this.isDirty=!0},addDimension(e){this.report&&e&&(this.isLocked?this.confirmReportIsLocked(()=>{this.addDimension(e)}):(this.report.dimensions||(this.report.dimensions=[]),this.report.dimensions=[...this.report.dimensions,e],this.setValueHasChanged()))},changeDimension(e,t){var i;this.report&&e&&(this.isLocked?this.confirmReportIsLocked(()=>{this.changeDimension(e,t)}):null!==(i=this.report.dimensions)&&void 0!==i&&i[t]&&(this.report.dimensions=[...this.report.dimensions],this.report.dimensions[t]=e,this.setValueHasChanged()))},changeMetric(e,t){var i;this.dependencyAdded=!1,this.report&&e&&(this.isLocked?this.confirmReportIsLocked(()=>{this.changeMetric(e,t)}):null!==(i=this.report.metrics)&&void 0!==i&&i[t]&&(this.report.metrics=[...this.report.metrics],this.report.metrics[t]=e,this.setValueHasChanged(),this.addMetricIfMissingDependency(e)))},setWebsiteChanged(e){this.setValueHasChanged(),this.initReportOptions(),"all"===this.report.site.id||"0"===this.report.site.id||0===this.report.site.id?this.multipleSites=[]:this.report.allowedToEdit&&!this.isSiteIncludedAlready(""+e.id)&&this.multipleSites&&this.multipleSites.push({idsite:e.id,name:e.name})},removeDimension(e){this.isLocked?this.confirmReportIsLocked(()=>{this.removeDimension(e)}):(window.$('div.ui-tooltip[role="tooltip"]:not([style*="display: none"])').remove(),e>-1&&(this.report.dimensions=[...this.report.dimensions],this.report.dimensions.splice(e,1),this.setValueHasChanged()))},addMetric(e){this.dependencyAdded=!1,this.report&&e&&(this.report.metrics||(this.report.metrics=[]),this.isLocked?this.confirmReportIsLocked(()=>{this.addMetric(e)}):(this.report.metrics=[...this.report.metrics,e],this.setValueHasChanged(),this.addMetricIfMissingDependency(e)))},addMetricIfMissingDependency(e){if(!["sum_product_revenue","avg_product_revenue"].includes(e)||this.doesReportIncludeProductQuantityMetric())return;const t="avg_product_revenue"===e?"avg_ecommerce_productquantity":"sum_ecommerce_productquantity";this.addMetric(t),this.dependencyAdded=!0},removeMetric(e){this.dependencyAdded=!1,this.isLocked?this.confirmReportIsLocked(()=>{this.removeMetric(e)}):(window.$('div.ui-tooltip[role="tooltip"]:not([style*="display: none"])').remove(),e>-1&&(this.report.metrics=[...this.report.metrics],this.report.metrics.splice(e,1),this.setValueHasChanged()))},setReportTypeHasChanged(e){this.report&&this.isLocked?e!==this.report.report_type&&this.confirmReportIsLocked(()=>{this.report.report_type=e,this.setValueHasChanged()}):(this.report.report_type=e,this.setValueHasChanged())},setSegmentFilterHasChanged(e){this.report&&this.isLocked?e!==this.report.segment_filter&&this.confirmReportIsLocked(()=>{this.report.segment_filter=e,this.setValueHasChanged()}):(this.report.segment_filter=e,this.setValueHasChanged())},updateReport(){if(this.removeAnyReportNotification(),!this.checkRequiredFieldsAreSet())return;const e=this.report.site.id,t="all"===e||"0"===e||0===e;this.multipleIdSites=[],this.multipleSites&&this.multipleSites.length>1&&!t&&(this.multipleIdSites.push(e),this.multipleSites.forEach(e=>{const t=e.idsite;this.multipleIdSites.some(e=>""+e===""+t)||this.multipleIdSites.push(t)}));const i=this.multipleIdSites.length?this.report.idsite:e,o="CustomReports.updateCustomReport";Ot.createOrUpdateReport(Object.assign(Object.assign({},this.report),{},{site:Object.assign(Object.assign({},this.report.site),{},{id:i})}),o,this.childReportIds,this.multipleIdSites).then(e=>{var t,i;if(!e||"error"===e.type)return void this.showApiErrorMessage(null!==(t=e.message)&&void 0!==t?t:"",null!==(i=e.type)&&void 0!==i?i:"error");const o=this.report.site.id,r=this.multipleIdSites.some(e=>""+e===""+p["Matomo"].idSite);this.isDirty=!1,this.canEdit=!0,!o||"all"===o||""+o===""+p["Matomo"].idSite||r?(Ot.reload().then(()=>{this.init()}),this.showNotification(Object(p["translate"])("CustomReports_ReportUpdated"),e.type)):p["MatomoUrl"].updateUrl(Object.assign(Object.assign({},p["MatomoUrl"].urlParsed.value),{},{idSite:o}),Object.assign({},p["MatomoUrl"].hashParsed.value))})},checkRequiredFieldsAreSet(){var e,t;if(!this.report.name){const e=Object(p["translate"])("General_Name");return this.showErrorFieldNotProvidedNotification(e),!1}if(this.typeNeedsDimensions&&(null===(t=this.report.dimensions)||void 0===t||!t.length||!Ct(this.report.dimensions).length)){const e=Object(p["translate"])("CustomReports_ErrorMissingDimension");return this.showNotification(e,"error"),!1}if(null===(e=this.report.metrics)||void 0===e||!e.length||!Ct(this.report.metrics).length){const e=Object(p["translate"])("CustomReports_ErrorMissingMetric");return this.showNotification(e,"error"),!1}return this.isProductRevenueDependencyMet(!1),!0},setSubcategory(e){this.report.subcategory=this.report.subcategory||{id:""},this.report.subcategory.id=e},isProductRevenueDependencyMet(e){const t=Object(p["externalLink"])("https://matomo.org/faq/custom-reports/why-is-there-an-error-when-i-try-to-run-a-custom-report-with-the-product-revenue-metric/"),i=Object(p["translate"])("CustomReports_WarningProductRevenueMetricDependency",t,"");return this.report.metrics.includes("sum_product_revenue")&&!this.doesReportIncludeProductQuantityMetric()?(this.addMetric("sum_ecommerce_productquantity"),this.showProductMetricNotification(i,e),!1):!(this.report.metrics.includes("avg_product_revenue")&&!this.doesReportIncludeProductQuantityMetric())||(this.addMetric("avg_ecommerce_productquantity"),this.showProductMetricNotification(i,e),!1)},doesReportIncludeProductQuantityMetric(){return this.report.metrics.includes("sum_ecommerce_productquantity")||this.report.metrics.includes("avg_ecommerce_productquantity")},isSiteIncludedAlready(e){return!(!this.multipleSites||!this.multipleSites.length)&&this.multipleSites.some(t=>""+t.idsite===""+e)},isSelectedSiteRow(e){return!!this.multipleSites&&this.multipleSites.length>1&&""+e.idsite===""+this.report.site.id},canRemoveSite(e){const t=""+e.idsite===""+this.report.idsite;return!(this.multipleSites&&this.multipleSites.length<=1)&&!(this.edit&&t)},removeSite(e){if(this.multipleSites){if(!this.canRemoveSite(e))return;const t=""+e.idsite===""+this.report.site.id,i=this.multipleSites.findIndex(t=>t.idsite===e.idsite);if(this.isDirty=!0,this.multipleSites=this.multipleSites.filter(t=>t.idsite!==e.idsite),t&&this.multipleSites.length){const e=i>0?this.multipleSites[i-1]:null,t=e||this.multipleSites[0];this.report.site={id:t.idsite,name:t.name},this.initReportOptions()}}},addSitesContaining(e){if(!e)return;const t=`"${p["Matomo"].helper.escape(p["Matomo"].helper.htmlEntities(e))}"`;p["AjaxHelper"].fetch({method:"SitesManager.getSitesWithAdminAccess",pattern:e,filter_limit:-1}).then(e=>{if(!e||!e.length){const e=`
\n

${Object(p["translate"])("CustomReports_MatchingSearchNotFound",t)}

\n \n
`;return void p["Matomo"].helper.modalConfirm(e)}const i=[],o=[];e.forEach(e=>{const t=window.vueSanitize(p["Matomo"].helper.htmlEntities(e.name)),r=`${t} (id ${parseInt(""+e.idsite,10)})
`;this.isSiteIncludedAlready(""+e.idsite)?o.push(r):i.push(r)});let r=Object(p["translate"])("CustomReports_MatchingSearchConfirmTitle",i.length);if(o.length){const e=Object(p["translate"])("CustomReports_MatchingSearchConfirmTitleAlreadyAdded",o.length);r+=` (${e})`}let s=`

${r}

\n ${Object(p["translate"])("CustomReports_MatchingSearchMatchedAdd",i.length,t)}:\n

`;if(s+=i.join(""),o.length){const e=Object(p["translate"])("CustomReports_MatchingSearchMatchedAlreadyAdded",o.length,t);s+=`
${e}:

${o.join("")}`}s+=`

\n \n
`,p["Matomo"].helper.modalConfirm(s,{yes:()=>{e.forEach(e=>{this.multipleSites&&(this.isSiteIncludedAlready(""+e.idsite)||(this.isDirty=!0,this.multipleSites.push({idsite:e.idsite,name:e.name})))}),this.containsText=""}})})}},computed:{isSuperUser(){return!!p["Matomo"].hasSuperUserAccess},allMetrics(){return Ot.state.value.allMetrics},metrics(){var e;const t=["pageviews_with_generation_time","avg_page_generation_time","max_actions_pagegenerationtime","sum_actions_pagegenerationtime"],i=((null===(e=this.report)||void 0===e?void 0:e.metrics)||[]).some(e=>t.indexOf(e)>=0),o=!i;return this.allMetrics.filter(e=>!!e&&!(o&&t.indexOf(e.key)>=0))},allDimensions(){return Ot.state.value.allDimensions},reportTypes(){return Ot.state.value.reportTypesReadable},isSingleMetricType(){var e;return"kpicard"===(null===(e=this.report)||void 0===e?void 0:e.report_type)},typeNeedsDimensions(){var e,t;return"evolution"!==(null===(e=this.report)||void 0===e?void 0:e.report_type)&&"kpicard"!==(null===(t=this.report)||void 0===t?void 0:t.report_type)},visibleMetrics(){var e;const t=(null===(e=this.report)||void 0===e?void 0:e.metrics)||[];return this.isSingleMetricType?t.slice(0,1):t},create(){return!this.idCustomReport},edit(){return!this.create},editTitle(){return this.create?"CustomReports_CreateNewReport":"CustomReports_EditReport"},contentTitle(){return Object(p["translate"])(this.editTitle,this.report.name?`"${this.report.name}"`:"")},categories(){return Ot.state.value.categories},subcategories(){return Ot.state.value.subcategories},dimensions(){var e;const t=[...this.allDimensions];if(null===(e=this.report)||void 0===e||!e.dimensions)return t;const i=-1!==this.report.dimensions.indexOf("Actions.PageGenerationTime"),o=this.report.dimensions.map(e=>{var i;return null===(i=t.find(t=>t.key===e))||void 0===i?void 0:i.sqlSegment}).filter(e=>!!e);for(let r=0;r-1&&!St(this.report.dimensions,e.key)&&-1===this.report.dimensions.indexOf(e.key)&&(t.splice(r,1),r-=1)}return t},isLoading(){return Ot.state.value.isLoading},isUpdating(){return Ot.state.value.isUpdating},dimensionsReadable(){return Ot.state.value.dimensionsReadable},metricsReadable(){return Ot.state.value.metricsReadable},saveButtonText(){return this.edit?Object(p["translate"])("CoreUpdater_UpdateTitle"):Object(p["translate"])("CustomReports_CreateNewReport")},getProductRevenueDependencyMessage(){const e=Object(p["externalLink"])("https://matomo.org/faq/custom-reports/why-is-there-an-error-when-i-try-to-run-a-custom-report-with-the-product-revenue-metric/");return Object(p["translate"])("CustomReports_WarningProductRevenueMetricDependency",e,"")},getPausedStateAdminMessage(){const e="?"+p["MatomoUrl"].stringify(Object.assign(Object.assign({},p["MatomoUrl"].urlParsed.value),{},{module:"CustomReports",action:"manage"}));return Object(p["translate"])("CustomReports_ReportInPausedStateAdmin",``,"")},getDescriptionHelpText(){const e=Object(p["externalLink"])("https://matomo.org/faq/custom-reports/how-to-add-an-information-icon-to-the-title-of-a-custom-report/");return Object(p["translate"])("CustomReports_ReportDescriptionHelpText",e,"")},getDimensionsHelpText(){const e=Object(p["translate"])("CustomReports_ReportDimensionsHelpNew"),t=this.getDimensionsHelpTextExtended;return t?`${e}

${t}`:e},getDimensionsHelpTextExtended(){if(this.isCloud)return"";const e=Object(p["externalLink"])("https://matomo.org/faq/custom-reports/faq_25655/");return Object(p["translate"])("CustomReports_ReportDimensionsHelpExtended",e,"")}}});Et.render=lt;var kt=Et;const Vt={class:"reportSearchFilter"},Dt={class:"index"},wt={class:"name"},_t={class:"description"},Mt={class:"reportType"},At={class:"reportCategory"},Bt={class:"action"},Tt={colspan:"7"},xt={class:"loadingPiwik"},It=Object(s["createElementVNode"])("img",{src:"plugins/Morpheus/images/loading-blue.gif"},null,-1),Lt={colspan:"7"},Ut=["id"],Pt={class:"index"},Ht={class:"name"},Gt=["title"],Ft=["title"],qt=["title"],$t=["title"],Wt=["title"],zt={class:"reportType"},Yt=["title"],Kt={key:0},Qt=["title","onClick"],Xt=["title","onClick"],Jt=["title","onClick"],Zt=["title","href"],ei={key:0},ti=["title","onClick"],ii={class:"tableActionBar"},oi=Object(s["createElementVNode"])("span",{class:"icon-add"},null,-1),ri={class:"ui-confirm",ref:"confirmDeleteReport"},si=["value"],ni=["value"],ai={class:"ui-confirm",ref:"confirmPauseReport"},li=["value"],ci=["value"],di={class:"ui-confirm",ref:"confirmResumeReport"},pi=["value"],ui=["value"];function mi(e,t,i,o,r,n){const a=Object(s["resolveComponent"])("Field"),l=Object(s["resolveComponent"])("EntityDuplicatorAction"),c=Object(s["resolveComponent"])("ContentBlock"),d=Object(s["resolveComponent"])("EntityDuplicatorModal"),p=Object(s["resolveDirective"])("content-table");return Object(s["openBlock"])(),Object(s["createElementBlock"])(s["Fragment"],null,[Object(s["createElementVNode"])("div",null,[Object(s["createVNode"])(c,{"content-title":e.translate("CustomReports_ManageReports"),feature:e.translate("CustomReports_ManageReports")},{default:Object(s["withCtx"])(()=>[Object(s["createElementVNode"])("p",null,Object(s["toDisplayString"])(e.translate("CustomReports_CustomReportIntroduction")),1),Object(s["createElementVNode"])("div",Vt,[Object(s["withDirectives"])(Object(s["createVNode"])(a,{uicontrol:"text",name:"reportSearch",title:e.translate("General_Search"),modelValue:e.searchFilter,"onUpdate:modelValue":t[0]||(t[0]=t=>e.searchFilter=t)},null,8,["title","modelValue"]),[[s["vShow"],e.reports.length>0]])]),Object(s["withDirectives"])((Object(s["openBlock"])(),Object(s["createElementBlock"])("table",null,[Object(s["createElementVNode"])("thead",null,[Object(s["createElementVNode"])("tr",null,[Object(s["createElementVNode"])("th",Dt,Object(s["toDisplayString"])(e.translate("General_Id")),1),Object(s["createElementVNode"])("th",wt,Object(s["toDisplayString"])(e.translate("General_Name")),1),Object(s["createElementVNode"])("th",_t,Object(s["toDisplayString"])(e.translate("General_Description")),1),Object(s["createElementVNode"])("th",Mt,Object(s["toDisplayString"])(e.translate("CustomReports_Type")),1),Object(s["createElementVNode"])("th",At,Object(s["toDisplayString"])(e.translate("CustomReports_Category")),1),Object(s["createElementVNode"])("th",Bt,Object(s["toDisplayString"])(e.translate("General_Actions")),1)])]),Object(s["createElementVNode"])("tbody",null,[Object(s["withDirectives"])(Object(s["createElementVNode"])("tr",null,[Object(s["createElementVNode"])("td",Tt,[Object(s["createElementVNode"])("span",xt,[It,Object(s["createTextVNode"])(" "+Object(s["toDisplayString"])(e.translate("General_LoadingData")),1)])])],512),[[s["vShow"],e.isLoading||e.isUpdating]]),Object(s["withDirectives"])(Object(s["createElementVNode"])("tr",null,[Object(s["createElementVNode"])("td",Lt,Object(s["toDisplayString"])(e.translate("CustomReports_NoCustomReportsFound")),1)],512),[[s["vShow"],!e.isLoading&&0==e.reports.length]]),(Object(s["openBlock"])(!0),Object(s["createElementBlock"])(s["Fragment"],null,Object(s["renderList"])(e.sortedReports,t=>{var i;return Object(s["openBlock"])(),Object(s["createElementBlock"])("tr",{id:"report"+t.idcustomreport,class:"customReports",key:t.idcustomreport},[Object(s["createElementVNode"])("td",Pt,Object(s["toDisplayString"])(t.idcustomreport),1),Object(s["createElementVNode"])("td",Ht,[Object(s["createTextVNode"])(Object(s["toDisplayString"])(t.name)+" ",1),Object(s["withDirectives"])(Object(s["createElementVNode"])("span",{class:"icon-locked",title:e.translate("CustomReports_ReportEditNotAllowedAllWebsitesUpdated")},null,8,Gt),[[s["vShow"],!t.idsite&&!e.isSuperUser]]),Object(s["withDirectives"])(Object(s["createElementVNode"])("span",{class:"icon-info2",title:e.translate("CustomReports_ReportAvailableToAllWebsites")},null,8,Ft),[[s["vShow"],!t.idsite&&e.isSuperUser]]),Object(s["withDirectives"])(Object(s["createElementVNode"])("span",{class:"icon-locked",title:e.translate("CustomReports_ReportEditNotAllowedMultipleWebsitesAccessIssue")},null,8,qt),[[s["vShow"],!t.allowedToEdit&&e.isMultiSiteReport(t)]]),Object(s["withDirectives"])(Object(s["createElementVNode"])("span",{class:"icon-info2",title:e.translate("CustomReports_ReportAvailableToMultipleWebsites")},null,8,$t),[[s["vShow"],t.allowedToEdit&&e.isMultiSiteReport(t)]])]),Object(s["createElementVNode"])("td",{class:"description",title:e.htmlEntities(t.description)},Object(s["toDisplayString"])(e.truncate(t.description.trim(),60)),9,Wt),Object(s["createElementVNode"])("td",zt,Object(s["toDisplayString"])(e.reportTypesReadable[t.report_type]),1),Object(s["createElementVNode"])("td",{class:"reportCategory",title:e.htmlEntities(t.category.name)},[Object(s["createTextVNode"])(Object(s["toDisplayString"])(e.truncate(t.category.name.trim(),60))+" ",1),null!==(i=t.subcategory)&&void 0!==i&&i.name?(Object(s["openBlock"])(),Object(s["createElementBlock"])("span",Kt," - "+Object(s["toDisplayString"])(e.truncate(t.subcategory.name.trim(),60)),1)):Object(s["createCommentVNode"])("",!0)],8,Yt),Object(s["createElementVNode"])("td",{class:Object(s["normalizeClass"])({action:!0,"duplicate-available":e.isEntityDuplicatorAvailable})},[Object(s["withDirectives"])(Object(s["createElementVNode"])("a",{class:"table-action icon-pause",title:e.translate("CustomReports_PauseReportInfo"),onClick:i=>e.pauseReport(t)},null,8,Qt),[[s["vShow"],(t.idsite&&!e.isMultiSiteReport(t)||t.allowedToEdit)&&"active"===t.status]]),Object(s["withDirectives"])(Object(s["createElementVNode"])("a",{class:"table-action icon-play",title:e.translate("CustomReports_ResumeReportInfo"),onClick:i=>e.resumeReport(t)},null,8,Xt),[[s["vShow"],(t.idsite&&!e.isMultiSiteReport(t)||t.allowedToEdit)&&"paused"===t.status]]),Object(s["createElementVNode"])("a",{class:"table-action icon-edit",title:e.translate("CustomReports_EditReport"),onClick:i=>e.editReport(t.idcustomreport)},null,8,Jt),Object(s["createElementVNode"])("a",{target:"_blank",class:"table-action icon-show",title:e.translate("CustomReports_ViewReportInfo"),href:e.getViewReportLink(t)},null,8,Zt),e.isEntityDuplicatorAvailable&&(t.idsite&&!e.isMultiSiteReport(t)||t.allowedToEdit)?(Object(s["openBlock"])(),Object(s["createElementBlock"])("span",ei,[Object(s["createVNode"])(l,{actionFormData:{idCustomReport:t.idcustomreport},modalStore:e.entityDuplicatorStore,isActionVisible:e.showDuplicatorAction,isActionEnabled:e.enableDuplicatorAction,tooltipTextOverrideDisabled:e.translate("CustomReports_QuotaReachedForX",e.translate("CustomReports_CustomReport"),e.translate("CustomReports_CustomReports")),extraClasses:["customreport-duplicate-action","customreport-"+t.idcustomreport]},null,8,["actionFormData","modalStore","isActionVisible","isActionEnabled","tooltipTextOverrideDisabled","extraClasses"])])):Object(s["createCommentVNode"])("",!0),Object(s["withDirectives"])(Object(s["createElementVNode"])("a",{class:"table-action icon-delete",title:e.translate("CustomReports_DeleteReportInfo"),onClick:i=>e.deleteReport(t)},null,8,ti),[[s["vShow"],t.idsite&&!e.isMultiSiteReport(t)||t.allowedToEdit]])],2)],8,Ut)}),128))])])),[[p]]),Object(s["createElementVNode"])("div",ii,[Object(s["createElementVNode"])("a",{class:"createNewReport",onClick:t[1]||(t[1]=t=>e.createReport())},[oi,Object(s["createTextVNode"])(" "+Object(s["toDisplayString"])(e.translate("CustomReports_CreateNewReport")),1)])])]),_:1},8,["content-title","feature"]),Object(s["createElementVNode"])("div",ri,[Object(s["createElementVNode"])("h2",null,Object(s["toDisplayString"])(e.translate("CustomReports_DeleteReportConfirm")),1),Object(s["createElementVNode"])("input",{role:"yes",type:"button",value:e.translate("General_Yes")},null,8,si),Object(s["createElementVNode"])("input",{role:"no",type:"button",value:e.translate("General_No")},null,8,ni)],512),Object(s["createElementVNode"])("div",ai,[Object(s["createElementVNode"])("h2",null,Object(s["toDisplayString"])(e.translate("CustomReports_PauseReportConfirm")),1),Object(s["createElementVNode"])("input",{role:"yes",type:"button",value:e.translate("General_Yes")},null,8,li),Object(s["createElementVNode"])("input",{role:"no",type:"button",value:e.translate("General_No")},null,8,ci)],512),Object(s["createElementVNode"])("div",di,[Object(s["createElementVNode"])("h2",null,Object(s["toDisplayString"])(e.translate("CustomReports_ResumeReportConfirm")),1),Object(s["createElementVNode"])("input",{role:"yes",type:"button",value:e.translate("General_Yes")},null,8,pi),Object(s["createElementVNode"])("input",{role:"no",type:"button",value:e.translate("General_No")},null,8,ui)],512)]),Object(s["createVNode"])(d,{modalStore:e.entityDuplicatorStore},null,8,["modalStore"])],64)} +/** + * Copyright (C) InnoCraft Ltd - All rights reserved. + * + * NOTICE: All information contained herein is, and remains the property of InnoCraft Ltd. + * The intellectual and technical concepts contained herein are protected by trade secret + * or copyright law. Redistribution of this information or reproduction of this material is + * strictly forbidden unless prior written permission is obtained from InnoCraft Ltd. + * + * You shall use this code only in accordance with the license agreement obtained from + * InnoCraft Ltd. + * + * @link https://www.innocraft.com/ + * @license For license details see https://www.innocraft.com/license + */function hi(e,t){return e&&e.length>t?e.substr(0,t-3)+"...":e}const bi=Object(p["useExternalPluginComponent"])("CoreHome","EntityDuplicatorModal"),vi=Object(p["useExternalPluginComponent"])("CoreHome","EntityDuplicatorAction");let gi=void 0;Object(p["importPluginUmd"])("CoreHome").then(e=>{gi=null===e||void 0===e?void 0:e.EntityDuplicatorStore});const ji="customreportmanagementlist";var Oi=Object(s["defineComponent"])({props:{},components:{ContentBlock:p["ContentBlock"],Field:ct["Field"],EntityDuplicatorModal:bi,EntityDuplicatorAction:vi},directives:{ContentTable:p["ContentTable"]},data(){return{searchFilter:"",showDuplicatorAction:!0,enableDuplicatorAction:!1,entityDuplicatorStore:"undefined"!==typeof gi?gi.buildStoreInstance("CustomReports_CustomReport",{method:"CustomReports.duplicateCustomReport",requiredFields:["idSite","idDestinationSites","idCustomReport"]}):void 0}},created(){Ot.getAvailableReportTypes(),Ot.fetchReports()},mounted(){if(this.checkIsAddingCustomReportsAllowed(),this.entityDuplicatorStore){const e=this.entityDuplicatorStore.adapter;e.onSuccessCallback=e=>new Promise(t=>{var i,o,r;const s=null===(i=e.additionalData)||void 0===i?void 0:i.idSite;return null!==(o=e.additionalData)&&void 0!==o&&o.idSite&&Array.isArray(null===(r=e.additionalData)||void 0===r?void 0:r.idDestinationSites)&&!e.additionalData.idDestinationSites.some(e=>+e===+s)?t():Ot.reload().then(()=>t())})}},methods:{createReport(){this.editReport(0)},checkIsAddingCustomReportsAllowed(){const e={isAllowed:!0};return p["Matomo"].postEvent("CustomReports.initAddCustomReport",e),this.enableDuplicatorAction=e&&!0===e.isAllowed,!this.enableDuplicatorAction},editReport(e){p["MatomoUrl"].updateHash(Object.assign(Object.assign({},p["MatomoUrl"].hashParsed.value),{},{idCustomReport:e}))},pauseReport(e){p["Matomo"].helper.modalConfirm(this.$refs.confirmPauseReport,{yes:()=>{Ot.pauseReport(e.idcustomreport,e.idsite).then(e=>{e&&"error"!==e.type?(Ot.reload().then(()=>{this.showNotification(this.translate("CustomReports_PausedReport"),"success")}),p["Matomo"].postEvent("updateReportingMenu")):Ot.reload()})}})},resumeReport(e){p["Matomo"].helper.modalConfirm(this.$refs.confirmResumeReport,{yes:()=>{Ot.resumeReport(e.idcustomreport,e.idsite).then(e=>{e&&"error"!==e.type?(Ot.reload().then(()=>{this.showNotification(this.translate("CustomReports_ResumedReport"),"success")}),p["Matomo"].postEvent("updateReportingMenu")):Ot.reload()})}})},showNotification(e,t,i=null){const o=p["NotificationsStore"].show({message:e,context:t,id:ji,type:null!==i?i:"toast"});setTimeout(()=>{p["NotificationsStore"].scrollToNotification(o)},200)},deleteReport(e){p["Matomo"].helper.modalConfirm(this.$refs.confirmDeleteReport,{yes:()=>{Ot.deleteReport(e.idcustomreport,e.idsite).then(()=>{Ot.reload(),p["Matomo"].postEvent("updateReportingMenu")})}})},getViewReportLink(e){return`?${p["MatomoUrl"].stringify({module:"CoreHome",action:"index",idSite:e.linkIdSite,period:"day",date:"yesterday"})}#?${p["MatomoUrl"].stringify({category:e.category.id,idSite:e.linkIdSite,date:p["MatomoUrl"].parsed.value.date,period:p["MatomoUrl"].parsed.value.period,segment:p["MatomoUrl"].parsed.value.segment,subcategory:e.subcategoryLink})}`},truncate:hi,htmlEntities(e){return p["Matomo"].helper.htmlEntities(e)},isMultiSiteReport(e){return e.multiple_idsites&&e.multiple_idsites.split(",")}},computed:{isSuperUser(){return p["Matomo"].hasSuperUserAccess},reports(){return Ot.state.value.reports},sortedReports(){const e=this.searchFilter.toLowerCase(),t=[...this.reports].filter(t=>Object.keys(t).some(i=>{const o=t;return"string"===typeof o[i]&&-1!==o[i].toLowerCase().indexOf(e)}));return t.sort((e,t)=>{const i=parseInt(""+e.idcustomreport,10),o=parseInt(""+t.idcustomreport,10);return i-o}),t},isLoading(){return Ot.state.value.isLoading},isUpdating(){return Ot.state.value.isUpdating},reportTypesReadable(){return Ot.state.value.reportTypesReadable},isEntityDuplicatorAvailable(){return"undefined"!==typeof gi}}});Oi.render=mi;var fi=Oi;const Ri={class:"manageReports"},yi={key:0},Ci={key:1};function Si(e,t,i,o,r,n){const a=Object(s["resolveComponent"])("CustomReportsList"),l=Object(s["resolveComponent"])("CustomReportsEdit");return Object(s["openBlock"])(),Object(s["createElementBlock"])("div",Ri,[e.editMode?Object(s["createCommentVNode"])("",!0):(Object(s["openBlock"])(),Object(s["createElementBlock"])("div",yi,[Object(s["createVNode"])(a)])),e.editMode?(Object(s["openBlock"])(),Object(s["createElementBlock"])("div",Ci,[Object(s["createVNode"])(l,{"id-custom-report":e.idCustomReport,"browser-archiving-disabled":e.browserArchivingDisabled,"re-archive-last-n":e.reArchiveLastN,"max-dimensions":e.maxDimensions,"is-cloud":e.isCloud},null,8,["id-custom-report","browser-archiving-disabled","re-archive-last-n","max-dimensions","is-cloud"])])):Object(s["createCommentVNode"])("",!0)])}var Ni=Object(s["defineComponent"])({props:{browserArchivingDisabled:Boolean,reArchiveLastN:Number,maxDimensions:Number,isCloud:Boolean},components:{CustomReportsList:fi,CustomReportsEdit:kt},data(){return{editMode:!1,idCustomReport:null}},watch:{editMode(){$(".ui-tooltip").remove()}},created(){Object(s["watch"])(()=>p["MatomoUrl"].hashParsed.value.idCustomReport,e=>{this.initState(e)}),this.initState(p["MatomoUrl"].hashParsed.value.idCustomReport)},methods:{removeAnyReportNotification(e=!0){p["NotificationsStore"].remove("reportsmanagement"),e&&p["NotificationsStore"].remove("reportsmanagementProductMetric")},initState(e){if(e){if("0"===e){const e={isAllowed:!0};if(p["Matomo"].postEvent("CustomReports.initAddReport",e),e&&!e.isAllowed)return this.editMode=!1,void(this.idCustomReport=null)}this.editMode=!0,this.idCustomReport=parseInt(e,10)}else this.editMode=!1,this.idCustomReport=null;this.removeAnyReportNotification(!e)}}});Ni.render=Si;var Ei=Ni; +/** + * Copyright (C) InnoCraft Ltd - All rights reserved. + * + * NOTICE: All information contained herein is, and remains the property of InnoCraft Ltd. + * The intellectual and technical concepts contained herein are protected by trade secret + * or copyright law. Redistribution of this information or reproduction of this material is + * strictly forbidden unless prior written permission is obtained from InnoCraft Ltd. + * + * You shall use this code only in accordance with the license agreement obtained from + * InnoCraft Ltd. + * + * @link https://www.innocraft.com/ + * @license For license details see https://www.innocraft.com/license + */}})})); +//# sourceMappingURL=CustomReports.umd.min.js.map \ No newline at end of file diff --git a/files/plugin-CustomReports-5.5.0/vue/dist/umd.metadata.json b/files/plugin-CustomReports-5.5.0/vue/dist/umd.metadata.json new file mode 100644 index 0000000..3abfd12 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/vue/dist/umd.metadata.json @@ -0,0 +1,7 @@ +{ + "dependsOn": [ + "CoreHome", + "CorePluginsAdmin", + "SegmentEditor" + ] +} \ No newline at end of file diff --git a/files/plugin-CustomReports-5.5.0/vue/src/CustomReports.store.ts b/files/plugin-CustomReports-5.5.0/vue/src/CustomReports.store.ts new file mode 100644 index 0000000..a829438 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/vue/src/CustomReports.store.ts @@ -0,0 +1,424 @@ +/** + * Copyright (C) InnoCraft Ltd - All rights reserved. + * + * NOTICE: All information contained herein is, and remains the property of InnoCraft Ltd. + * The intellectual and technical concepts contained herein are protected by trade secret + * or copyright law. Redistribution of this information or reproduction of this material is + * strictly forbidden unless prior written permission is obtained from InnoCraft Ltd. + * + * You shall use this code only in accordance with the license agreement obtained from + * InnoCraft Ltd. + * + * @link https://www.innocraft.com/ + * @license For license details see https://www.innocraft.com/license + */ + +import { + reactive, + computed, + readonly, + DeepReadonly, +} from 'vue'; +import { AjaxHelper, Matomo } from 'CoreHome'; +import { CustomReport, CustomReportType } from './types'; + +interface Option { + key: string; + value: string; +} + +type GetAvailableCategoriesResponse = { + uniqueId: string; + name: string; + subcategories: { + uniqueId: string; + name: string; + }[]; +}[]; + +type ListByCategory = ({ + category: string; +} & { + [S in SubcategoryField]: ({ + uniqueId: string; + name: string; + description?: string; + } & { + [E in ExtraField]?: string; + })[]; +})[]; + +type GetAvailableMetricsResponse = ListByCategory<'metrics'>; +type GetAvailableDimensionsResponse = ListByCategory<'dimensions', 'sqlSegment'>; + +function arrayFilterAndRemoveDuplicates(values: T[]) { + return [...new Set(values)].filter((v) => !!v); +} + +type FormattedListItem = ({ + group: string; + key: string; + value: string; + tooltip?: string; +} & { + [E in ExtraField]?: string; +}); + +function formatExpandableList( + listByCategories: ListByCategory, + subcategoryField: SubcategoryField, + extraField?: ExtraField, +) { + const list: FormattedListItem[] = []; + + listByCategories.forEach((category) => { + category[subcategoryField].forEach((value) => { + list.push({ + group: category.category, + key: value.uniqueId, + value: value.name, + tooltip: value.description || undefined, + ...(extraField ? { [extraField]: value[extraField] } : {}), + }); + }); + }); + + return list; +} + +const EMPTY_CAT = { + key: '', + value: '', +}; + +interface CustomReportsStoreState { + reports: CustomReport[]; + reportTypesReadable: Record; + dimensionsReadable: Record; + metricsReadable: Record; + categories: Option[]; + subcategories: Record; + isLoading: boolean; + isUpdating: boolean; + allMetrics: FormattedListItem[]; + allDimensions: FormattedListItem<'sqlSegment'>[]; +} + +class CustomReportsStore { + private privateState = reactive({ + reports: [], + reportTypesReadable: {}, + dimensionsReadable: {}, + metricsReadable: {}, + categories: [], + subcategories: {}, + isLoading: false, + isUpdating: false, + allMetrics: [], + allDimensions: [], + }); + + readonly state = computed(() => readonly(this.privateState)); + + private fetchPromise: Promise|null = null; + + private availableReportTypesPromise: Promise|null = null; + + private dimensionsPromise: Promise|null = null; + + private dimensionsIdsiteLoaded: number|string = 0; + + private metricsPromise: Promise|null = null; + + private metricsIdsiteLoaded: number|string = 0; + + reload() { + this.privateState.reports = []; + this.fetchPromise = null; + return this.fetchReports(); + } + + cleanupSegmentDefinition(definition: string) { + let result = definition; + result = result.replace('\'', '%27'); + result = result.replace('&', '%26'); + return result; + } + + getAvailableReportTypes(): Promise { + if (!this.availableReportTypesPromise) { + this.availableReportTypesPromise = AjaxHelper.fetch({ + method: 'CustomReports.getAvailableReportTypes', + filter_limit: '-1', + }).then((reportTypes) => { + const reportTypeMap: Record = {}; + reportTypes.forEach((rt) => { + reportTypeMap[rt.key] = rt.value; + }); + this.privateState.reportTypesReadable = reportTypeMap; + }); + } + + return this.availableReportTypesPromise!.then(() => this.state.value.reportTypesReadable); + } + + getAvailableDimensions( + idSite: number|'all', + ): Promise { + if (!this.dimensionsPromise || this.dimensionsIdsiteLoaded !== idSite) { + this.dimensionsIdsiteLoaded = idSite; + + this.dimensionsPromise = AjaxHelper.fetch({ + method: 'CustomReports.getAvailableDimensions', + filter_limit: '-1', + idSite, + }).then((dimensions) => { + const dimensionMap: Record = {}; + dimensions.forEach((category) => { + category.dimensions.forEach((dimension) => { + dimensionMap[dimension.uniqueId] = dimension.name; + }); + }); + this.privateState.dimensionsReadable = dimensionMap; + + this.privateState.allDimensions = formatExpandableList( + dimensions, + 'dimensions', + 'sqlSegment', + ); + }); + } + + return this.dimensionsPromise!.then(() => this.state.value.dimensionsReadable); + } + + getAvailableMetrics( + idSite: number|'all', + ): Promise { + if (!this.metricsPromise || this.metricsIdsiteLoaded !== idSite) { + this.metricsIdsiteLoaded = idSite; + this.metricsPromise = AjaxHelper.fetch({ + method: 'CustomReports.getAvailableMetrics', + filter_limit: '-1', + idSite, + }).then((metrics) => { + const metricsMap: Record = {}; + metrics.forEach((metricsCategory) => { + metricsCategory.metrics.forEach((metric) => { + metricsMap[metric.uniqueId] = metric.name; + }); + }); + this.privateState.metricsReadable = metricsMap; + + this.privateState.allMetrics = formatExpandableList(metrics, 'metrics'); + }); + } + + return this.metricsPromise!.then(() => this.state.value.metricsReadable); + } + + private categoriesPromise: Promise|null = null; + + private categoriesIdsiteLoaded: number|null = null; + + getAvailableCategories(idSite?: number|'all'): Promise { + const idSiteToUse = !idSite || idSite === 'all' ? Matomo.idSite : idSite; + + if (!this.categoriesPromise || this.categoriesIdsiteLoaded !== idSite) { + this.categoriesPromise = AjaxHelper.fetch({ + method: 'CustomReports.getAvailableCategories', + filter_limit: '-1', + idSite: idSiteToUse, + }).then((response) => { + const categories: Option[] = []; + const subcategories: Record = {}; + + response.forEach((category) => { + categories.push({ key: category.uniqueId, value: category.name }); + + category.subcategories.forEach((subcat) => { + subcategories[category.uniqueId] = subcategories[category.uniqueId] || [EMPTY_CAT]; + subcategories[category.uniqueId].push({ key: subcat.uniqueId, value: subcat.name }); + }); + }); + + this.privateState.categories = categories; + this.privateState.subcategories = subcategories; + }); + } + + return this.categoriesPromise!; + } + + fetchReports(): Promise { + if (!this.fetchPromise) { + this.fetchPromise = AjaxHelper.fetch({ + method: 'CustomReports.getConfiguredReports', + filter_limit: '-1', + }); + } + + this.privateState.isLoading = true; + this.privateState.reports = []; + return this.fetchPromise.then((reports) => { + this.privateState.reports = reports.map((report) => { + let subcategoryLink: string|number|undefined = undefined; + if (report?.subcategory?.id) { + subcategoryLink = report.subcategory.id; + } else if (report?.category?.id === 'CustomReports_CustomReports') { + subcategoryLink = report.idcustomreport; + } else { + subcategoryLink = report.name; + } + + return { + ...report, + // report.idsite is falsey when report is set for all sites + linkIdSite: report.idsite ? report.idsite : Matomo.idSite, + subcategoryLink, + }; + }); + + return this.state.value.reports; + }).finally(() => { + this.privateState.isLoading = false; + }); + } + + findReport(idCustomReport: number, isReload: boolean): Promise> { + // before going through an API request we first try to find it in loaded reports + const found = this.state.value.reports.find( + (r) => parseInt(`${r.idcustomreport}`, 10) === idCustomReport, + ); + + if (found && !isReload) { + return Promise.resolve(found); + } + + // otherwise we fetch it via API + this.privateState.isLoading = true; + return AjaxHelper.fetch({ + idCustomReport, + method: 'CustomReports.getConfiguredReport', + }).finally(() => { + this.privateState.isLoading = false; + }); + } + + deleteReport( + idCustomReport: number|string, + idSite: number|string, + ): Promise<{ type: string, message?: string }> { + this.privateState.isUpdating = true; + this.privateState.reports = []; + + return AjaxHelper.fetch( + { + idCustomReport, + idSite: `${idSite}`, + method: 'CustomReports.deleteCustomReport', + }, + { + withTokenInUrl: true, + }, + ).then(() => ({ + type: 'success', + })).catch((e) => ({ + type: 'error', + message: e.message || e as string, + })).finally(() => { + this.privateState.isUpdating = false; + }); + } + + pauseReport( + idCustomReport: number|string, + idSite: number|string, + ): Promise<{ type: string, message?: string }> { + this.privateState.isUpdating = true; + this.privateState.reports = []; + + return AjaxHelper.fetch( + { + idCustomReport, + idSite: `${idSite}`, + method: 'CustomReports.pauseCustomReport', + }, + { + withTokenInUrl: true, + }, + ).then(() => ({ + type: 'success', + })).catch((e) => ({ + type: 'error', + message: e.message || e as string, + })).finally(() => { + this.privateState.isUpdating = false; + }); + } + + resumeReport( + idCustomReport: number|string, + idSite: number|string, + ): Promise<{ type: string, message?: string }> { + this.privateState.isUpdating = true; + this.privateState.reports = []; + + return AjaxHelper.fetch( + { + idCustomReport, + idSite: `${idSite}`, + method: 'CustomReports.resumeCustomReport', + }, + { + withTokenInUrl: true, + }, + ).then(() => ({ + type: 'success', + })).catch((e) => ({ + type: 'error', + message: e.message || e as string, + })).finally(() => { + this.privateState.isUpdating = false; + }); + } + + createOrUpdateReport( + report: CustomReport, + method: string, + childReportIds: Array, + multipleIdSites: Array, + ): Promise<{ type: string, message?: string, response?: { value: number|string }}> { + this.privateState.isUpdating = true; + return AjaxHelper.post<{ value: number|string }>( + { + method, + idCustomReport: report.idcustomreport, + reportType: report.report_type, + name: report.name.trim(), + description: report.description.trim(), + segmentFilter: encodeURIComponent(report.segment_filter), + categoryId: report.category?.id, + subcategoryId: report.subcategory?.id, + idSite: report.site.id, + subCategoryReportIds: childReportIds, + multipleIdSites, + }, + { + dimensionIds: arrayFilterAndRemoveDuplicates(report.dimensions), + metricIds: arrayFilterAndRemoveDuplicates(report.metrics), + }, + { withTokenInUrl: true }, + ).then((response) => ({ + type: 'success', + response, + })).catch((error) => ({ + type: 'error', + message: error.message || error as string, + })).finally(() => { + this.privateState.isUpdating = false; + }); + } +} + +export default new CustomReportsStore(); diff --git a/files/plugin-CustomReports-5.5.0/vue/src/KpiCardView/KpiCardView.less b/files/plugin-CustomReports-5.5.0/vue/src/KpiCardView/KpiCardView.less new file mode 100644 index 0000000..501b09e --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/vue/src/KpiCardView/KpiCardView.less @@ -0,0 +1,3 @@ +[vue-entry="CustomReports.KpiCardView"] { + display: block; +} diff --git a/files/plugin-CustomReports-5.5.0/vue/src/KpiCardView/KpiCardView.vue b/files/plugin-CustomReports-5.5.0/vue/src/KpiCardView/KpiCardView.vue new file mode 100644 index 0000000..66316e7 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/vue/src/KpiCardView/KpiCardView.vue @@ -0,0 +1,276 @@ + + + + + diff --git a/files/plugin-CustomReports-5.5.0/vue/src/Reports/DraggableList.less b/files/plugin-CustomReports-5.5.0/vue/src/Reports/DraggableList.less new file mode 100644 index 0000000..ca150eb --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/vue/src/Reports/DraggableList.less @@ -0,0 +1,63 @@ +.draggableList { + list-style: none; + margin: 0; + padding: 0; + + .draggableListItem { + display: flex; + align-items: center; + padding: 0.6rem 0.9rem; + border: 1px solid @theme-color-border; + border-radius: 4px; + background: @theme-color-background-tinyContrast; + color: @theme-color-text; + cursor: grab; + margin-bottom: 0.6rem; + user-select: none; + + > :last-child { + flex: 1; + min-width: 0; + } + } + + &.isDragging .draggableListItem { + cursor: grabbing; + } + + &.isDisabled .draggableListItem, + &.isDisabled .drag-icon { + cursor: default; + } + + .draggableListItem.isDragged { + border-style: dashed; + border-color: @theme-color-border; + background: @theme-color-background-base; + + > * { + visibility: hidden; + } + } + + .drag-icon { + padding-right: 15px; + font-size: 13px; + color: @theme-color-text-light; + } +} + +.draggableListPanel { + font-size: 1rem; + margin-top: 1.5rem; + margin-bottom: 2.5rem; + + .draggableListHeading { + margin-bottom: 0.4rem; + } + + .draggableListHelp { + margin-top: 0; + color: @theme-color-text-lighter; + } +} diff --git a/files/plugin-CustomReports-5.5.0/vue/src/Reports/DraggableList.vue b/files/plugin-CustomReports-5.5.0/vue/src/Reports/DraggableList.vue new file mode 100644 index 0000000..9f38c6d --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/vue/src/Reports/DraggableList.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/files/plugin-CustomReports-5.5.0/vue/src/Reports/Edit.less b/files/plugin-CustomReports-5.5.0/vue/src/Reports/Edit.less new file mode 100644 index 0000000..bdcdffb --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/vue/src/Reports/Edit.less @@ -0,0 +1,113 @@ +.editReport { + min-width: 1070px; + + form > div { + .matomo-form-field { + margin-left: -.75rem; + } + + > .form-group { + margin-left: -.75rem; + } + } + + .icon-help { + color: #888; + cursor: help; + &:hover { + color: @theme-color-text; + } + } + + .alertUnlocked, + .unlockAlert { + margin-top: -24px; + } + + .icon-close { + cursor: pointer; + } + + .metricsGroup, + .dimensionsGroup { + padding: 0 0.75rem; + + .groupValueSelect { + min-width: 300px; + display: inline-block; + + .expandableList { + margin-top: -32px; + margin-left: 12px; + } + } + + .selectedMetric, + .selectedDimension { + .select-dropdown { + // #e4e4e4: no theme var sits between tinyContrast and lowContrast, and + // lowContrast matches the disabled button. + background: @theme-color-background-tinyContrast !important; + } + } + + .icon-minus { + display: inline-block; + vertical-align: top; + margin-top: 20px; + margin-left: 53.5px; + color: #666; + cursor: pointer; + } + + label { + font-size: 0.8rem; + } + .form-group.row { + margin-top: 11px; + margin-bottom: 0; + } + + .input-field { + margin-top: 0; + .caret { + right: -25px; + z-index: 9; + cursor: pointer; + } + .select-dropdown { + margin-bottom: 0; + border: 0; + background: @theme-color-background-contrast; + display: inline-block; + height: 32px; + font-size: 13px; + font-weight: 500; + color: @theme-color-text-lighter; + line-height: 32px; + border-radius: 3em; + padding-left: 12px; + padding-right: 30px; + box-shadow: 0 2px 5px 0 rgba(0,0,0,0.16), 0 2px 10px 0 rgba(0,0,0,0.12) !important; + } + } + } + + .expandableSelector { + label { + white-space: nowrap; + top: -0.8rem; + } + } + + .customReportSearchMeasurablesField { + width: 50% !important; + display: inline-block; + } + + .entityTable { + .selectedSiteRow td { + background: @color-silver-l98; + } + } +} diff --git a/files/plugin-CustomReports-5.5.0/vue/src/Reports/Edit.vue b/files/plugin-CustomReports-5.5.0/vue/src/Reports/Edit.vue new file mode 100644 index 0000000..4f8cfb7 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/vue/src/Reports/Edit.vue @@ -0,0 +1,1524 @@ + + + + + diff --git a/files/plugin-CustomReports-5.5.0/vue/src/Reports/List.less b/files/plugin-CustomReports-5.5.0/vue/src/Reports/List.less new file mode 100644 index 0000000..0b28ef0 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/vue/src/Reports/List.less @@ -0,0 +1,58 @@ +.manageReports { + .reportSearchFilter { + .form-group { + .theWidgetContent & { + margin-top: 0 !important; + } + } + .input-field { + margin-left: -0.75em !important; + margin-top: 0; + + .theWidgetContent & { + margin-left: 0 !important; + } + } + } + .info { + color: @color-silver; + } + + .icon-info2, + .icon-locked { + color: @color-silver; + cursor: help; + } + + p, pre { + max-width: 900px; + } + + th.action, td.action { + width: 250px; + + a { + color: #000; + margin-top: -16px; + } + } + + th.action.duplicate-available, td.action.duplicate-available { + width: 280px; + } + + .index { + width: 60px; + } + + td { + .icon-ok { + color: #43a047; + } + } +} + +.customReportsHigherDimensions table.dataTable td { + width: calc(100% - 4rem) !important; + max-width: 160px !important; +} \ No newline at end of file diff --git a/files/plugin-CustomReports-5.5.0/vue/src/Reports/List.vue b/files/plugin-CustomReports-5.5.0/vue/src/Reports/List.vue new file mode 100644 index 0000000..910c7a9 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/vue/src/Reports/List.vue @@ -0,0 +1,451 @@ + + + + + diff --git a/files/plugin-CustomReports-5.5.0/vue/src/Reports/Manage.vue b/files/plugin-CustomReports-5.5.0/vue/src/Reports/Manage.vue new file mode 100644 index 0000000..d093cd5 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/vue/src/Reports/Manage.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/files/plugin-CustomReports-5.5.0/vue/src/index.ts b/files/plugin-CustomReports-5.5.0/vue/src/index.ts new file mode 100644 index 0000000..3ffed51 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/vue/src/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright (C) InnoCraft Ltd - All rights reserved. + * + * NOTICE: All information contained herein is, and remains the property of InnoCraft Ltd. + * The intellectual and technical concepts contained herein are protected by trade secret + * or copyright law. Redistribution of this information or reproduction of this material is + * strictly forbidden unless prior written permission is obtained from InnoCraft Ltd. + * + * You shall use this code only in accordance with the license agreement obtained from + * InnoCraft Ltd. + * + * @link https://www.innocraft.com/ + * @license For license details see https://www.innocraft.com/license + */ + +export { default as KpiCardView } from './KpiCardView/KpiCardView.vue'; +export { default as ReportEdit } from './Reports/Edit.vue'; +export { default as ReportsList } from './Reports/List.vue'; +export { default as ReportsManage } from './Reports/Manage.vue'; diff --git a/files/plugin-CustomReports-5.5.0/vue/src/truncateText2.ts b/files/plugin-CustomReports-5.5.0/vue/src/truncateText2.ts new file mode 100644 index 0000000..ee32085 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/vue/src/truncateText2.ts @@ -0,0 +1,21 @@ +/** + * Copyright (C) InnoCraft Ltd - All rights reserved. + * + * NOTICE: All information contained herein is, and remains the property of InnoCraft Ltd. + * The intellectual and technical concepts contained herein are protected by trade secret + * or copyright law. Redistribution of this information or reproduction of this material is + * strictly forbidden unless prior written permission is obtained from InnoCraft Ltd. + * + * You shall use this code only in accordance with the license agreement obtained from + * InnoCraft Ltd. + * + * @link https://www.innocraft.com/ + * @license For license details see https://www.innocraft.com/license + */ + +export default function truncateText2(text: string, length: number): string { + if (text && text.length > length) { + return `${text.substr(0, length - 3)}...`; + } + return text; +} diff --git a/files/plugin-CustomReports-5.5.0/vue/src/types.ts b/files/plugin-CustomReports-5.5.0/vue/src/types.ts new file mode 100644 index 0000000..96145c9 --- /dev/null +++ b/files/plugin-CustomReports-5.5.0/vue/src/types.ts @@ -0,0 +1,70 @@ +/** + * Copyright (C) InnoCraft Ltd - All rights reserved. + * + * NOTICE: All information contained herein is, and remains the property of InnoCraft Ltd. + * The intellectual and technical concepts contained herein are protected by trade secret + * or copyright law. Redistribution of this information or reproduction of this material is + * strictly forbidden unless prior written permission is obtained from InnoCraft Ltd. + * + * You shall use this code only in accordance with the license agreement obtained from + * InnoCraft Ltd. + * + * @link https://www.innocraft.com/ + * @license For license details see https://www.innocraft.com/license + */ + +import { SiteRef } from 'CoreHome'; + +export interface CustomReportType { + key: string; + value: string; +} + +interface Category { + id: string; + name?: string; + order?: number; + icon?: string; +} + +interface Subcategory { + id: string|number; + name?: string; + order?: number; +} + +export interface Site { + idsite: string | number; + name: string; +} + +export interface ChildReport { + idcustomreport: string|number; + name: string; + subcategory_order: string|number; +} + +export interface CustomReport { + category: Category; + created_date: string; + description: string; + dimensions: string[]; + idcustomreport: string|number; + idsite: string|number; + metrics: string[]; + name: string; + report_type: string; + revision: string|number; + segment_filter: string; + site: SiteRef; + status: string; + subcategory: Subcategory|null; + updated_date: string; + child_reports: ChildReport[] + multipleIdSites: Site[]; + multiple_idsites: null|string; + allowedToEdit: boolean; + + linkIdSite?: string|number; + subcategoryLink?: string|number; +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/API.php b/files/plugin-HeatmapSessionRecording-5.6.0/API.php new file mode 100644 index 0000000..8ae3860 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/API.php @@ -0,0 +1,1261 @@ +validator = $validator; + $this->aggregator = $aggregator; + $this->siteHsr = $siteHsr; + $this->logHsr = $logHsr; + $this->logEvent = $logEvent; + $this->logHsrSite = $logHsrSite; + $this->systemSettings = $settings; + $this->configuration = $configuration; + + $dir = Plugin\Manager::getPluginDirectory('UserCountry'); + require_once $dir . '/functions.php'; + } + + /** + * Adds a new heatmap. + * + * Once created, Matomo starts recording matching page activity for this heatmap. + * + * @param int $idSite The numeric ID of the website to configure. + * @param string $name The heatmap name shown in the reporting UI. + * @param array $matchPageRules Matching rules that define which pages contribute to this heatmap. + * @param int $sampleLimit The number of matching page views to record before the heatmap ends automatically. + * @param float $sampleRate The percentage of matching traffic to record, from 0 to 100 with up to one decimal + * place. + * @param string|false $excludedElements Comma-separated CSS selectors to exclude from the heatmap output. + * @param string|false $screenshotUrl URL of the page to capture for the heatmap screenshot. + * @param int|false $breakpointMobile Width threshold used to classify visits as mobile when device detection is + * unavailable. + * @param int|false $breakpointTablet Width threshold used to classify visits as tablet when device detection is + * unavailable. + * @param bool $captureDomManually Whether the DOM should be captured manually instead of automatically. + * @param string $description Optional description providing additional context, such as the purpose or usage. + * @param bool $autoRepeat Whether Matomo should automatically create a new heatmap once this one finishes. + * + * @return int The ID of the newly created heatmap configuration. + */ + public function addHeatmap( + $idSite, + $name, + $matchPageRules, + $sampleLimit = 1000, + $sampleRate = 5, + $excludedElements = false, + $screenshotUrl = false, + $breakpointMobile = false, + $breakpointTablet = false, + $captureDomManually = false, + $description = '', + bool $autoRepeat = false + ) { + $this->validator->checkHeatmapReportWritePermission($idSite); + + if ($breakpointMobile === false || $breakpointMobile === null) { + $breakpointMobile = $this->systemSettings->breakpointMobile->getValue(); + } + + if ($breakpointTablet === false || $breakpointTablet === null) { + $breakpointTablet = $this->systemSettings->breakpointTablet->getValue(); + } + + $createdDate = Date::now()->getDatetime(); + + $matchPageRules = $this->unsanitizePageRules($matchPageRules); + $screenshotUrl = $this->unsanitizeScreenshotUrl($screenshotUrl); + + return $this->siteHsr->addHeatmap($idSite, $name, $matchPageRules, $sampleLimit, $sampleRate, $excludedElements, $screenshotUrl, $breakpointMobile, $breakpointTablet, $captureDomManually, $createdDate, $description, $autoRepeat); + } + + /** + * Copies a specified heatmap to one or more sites. If a heatmap with the same name already exists, the new heatmap + * will have an automatically adjusted name to make it unique to the assigned site. + * + * @param int $idSite The numeric ID of the website that owns the source heatmap. + * @param int $idSiteHsr The ID of the heatmap configuration to duplicate. + * @param int[] $idDestinationSites Site IDs to copy the heatmap to. Defaults to the source site when omitted. + * + * @return array The duplication result, including success state, messages, and any newly created heatmap IDs. + */ + public function duplicateHeatmap(int $idSite, int $idSiteHsr, array $idDestinationSites = []): array + { + if (!class_exists('\Piwik\Plugins\CoreHome\EntityDuplicator\DuplicateRequestResponse')) { + throw new BadRequestException('This endpoint is not available until Matomo 5.4.0'); + } + + // Define data array before any alterations to the variables + $additionalData = [ + 'idSite' => $idSite, + 'idDestinationSites' => $idDestinationSites, + 'idSiteHsr' => $idSiteHsr, + ]; + + $idDestinationSites = count($idDestinationSites) > 0 ? $idDestinationSites : [$idSite]; + $idSitesToCheck = array_unique(array_merge([$idSite], $idDestinationSites)); + $this->validator->checkSitesDuplicationPermission($idSitesToCheck); + + // Initialise the common response values + $duplicateRequestResponse = new DuplicateRequestResponse(); + + $heatmap = null; + try { + $heatmap = $this->getHeatmap($idSite, $idSiteHsr); + } catch (\Throwable $e) { + // Log the error, but continue for the proper response to be built later + $this->logError('Uncaught exception looking up heatmap to duplicate: {exception}', $e); + } + if (empty($heatmap['name']) || empty($heatmap['match_page_rules'])) { + $duplicateRequestResponse->setSuccess(false); + $duplicateRequestResponse->setMessage(Piwik::translate('HeatmapSessionRecording_SourceHeatmapLookupError')); + $duplicateRequestResponse->setAdditionalData($additionalData); + + return $duplicateRequestResponse->getResponseArray(); + } + + $idSitesFailed = $idHrsNew = []; + foreach ($idDestinationSites as $idDestinationSite) { + try { + // Make sure that the new name is unique. + $newName = $heatmap['name']; + $heatmaps = $this->siteHsr->getHeatmaps($idDestinationSite, false, true); + // It can only be a duplicate name if some heatmaps were found for the site. + if (is_array($heatmaps) && count($heatmaps) > 0) { + $heatmapNames = array_column($heatmaps, 'name'); + $newName = EntityDuplicatorHelper::getUniqueNameComparedToList($newName, $heatmapNames, Name::MAX_LENGTH); + } + + $response = $this->addHeatmap( + $idDestinationSite, + $newName, + $heatmap['match_page_rules'], + $heatmap['sample_limit'] ?? 1000, + $heatmap['sample_rate'] ?? 5, + $heatmap['excluded_elements'] ?? false, + $heatmap['screenshot_url'] ?? false, + $heatmap['breakpoint_mobile'] ?? false, + $heatmap['breakpoint_tablet'] ?? false, + $heatmap['capture_manually'] ?? false, + $heatmap['description'] ?? '', + $heatmap['auto_repeat'] ?? false + ); + + // Check response for success or failure. The only return is the new ID, so make sure it's a valid int + if (!is_int($response) || $response < 1) { + $idSitesFailed[] = $idDestinationSite; + continue; + } + + $idHrsNew[] = $response; + } catch (\Throwable $e) { + $idSitesFailed[] = $idDestinationSite; + + // Log the error, but continue in case there are other sites to copy to + $this->logError('Uncaught exception duplicating heatmap: {exception}', $e); + } + } + + // Set the values for success response + $duplicateRequestResponse->setSuccess(true); + $duplicateRequestResponse->setMessage(Piwik::translate('HeatmapSessionRecording_HeatmapCopied')); + $additionalData['newIds'] = $idHrsNew; + $duplicateRequestResponse->setAdditionalData($additionalData); + + // If any of the sites failed, update to error response + if (count($idSitesFailed) > 0) { + $successSites = array_diff($idDestinationSites, $idSitesFailed); + $sitesString = count($successSites) ? implode(',', $successSites) : Piwik::translate('HeatmapSessionRecording_None'); + $message = Piwik::translate('HeatmapSessionRecording_HeatmapDuplicationError', [implode(',', $idSitesFailed), $sitesString]); + $duplicateRequestResponse->setSuccess(false); + $duplicateRequestResponse->setMessage($message); + } + + // Make sure to record the activity for the report being copied + if (class_exists('\Piwik\Plugins\ActivityLog\ActivityParamObject\EntityDuplicatedData')) { + // TODO - Remove this if/else and always use the setRequestDataForActivity method for Matomo 6.x + if (method_exists($duplicateRequestResponse, 'setRequestDataForEvent')) { + $duplicateRequestResponse->setRequestDataForEvent( + 'HeatmapSessionRecording_Heatmap', + $heatmap['name'], + $idSiteHsr, + $idSite, + $idDestinationSites, + $additionalData + ); + } else { + ( + new \Piwik\Plugins\ActivityLog\ActivityParamObject\EntityDuplicatedData( + 'HeatmapSessionRecording_Heatmap', + $heatmap['name'], + $idSiteHsr, + $idSite, + $idDestinationSites, + $additionalData + ) + )->postActivityEvent(); + } + } + + return $duplicateRequestResponse->getResponseArray(); + } + + private function logError(string $message, \Throwable $e): void + { + StaticContainer::get(\Piwik\Log\LoggerInterface::class)->error( + $message, + [ + 'exception' => $e, + 'ignoreInScreenWriter' => true, + ] + ); + } + + private function unsanitizeScreenshotUrl($screenshotUrl) + { + if (!empty($screenshotUrl) && is_string($screenshotUrl)) { + $screenshotUrl = Common::unsanitizeInputValue($screenshotUrl); + } + + return $screenshotUrl; + } + + private function unsanitizePageRules($matchPageRules) + { + if (!empty($matchPageRules) && is_array($matchPageRules)) { + foreach ($matchPageRules as $index => $matchPageRule) { + if (is_array($matchPageRule) && !empty($matchPageRule['value'])) { + $matchPageRules[$index]['value'] = Common::unsanitizeInputValue($matchPageRule['value']); + } + } + } + return $matchPageRules; + } + + /** + * Updates an existing heatmap. + * + * Send the full configuration payload when updating a heatmap, not only the fields you changed. + * + * @param int $idSite The numeric ID of the website that owns the heatmap. + * @param int $idSiteHsr The ID of the heatmap configuration to update. + * @param string $name The heatmap name shown in the reporting UI. + * @param array $matchPageRules Matching rules that define which pages contribute to this heatmap. + * @param int $sampleLimit The number of matching page views to record before the heatmap ends automatically. + * @param float $sampleRate The percentage of matching traffic to record, from 0 to 100 with up to one decimal + * place. + * @param string|false $excludedElements Comma-separated CSS selectors to exclude from the heatmap output. + * @param string|false $screenshotUrl URL of the page to capture for the heatmap screenshot. + * @param int|false $breakpointMobile Width threshold used to classify visits as mobile when device detection is + * unavailable. + * @param int|false $breakpointTablet Width threshold used to classify visits as tablet when device detection is + * unavailable. + * @param bool $captureDomManually Whether the DOM should be captured manually instead of automatically. + * @param string $description Optional description providing additional context, such as the purpose or usage. + * @param bool $autoRepeat Whether Matomo should automatically create a new heatmap once this one finishes. + * + * @return void + */ + public function updateHeatmap( + $idSite, + $idSiteHsr, + $name, + $matchPageRules, + $sampleLimit = 1000, + $sampleRate = 5, + $excludedElements = false, + $screenshotUrl = false, + $breakpointMobile = false, + $breakpointTablet = false, + $captureDomManually = false, + $description = '', + bool $autoRepeat = false + ): void { + $this->validator->checkHeatmapReportWritePermission($idSite); + $this->siteHsr->checkHeatmapExists($idSite, $idSiteHsr); + + if ($breakpointMobile === false || $breakpointMobile === null) { + $breakpointMobile = $this->systemSettings->breakpointMobile->getValue(); + } + + if ($breakpointTablet === false || $breakpointTablet === null) { + $breakpointTablet = $this->systemSettings->breakpointTablet->getValue(); + } + + $updatedDate = Date::now()->getDatetime(); + + $matchPageRules = $this->unsanitizePageRules($matchPageRules); + $screenshotUrl = $this->unsanitizeScreenshotUrl($screenshotUrl); + + $this->siteHsr->updateHeatmap($idSite, $idSiteHsr, $name, $matchPageRules, $sampleLimit, $sampleRate, $excludedElements, $screenshotUrl, $breakpointMobile, $breakpointTablet, $captureDomManually, $updatedDate, $description, $autoRepeat); + } + + /** + * Deletes the stored snapshot (screenshot) for a heatmap. + * + * Works for active, ended and paused heatmaps. When $captureManually is set, the snapshot is not retaken + * automatically on the next recorded visit and must be captured manually; this is only allowed for active + * heatmaps, as ended or paused heatmaps no longer record any visits. + * + * @param int $idSite The numeric ID of the website that owns the heatmap. + * @param int $idSiteHsr The ID of the heatmap configuration to update. + * @param bool $captureManually Whether the new snapshot must be captured manually instead of automatically. + * + * @return bool Whether the snapshot reference was removed. + */ + public function deleteHeatmapScreenshot($idSite, $idSiteHsr, bool $captureManually = false) + { + $this->validator->checkHeatmapReportWritePermission($idSite); + $this->siteHsr->checkHeatmapExists($idSite, $idSiteHsr); + + $heatmap = $this->siteHsr->getHeatmap($idSite, $idSiteHsr); + $isActive = !empty($heatmap['status']) && $heatmap['status'] === SiteHsrDao::STATUS_ACTIVE; + + if ($captureManually && !$isActive) { + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorSnapshotManualRetakeOnlyActive')); + } + + $hadScreenshot = !empty($heatmap['page_treemirror']); + $captureManuallyChanged = (int) ($heatmap['capture_manually'] ?? 0) !== ($captureManually ? 1 : 0); + + $this->siteHsr->setPageTreeMirror($idSite, $idSiteHsr, null, null); + $this->siteHsr->markScreenshotDeleted($idSite, $idSiteHsr, $captureManually); + + if ($hadScreenshot || $captureManuallyChanged) { + // the tracker cache carries page_treemirror and capture_manually, so it must be + // rebuilt when the snapshot existed or the stored manual-capture flag changed + Cache::deleteCacheWebsiteAttributes($idSite); + } + + return true; + } + + /** + * Adds a new session recording. + * + * Once created, Matomo starts recording matching visitor sessions. + * + * @param int $idSite The numeric ID of the website to configure. + * @param string $name The session recording name shown in the reporting UI. + * @param array $matchPageRules Matching rules that define which pages contribute to this heatmap. + * @param int $sampleLimit The number of sessions to record before the recording ends automatically. + * @param float $sampleRate The percentage of matching traffic to record, from 0 to 100 with up to one decimal + * place. + * @param int $minSessionTime Minimum time in seconds a visitor must spend on the current page before recording. + * @param bool $requiresActivity Whether the visitor must interact by scrolling or clicking before the session is + * kept. + * @param bool $captureKeystrokes Whether entered form text should be recorded. Password fields are masked + * automatically. + * @param string $description Optional description providing additional context, such as the purpose or usage. + * @param bool $autoRepeat Whether Matomo should automatically create a new session recording once this one + * finishes. + * + * @return int The ID of the newly created session recording configuration. + */ + public function addSessionRecording($idSite, $name, $matchPageRules = array(), $sampleLimit = 1000, $sampleRate = 10, $minSessionTime = 0, $requiresActivity = true, $captureKeystrokes = true, $description = '', bool $autoRepeat = false) + { + $this->validator->checkSessionReportWritePermission($idSite); + + $createdDate = Date::now()->getDatetime(); + + $matchPageRules = $this->unsanitizePageRules($matchPageRules); + + return $this->siteHsr->addSessionRecording($idSite, $name, $matchPageRules, $sampleLimit, $sampleRate, $minSessionTime, $requiresActivity, $captureKeystrokes, $createdDate, $description, $autoRepeat); + } + + /** + * Updates an existing session recording. + * + * Send the full configuration payload when updating a session recording, not only the fields you changed. + * + * @param int $idSite The numeric ID of the website that owns the session recording. + * @param int $idSiteHsr The ID of the session recording configuration to update. + * @param string $name The session recording name shown in the reporting UI. + * @param array $matchPageRules Matching rules that define which pages contribute to this heatmap. + * @param int $sampleLimit The number of sessions to record before the recording ends automatically. + * @param float $sampleRate The percentage of matching traffic to record, from 0 to 100 with up to one decimal + * place. + * @param int $minSessionTime Minimum time in seconds a visitor must spend on the current page before recording. + * @param bool $requiresActivity Whether the visitor must interact by scrolling or clicking before the session is + * kept. + * @param bool $captureKeystrokes Whether entered form text should be recorded. Password fields are masked + * automatically. + * @param string $description Optional description providing additional context, such as the purpose or usage. + * @param bool $autoRepeat Whether Matomo should automatically create a new session recording once this one + * finishes. + * + * @return void + */ + public function updateSessionRecording( + $idSite, + $idSiteHsr, + $name, + $matchPageRules = array(), + $sampleLimit = 1000, + $sampleRate = 10, + $minSessionTime = 0, + $requiresActivity = true, + $captureKeystrokes = true, + $description = '', + bool $autoRepeat = false + ): void { + $this->validator->checkSessionReportWritePermission($idSite); + $this->siteHsr->checkSessionRecordingExists($idSite, $idSiteHsr); + + $updatedDate = Date::now()->getDatetime(); + + $matchPageRules = $this->unsanitizePageRules($matchPageRules); + + $this->siteHsr->updateSessionRecording($idSite, $idSiteHsr, $name, $matchPageRules, $sampleLimit, $sampleRate, $minSessionTime, $requiresActivity, $captureKeystrokes, $updatedDate, $description, $autoRepeat); + } + + /** + * Returns a single heatmap configuration. + * + * @param int $idSite The numeric ID of the website to query. + * @param int $idSiteHsr The ID of the heatmap configuration to fetch. + * + * @return array The heatmap configuration data for the requested heatmap. + */ + public function getHeatmap($idSite, $idSiteHsr): array + { + $this->validator->checkHeatmapReportViewPermission($idSite); + $this->siteHsr->checkHeatmapExists($idSite, $idSiteHsr); + + $heatmap = $this->siteHsr->getHeatmap($idSite, $idSiteHsr); + + return $heatmap; + } + + /** + * Returns a single session recording configuration. + * + * @param int $idSite The numeric ID of the website to query. + * @param int $idSiteHsr The ID of the session recording configuration to fetch. + * + * @return array The session recording configuration data for the requested recording. + */ + public function getSessionRecording($idSite, $idSiteHsr): array + { + $this->validator->checkSessionReportViewPermission($idSite); + $this->siteHsr->checkSessionRecordingExists($idSite, $idSiteHsr); + + return $this->siteHsr->getSessionRecording($idSite, $idSiteHsr); + } + + /** + * Pauses a heatmap configuration. + * + * While paused, Matomo stops collecting new activity for the heatmap until it is resumed. + * + * @param int $idSite The numeric ID of the website that owns the heatmap. + * @param int $idSiteHsr The ID of the heatmap configuration to pause. + * + * @return void + */ + public function pauseHeatmap($idSite, $idSiteHsr): void + { + $this->validator->checkHeatmapReportWritePermission($idSite); + $this->siteHsr->checkHeatmapExists($idSite, $idSiteHsr); + + $this->siteHsr->pauseHeatmap($idSite, $idSiteHsr); + + Cache::deleteCacheWebsiteAttributes($idSite); + } + + /** + * Resumes a paused heatmap configuration. + * + * Once resumed, Matomo starts collecting new matching activity again. + * + * @param int $idSite The numeric ID of the website that owns the heatmap. + * @param int $idSiteHsr The ID of the heatmap configuration to resume. + * + * @return void + */ + public function resumeHeatmap($idSite, $idSiteHsr): void + { + $this->validator->checkHeatmapReportWritePermission($idSite); + $this->siteHsr->checkHeatmapExists($idSite, $idSiteHsr); + + $this->siteHsr->resumeHeatmap($idSite, $idSiteHsr); + + Cache::deleteCacheWebsiteAttributes($idSite); + } + + /** + * Deletes a heatmap configuration. + * + * Deleted heatmaps are no longer available through the API, and related tracked data may be removed. + * + * @param int $idSite The numeric ID of the website that owns the heatmap. + * @param int $idSiteHsr The ID of the heatmap configuration to delete. + * + * @return void + */ + public function deleteHeatmap($idSite, $idSiteHsr): void + { + $this->validator->checkHeatmapReportWritePermission($idSite); + + $this->siteHsr->deactivateHeatmap($idSite, $idSiteHsr); + } + + /** + * Ends a heatmap configuration. + * + * Ended heatmaps remain available in the API and UI, but no new heatmap activity is recorded. + * Only a heatmap that has started (active or paused) can be ended. A heatmap that is on hold waiting for its + * scheduled date, or one that has already ended, is left unchanged and can only be deleted. + * + * @param int $idSite The numeric ID of the website that owns the heatmap. + * @param int $idSiteHsr The ID of the heatmap configuration to end. + * + * @return void + */ + public function endHeatmap($idSite, $idSiteHsr): void + { + $this->validator->checkHeatmapReportWritePermission($idSite); + $this->siteHsr->checkHeatmapExists($idSite, $idSiteHsr); + + $this->siteHsr->endHeatmap($idSite, $idSiteHsr); + } + + /** + * Pauses a session recording configuration. + * + * While paused, Matomo stops collecting new recorded sessions until it is resumed. + * + * @param int $idSite The numeric ID of the website that owns the session recording. + * @param int $idSiteHsr The ID of the session recording configuration to pause. + * + * @return void + */ + public function pauseSessionRecording($idSite, $idSiteHsr): void + { + $this->validator->checkSessionReportWritePermission($idSite); + $this->siteHsr->checkSessionRecordingExists($idSite, $idSiteHsr); + + $this->siteHsr->pauseSessionRecording($idSite, $idSiteHsr); + + Cache::deleteCacheWebsiteAttributes($idSite); + } + + /** + * Resumes a paused session recording configuration. + * + * Once resumed, Matomo starts collecting new matching sessions again. + * + * @param int $idSite The numeric ID of the website that owns the session recording. + * @param int $idSiteHsr The ID of the session recording configuration to resume. + * + * @return void + */ + public function resumeSessionRecording($idSite, $idSiteHsr): void + { + $this->validator->checkSessionReportWritePermission($idSite); + $this->siteHsr->checkSessionRecordingExists($idSite, $idSiteHsr); + + $this->siteHsr->resumeSessionRecording($idSite, $idSiteHsr); + + Cache::deleteCacheWebsiteAttributes($idSite); + } + + /** + * Deletes a session recording configuration. + * + * Deleted session recordings are no longer available through the API, and related tracked data may be removed. + * + * @param int $idSite The numeric ID of the website that owns the session recording. + * @param int $idSiteHsr The ID of the session recording configuration to delete. + * + * @return void + */ + public function deleteSessionRecording($idSite, $idSiteHsr): void + { + + $this->validator->checkSessionReportWritePermission($idSite); + + $this->siteHsr->deactivateSessionRecording($idSite, $idSiteHsr); + } + + /** + * Ends a session recording configuration. + * + * Ended session recordings remain available in the API and UI, but no new sessions are recorded. + * + * @param int $idSite The numeric ID of the website that owns the session recording. + * @param int $idSiteHsr The ID of the session recording configuration to end. + * + * @return void + */ + public function endSessionRecording($idSite, $idSiteHsr): void + { + $this->validator->checkSessionReportWritePermission($idSite); + $this->siteHsr->checkSessionRecordingExists($idSite, $idSiteHsr); + + $this->siteHsr->endSessionRecording($idSite, $idSiteHsr); + } + + /** + * Returns all non-deleted heatmaps for a website. + * + * The response can include active and ended heatmaps, but not deleted ones. + * + * @param int $idSite The numeric ID of the website to query. + * @param bool|int $includePageTreeMirror Whether to include the stored page tree mirror for each heatmap. + * + * @return array A list of heatmap configurations for the requested website. + */ + public function getHeatmaps($idSite, $includePageTreeMirror = true) + { + $this->validator->checkHeatmapReportViewPermission($idSite); + + return $this->siteHsr->getHeatmaps($idSite, !empty($includePageTreeMirror)); + } + + /** + * Returns all non-deleted session recordings for a website. + * + * The response can include active and ended session recordings, but not deleted ones. + * + * @param int $idSite The numeric ID of the website to query. + * + * @return array A list of session recording configurations for the requested website. + */ + public function getSessionRecordings($idSite) + { + $this->validator->checkSessionReportViewPermission($idSite); + + return $this->siteHsr->getSessionRecordings($idSite); + } + + /** + * Returns all page views that were recorded during a particular session / visit. We do not apply segments as it is + * used for video player when replaying sessions etc. + * + * @param int $idSite + * @param int $idSiteHsr The id of a session recording. [@example=13] + * @param int $idVisit The visit / session id + * @return array A collection of session recordings tied to a specific visit and within a period of time. + */ + private function getRecordedPageViewsInSession($idSite, $idSiteHsr, $idVisit, $period, $date) + { + $timezone = Site::getTimezoneFor($idSite); + + // ideally we would also check if idSiteHsr is actually linked to idLogHsr but not really needed for security reasons + $pageviews = $this->aggregator->getRecordedPageViewsInSession($idSite, $idSiteHsr, $idVisit, $period, $date, $segment = false); + + $isAnonymous = Piwik::isUserIsAnonymous(); + + foreach ($pageviews as &$pageview) { + $pageview['server_time_pretty'] = Date::factory($pageview['server_time'], $timezone)->getLocalized(DateTimeFormatProvider::DATETIME_FORMAT_SHORT); + + if ($isAnonymous) { + unset($pageview['idvisitor']); + } else { + $pageview['idvisitor'] = bin2hex($pageview['idvisitor']); + } + + $formatter = new Formatter(); + $pageview['time_on_page_pretty'] = $formatter->getPrettyTimeFromSeconds(intval($pageview['time_on_page'] / 1000), $asSentence = true); + } + + return $pageviews; + } + + /** + * Returns all recorded sessions for a specific session recording. + * + * Set `idSubtable` to a visit ID to return the recorded pageviews for that visit instead of the session list. + * + * @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 int $idSiteHsr The ID of the session recording configuration to query. + * @param string|null|false $segment Custom segment to filter the report. + * Example: "referrerName==example.com" + * Supports AND (;) and OR (,) operators. + * @param int|false $idSubtable Visit ID to fetch recorded pageviews for one visit instead of the session list. + * + * @return DataTable The recorded sessions, or the recorded pageviews for one visit when `idSubtable` is provided. + */ + public function getRecordedSessions($idSite, $period, $date, $idSiteHsr, $segment = false, $idSubtable = false): DataTable + { + $this->validator->checkSessionReportViewPermission($idSite); + $this->siteHsr->checkSessionRecordingExists($idSite, $idSiteHsr); + + $idVisit = $idSubtable; + + try { + PeriodFactory::checkPeriodIsEnabled($period); + } catch (\Exception $e) { + throw new Exception(Piwik::translate('HeatmapSessionRecording_PeriodDisabledErrorMessage', $period)); + } + + if (!empty($idVisit)) { + $recordings = $this->aggregator->getRecordedPageViewsInSession($idSite, $idSiteHsr, $idVisit, $period, $date, $segment); + } else { + $recordings = $this->aggregator->getRecordedSessions($idSite, $idSiteHsr, $period, $date, $segment); + } + + $table = new DataTable(); + $table->disableFilter('AddColumnsProcessedMetrics'); + $table->setMetadata('idSiteHsr', $idSiteHsr); + + if (!empty($recordings)) { + $table->addRowsFromSimpleArray($recordings); + } + + if (empty($idVisit)) { + $table->queueFilter(function (DataTable $table) { + foreach ($table->getRowsWithoutSummaryRow() as $row) { + if ($idVisit = $row->getColumn('idvisit')) { + $row->setNonLoadedSubtableId($idVisit); + } + } + }); + } else { + $table->disableFilter('Sort'); + } + + if (!method_exists(SettingsServer::class, 'isMatomoForWordPress') || !SettingsServer::isMatomoForWordPress()) { + $table->queueFilter(function (DataTable $table) use ($idSite, $idSiteHsr, $period, $date) { + foreach ($table->getRowsWithoutSummaryRow() as $row) { + $idLogHsr = $row->getColumn('idloghsr'); + $row->setMetadata('sessionReplayUrl', SiteHsrModel::completeWidgetUrl('replayRecording', 'idSiteHsr=' . (int) $idSiteHsr . '&idLogHsr=' . (int) $idLogHsr, $idSite, $period, $date)); + } + }); + } + + $table->filter('Piwik\Plugins\HeatmapSessionRecording\DataTable\Filter\EnrichRecordedSessions'); + + return $table; + } + + /** + * Get all activities of a specific recorded session. + * + * The response includes event data, related pageviews, viewport details, device information, and derived display + * metadata for replaying a recorded session. + * + * @param int $idSite The numeric ID of the website to query. + * @param int $idSiteHsr The ID of the session recording configuration to query. + * @param int $idLogHsr The ID of the recorded session entry to fetch. + * + * @return array The recorded session details, including events and recorded pageviews for the replay. + */ + public function getRecordedSession($idSite, $idSiteHsr, $idLogHsr) + { + $this->validator->checkSessionReportViewPermission($idSite); + $this->siteHsr->checkSessionRecordingExists($idSite, $idSiteHsr); + + // ideally we would also check if idSiteHsr is actually linked to idLogHsr but not really needed for security reasons + $session = $this->aggregator->getRecordedSession($idLogHsr); + + if (empty($session['idsite']) || empty($idSite)) { + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorSessionRecordingDoesNotExist')); + } + + if ($session['idsite'] != $idSite) { + // important otherwise can fetch any log entry! + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorSessionRecordingDoesNotExist')); + } + + $session['idvisitor'] = !empty($session['idvisitor']) ? bin2hex($session['idvisitor']) : ''; + + if (Piwik::isUserIsAnonymous()) { + foreach (EnrichRecordedSessions::getBlockedFields() as $blockedField) { + if (isset($session[$blockedField])) { + $session[$blockedField] = null; + } + } + } + + + $configBrowserName = !empty($session['config_browser_name']) ? $session['config_browser_name'] : ''; + $session['browser_name'] = \Piwik\Plugins\DevicesDetection\getBrowserName($configBrowserName); + $session['browser_logo'] = \Piwik\Plugins\DevicesDetection\getBrowserLogo($configBrowserName); + $configOs = !empty($session['config_os']) ? $session['config_os'] : ''; + $session['os_name'] = \Piwik\Plugins\DevicesDetection\getOsFullName($configOs); + $session['os_logo'] = \Piwik\Plugins\DevicesDetection\getOsLogo($configOs); + $session['device_name'] = \Piwik\Plugins\DevicesDetection\getDeviceTypeLabel($session['config_device_type']); + $session['device_logo'] = \Piwik\Plugins\DevicesDetection\getDeviceTypeLogo($session['config_device_type']); + + if (!empty($session['config_device_model'])) { + $session['device_name'] .= ', ' . $session['config_device_model']; + } + + $session['location_name'] = ''; + $session['location_logo'] = ''; + + if (!empty($session['location_country'])) { + $session['location_name'] = \Piwik\Plugins\UserCountry\countryTranslate($session['location_country']); + $session['location_logo'] = \Piwik\Plugins\UserCountry\getFlagFromCode($session['location_country']); + + if (!empty($session['location_region']) && $session['location_region'] != Visit::UNKNOWN_CODE) { + $session['location_name'] .= ', ' . \Piwik\Plugins\UserCountry\getRegionNameFromCodes($session['location_country'], $session['location_region']); + } + + if (!empty($session['location_city'])) { + $session['location_name'] .= ', ' . $session['location_city']; + } + } + + $timezone = Site::getTimezoneFor($idSite); + $session['server_time_pretty'] = Date::factory($session['server_time'], $timezone)->getLocalized(DateTimeFormatProvider::DATETIME_FORMAT_SHORT); + + $formatter = new Formatter(); + $session['time_on_page_pretty'] = $formatter->getPrettyTimeFromSeconds(intval($session['time_on_page'] / 1000), $asSentence = true); + + // we make sure to get all recorded pageviews in this session + $serverTime = Date::factory($session['server_time']); + $from = $serverTime->subDay(1)->toString(); + $to = $serverTime->addDay(1)->toString(); + + $period = 'range'; + $dateRange = $from . ',' . $to; + + $session['events'] = $this->logEvent->getEventsForPageview($idLogHsr); + $session['pageviews'] = $this->getRecordedPageViewsInSession($idSite, $idSiteHsr, $session['idvisit'], $period, $dateRange); + $session['numPageviews'] = count($session['pageviews']); + + return $session; + } + + /** + * Deletes all recorded page views within a recorded session. + * + * After deletion, the session replay is no longer available in the UI or through the API. + * + * @param int $idSite The numeric ID of the website that owns the recording. + * @param int $idSiteHsr The ID of the session recording configuration whose data should be deleted. + * @param int $idVisit The visit ID of the recorded session to delete. + * + * @return void + * + * @OA\Get( + * path="/index.php?module=API&method=HeatmapSessionRecording.deleteRecordedSession", + * operationId="HeatmapSessionRecording.deleteRecordedSession", + * tags={"HeatmapSessionRecording"}, + * @OA\Parameter(ref="#/components/parameters/formatOptional"), + * @OA\Parameter(ref="#/components/parameters/idSiteRequired"), + * @OA\Parameter( + * name="idSiteHsr", + * in="query", + * required=true, + * description="The id of the session recording you want to delete the data.", + * @OA\Schema( + * type="integer" + * ) + * ), + * @OA\Parameter( + * name="idVisit", + * in="query", + * required=true, + * description="The visitId of the recorded session you want to delete.", + * @OA\Schema( + * type="integer" + * ) + * ), + * @OA\Response(response=200, ref="#/components/responses/GenericSuccessNoBody"), + * @OA\Response(response=400, ref="#/components/responses/BadRequest"), + * @OA\Response(response=401, ref="#/components/responses/Unauthorized"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=500, ref="#/components/responses/ServerError"), + * @OA\Response(response="default", ref="#/components/responses/DefaultError") + * ) + */ + public function deleteRecordedSession($idSite, $idSiteHsr, $idVisit): void + { + $this->validator->checkSessionReportWritePermission($idSite); + // make sure the recording actually belongs to that site, otherwise could delete any recording for any other site + $this->siteHsr->checkSessionRecordingExists($idSite, $idSiteHsr); + + // we also need to make sure the visit actually belongs to that site + $idLogHsrs = $this->logHsr->findLogHsrIdsInVisit($idSite, $idVisit); + + foreach ($idLogHsrs as $idLogHsr) { + $this->logHsrSite->unlinkRecord($idLogHsr, $idSiteHsr); + } + } + + /** + * Deletes an individual page view within a recorded session. + * + * This removes only one recorded pageview, not the entire recorded session. + * + * @param int $idSite The numeric ID of the website that owns the recording. + * @param int $idSiteHsr The ID of the session recording configuration whose data should be deleted. + * @param int $idLogHsr The ID of the recorded pageview entry to delete. + * + * @return void + */ + public function deleteRecordedPageview($idSite, $idSiteHsr, $idLogHsr): void + { + $this->validator->checkWritePermission($idSite); + // make sure the recording actually belongs to that site, otherwise could delete any recording for any other site + $this->siteHsr->checkSessionRecordingExists($idSite, $idSiteHsr); + + $this->logHsrSite->unlinkRecord($idLogHsr, $idSiteHsr); + } + + /** + * Returns summary metadata for a recorded heatmap. + * + * The response includes sample counts and average above-the-fold values for each device type. + * + * @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 int $idSiteHsr The ID of the heatmap configuration to query. + * @param string|null|false $segment Custom segment to filter the report. + * Example: "referrerName==example.com" + * Supports AND (;) and OR (,) operators. + * + * @return array The recorded heatmap metadata for the requested period. + * + */ + public function getRecordedHeatmapMetadata($idSite, $period, $date, $idSiteHsr, $segment = false) + { + $this->validator->checkHeatmapReportViewPermission($idSite); + $this->siteHsr->checkHeatmapExists($idSite, $idSiteHsr); + + $samples = $this->aggregator->getRecordedHeatmapMetadata($idSiteHsr, $idSite, $period, $date, $segment); + + $result = array('nb_samples_device_all' => 0); + + foreach ($samples as $sample) { + $result['nb_samples_device_' . $sample['device_type']] = $sample['value']; + $result['avg_fold_device_' . $sample['device_type']] = round(($sample['avg_fold'] / LogHsr::SCROLL_ACCURACY) * 100, 1); + $result['nb_samples_device_all'] += $sample['value']; + } + + return $result; + } + + /** + * Get all activities of a heatmap. + * + * Use this endpoint to fetch aggregated click, move, or scroll activity for one device type and period. + * + * @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 int $idSiteHsr The ID of the heatmap configuration to query. + * @param int $heatmapType Heatmap activity type to return. + * @param int $deviceType Device type to return. + * @param string|null|false $segment Custom segment to filter the report. + * Example: "referrerName==example.com" + * Supports AND (;) and OR (,) operators. + * + * @return array The aggregated heatmap activity points for the requested heatmap, period, and device type. + * + */ + public function getRecordedHeatmap($idSite, $period, $date, $idSiteHsr, $heatmapType, $deviceType, $segment = false) + { + $this->validator->checkHeatmapReportViewPermission($idSite); + $this->siteHsr->checkHeatmapExists($idSite, $idSiteHsr); + + if ($heatmapType == RequestProcessor::EVENT_TYPE_SCROLL) { + $heatmap = $this->aggregator->aggregateScrollHeatmap($idSiteHsr, $deviceType, $idSite, $period, $date, $segment); + } else { + $heatmap = $this->aggregator->aggregateHeatmap($idSiteHsr, $heatmapType, $deviceType, $idSite, $period, $date, $segment); + } + + // we do not return dataTable here as it doubles the time it takes to call this method (eg 4s vs 7s when heaps of data) + // datatable is not really needed here as we don't want to sort it or so + return $heatmap; + } + + /** + * Returns the minimal session replay context needed by the embed view. + * + * @param int $idSite The numeric ID of the website to query. + * @param int $idSiteHsr The ID of the session recording configuration to query. + * @param int $idLogHsr The ID of the recorded session entry to fetch. + * + * @return array The embedded replay metadata for the requested recorded session. + * @hide + */ + public function getEmbedSessionInfo($idSite, $idSiteHsr, $idLogHsr) + { + $this->validator->checkSessionReportViewPermission($idSite); + + $aggregator = new Aggregator(); + return $aggregator->getEmbedSessionInfo($idSite, $idSiteHsr, $idLogHsr); + } + + /** + * Tests, checks whether the given URL matches the given page rules. + * + * Use this before saving a heatmap or session recording to validate that the configured rules match a URL. + * + * @param string $url The URL to test the matching rules against. + * @param array $matchPageRules Matching rules to evaluate against the URL. + * + * @return array The result of the test, including the URL and whether it matches all rules. + * + */ + public function testUrlMatchPages($url, $matchPageRules = array()) + { + $this->validator->checkHasSomeWritePermission(); + + if ($url === '' || $url === false || $url === null) { + return array('url' => '', 'matches' => false); + } + + if (!empty($matchPageRules) && !is_array($matchPageRules)) { + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorNotAnArray', 'matchPageRules')); + } + + $url = Common::unsanitizeInputValue($url); + + if (!empty($matchPageRules)) { + $pageRules = new PageRules($matchPageRules, '', $needsOneEntry = false); + $pageRules->check(); + } + + $matchPageRules = $this->unsanitizePageRules($matchPageRules); + + $allMatch = HsrMatcher::matchesAllPageRules($matchPageRules, $url); + + return array('url' => $url, 'matches' => $allMatch); + } + + /** + * Returns the valid heatmap and session recording statuses. + * + * @return array The list of potential statuses and their human-friendly names. + */ + public function getAvailableStatuses() + { + $this->validator->checkHasSomeWritePermission(); + + return array( + array('value' => SiteHsrDao::STATUS_ACTIVE, 'name' => Piwik::translate('HeatmapSessionRecording_StatusActive')), + array('value' => SiteHsrDao::STATUS_ENDED, 'name' => Piwik::translate('HeatmapSessionRecording_StatusEnded')), + array('value' => SiteHsrDao::STATUS_ONHOLD, 'name' => Piwik::translate('HeatmapSessionRecording_StatusOnHold')), + ); + } + + /** + * Returns the available target attributes and rule types for page matching. + * + * @return array The available components for building heatmap and session recording page rules. + */ + public function getAvailableTargetPageRules(): array + { + $this->validator->checkHasSomeWritePermission(); + + return PageRuleMatcher::getAvailableTargetTypes(); + } + + /** + * Returns the device types supported by the heatmap reporting endpoints. + * + * @return array The list of available device types and their human-friendly names. + */ + public function getAvailableDeviceTypes(): array + { + Piwik::checkUserHasSomeViewAccess(); + + return array( + array('name' => Piwik::translate('General_Desktop'), + 'key' => LogHsr::DEVICE_TYPE_DESKTOP, + 'logo' => 'plugins/Morpheus/icons/dist/devices/desktop.png'), + array('name' => Piwik::translate('DevicesDetection_Tablet'), + 'key' => LogHsr::DEVICE_TYPE_TABLET, + 'logo' => 'plugins/Morpheus/icons/dist/devices/tablet.png'), + array('name' => Piwik::translate('General_Mobile'), + 'key' => LogHsr::DEVICE_TYPE_MOBILE, + 'logo' => 'plugins/Morpheus/icons/dist/devices/smartphone.png'), + ); + } + + /** + * Returns the heatmap activity types supported by the heatmap reporting endpoints. + * + * @return array The list of available categories of heatmaps and their human-friendly names. + */ + public function getAvailableHeatmapTypes() + { + Piwik::checkUserHasSomeViewAccess(); + + return array( + array( + 'name' => Piwik::translate('HeatmapSessionRecording_ActivityClick'), + 'key' => RequestProcessor::EVENT_TYPE_CLICK), + array( + 'name' => Piwik::translate('HeatmapSessionRecording_ActivityMove'), + 'key' => RequestProcessor::EVENT_TYPE_MOVEMENT), + array( + 'name' => Piwik::translate('HeatmapSessionRecording_ActivityScroll'), + 'key' => RequestProcessor::EVENT_TYPE_SCROLL), + ); + } + + /** + * Get a list of available session recording sample limits. + * + * This is the suggested list shown in the UI. Direct API calls can still use other limits. + * + * @return array The list of available integer values that can be used as the session recording sample limit. + */ + public function getAvailableSessionRecordingSampleLimits() + { + $this->validator->checkHasSomeWritePermission(); + $this->validator->checkSessionRecordingEnabled(); + + return $this->configuration->getSessionRecordingSampleLimits(); + } + + /** + * Returns the event types that may appear in recorded session data. + * + * @return array The tracked event types and their human-friendly names. + */ + public function getEventTypes() + { + Piwik::checkUserHasSomeViewAccess(); + + return array( + array( + 'name' => Piwik::translate('HeatmapSessionRecording_ActivityMove'), + 'key' => RequestProcessor::EVENT_TYPE_MOVEMENT), + array( + 'name' => Piwik::translate('HeatmapSessionRecording_ActivityClick'), + 'key' => RequestProcessor::EVENT_TYPE_CLICK), + array( + 'name' => Piwik::translate('HeatmapSessionRecording_ActivityScroll'), + 'key' => RequestProcessor::EVENT_TYPE_SCROLL), + array( + 'name' => Piwik::translate('HeatmapSessionRecording_ActivityResize'), + 'key' => RequestProcessor::EVENT_TYPE_RESIZE), + array( + 'name' => Piwik::translate('HeatmapSessionRecording_ActivityInitialDom'), + 'key' => RequestProcessor::EVENT_TYPE_INITIAL_DOM), + array( + 'name' => Piwik::translate('HeatmapSessionRecording_ActivityPageChange'), + 'key' => RequestProcessor::EVENT_TYPE_MUTATION), + array( + 'name' => Piwik::translate('HeatmapSessionRecording_ActivityFormText'), + 'key' => RequestProcessor::EVENT_TYPE_FORM_TEXT), + array( + 'name' => Piwik::translate('HeatmapSessionRecording_ActivityFormValue'), + 'key' => RequestProcessor::EVENT_TYPE_FORM_VALUE), + array( + 'name' => Piwik::translate('HeatmapSessionRecording_ActivityScrollElement'), + 'key' => RequestProcessor::EVENT_TYPE_SCROLL_ELEMENT), + ); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Actions/ActionHsr.php b/files/plugin-HeatmapSessionRecording-5.6.0/Actions/ActionHsr.php new file mode 100644 index 0000000..8eb7998 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Actions/ActionHsr.php @@ -0,0 +1,61 @@ +getParam('url'); + + $this->setActionUrl($url); + } + + public static function shouldHandle(Request $request) + { + $params = $request->getParams(); + $isHsrRequest = Common::getRequestVar(RequestProcessor::TRACKING_PARAM_HSR_ID_VIEW, '', 'string', $params); + + return !empty($isHsrRequest); + } + + protected function getActionsToLookup() + { + return array(); + } + + // Do not track this Event URL as Entry/Exit Page URL (leave the existing entry/exit) + public function getIdActionUrlForEntryAndExitIds() + { + return false; + } + + // Do not track this Event Name as Entry/Exit Page Title (leave the existing entry/exit) + public function getIdActionNameForEntryAndExitIds() + { + return false; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Activity/BaseActivity.php b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/BaseActivity.php new file mode 100644 index 0000000..daac776 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/BaseActivity.php @@ -0,0 +1,114 @@ + $this->getSiteData($idSite), + 'version' => 'v1', + 'hsr' => $this->getHsrData($idSiteHsr, $idSite), + ); + } + + private function getSiteData($idSite) + { + return array( + 'site_id' => $idSite, + 'site_name' => Site::getNameFor($idSite) + ); + } + + private function getHsrData($idSiteHsr, $idSite) + { + $dao = $this->getDao(); + $hsr = $dao->getRecord($idSite, $idSiteHsr, SiteHsrDao::RECORD_TYPE_HEATMAP); + if (empty($hsr)) { + // maybe it is a session? we could make this faster by adding a new method to DAO that returns hsr independent of type + $hsr = $dao->getRecord($idSite, $idSiteHsr, SiteHsrDao::RECORD_TYPE_SESSION); + } + + $hsrName = ''; + if (!empty($hsr['name'])) { + // hsr name might not be set when we are handling deleteExperiment activity + $hsrName = $hsr['name']; + } + + return array( + 'id' => $idSiteHsr, + 'name' => $hsrName + ); + } + + public function getPerformingUser($eventData = null) + { + $login = Piwik::getCurrentUserLogin(); + + if ($login === self::USER_ANONYMOUS || empty($login)) { + // anonymous cannot change an experiment, in this case the system changed it, eg during tracking it started + // an experiment + return self::USER_SYSTEM; + } + + return $login; + } + + private function getDao() + { + // we do not get it via DI as it would slow down creation of all activities on all requests. Instead only + // create instance when needed + return StaticContainer::get('Piwik\Plugins\HeatmapSessionRecording\Dao\SiteHsrDao'); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Activity/HeatmapAdded.php b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/HeatmapAdded.php new file mode 100644 index 0000000..ad04966 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/HeatmapAdded.php @@ -0,0 +1,41 @@ +formatActivityData($idSiteHsr, $idSite); + } + + public function getTranslatedDescription($activityData, $performingUser) + { + $siteName = $this->getSiteNameFromActivityData($activityData); + $hsrName = $this->getHsrNameFromActivityData($activityData); + + return Piwik::translate('HeatmapSessionRecording_HeatmapAddedActivity', [$hsrName, $siteName]); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Activity/HeatmapDeleted.php b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/HeatmapDeleted.php new file mode 100644 index 0000000..fae6d25 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/HeatmapDeleted.php @@ -0,0 +1,46 @@ +formatActivityData($idSiteHsr, $idSite); + } + + public function getTranslatedDescription($activityData, $performingUser) + { + $siteName = $this->getSiteNameFromActivityData($activityData); + $hsrName = $this->getHsrNameFromActivityData($activityData); + + return Piwik::translate('HeatmapSessionRecording_HeatmapDeletedActivity', [$hsrName, $siteName]); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Activity/HeatmapEnded.php b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/HeatmapEnded.php new file mode 100644 index 0000000..28afe54 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/HeatmapEnded.php @@ -0,0 +1,44 @@ +formatActivityData($idSiteHsr, $idSite); + } + + public function getTranslatedDescription($activityData, $performingUser) + { + $siteName = $this->getSiteNameFromActivityData($activityData); + $hsrName = $this->getHsrNameFromActivityData($activityData); + + return Piwik::translate('HeatmapSessionRecording_HeatmapEndedActivity', [$hsrName, $siteName]); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Activity/HeatmapPaused.php b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/HeatmapPaused.php new file mode 100644 index 0000000..ddacfb5 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/HeatmapPaused.php @@ -0,0 +1,46 @@ +formatActivityData($idSiteHsr, $idSite); + } + + public function getTranslatedDescription($activityData, $performingUser) + { + $siteName = $this->getSiteNameFromActivityData($activityData); + $hsrName = $this->getHsrNameFromActivityData($activityData); + + return Piwik::translate('HeatmapSessionRecording_HeatmapPausedActivity', [$hsrName, $siteName]); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Activity/HeatmapResumed.php b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/HeatmapResumed.php new file mode 100644 index 0000000..9062306 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/HeatmapResumed.php @@ -0,0 +1,46 @@ +formatActivityData($idSiteHsr, $idSite); + } + + public function getTranslatedDescription($activityData, $performingUser) + { + $siteName = $this->getSiteNameFromActivityData($activityData); + $hsrName = $this->getHsrNameFromActivityData($activityData); + + return Piwik::translate('HeatmapSessionRecording_HeatmapResumedActivity', [$hsrName, $siteName]); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Activity/HeatmapScreenshotDeleted.php b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/HeatmapScreenshotDeleted.php new file mode 100644 index 0000000..4275d16 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/HeatmapScreenshotDeleted.php @@ -0,0 +1,46 @@ +formatActivityData($idSiteHsr, $idSite); + } + + public function getTranslatedDescription($activityData, $performingUser) + { + $siteName = $this->getSiteNameFromActivityData($activityData); + $hsrName = $this->getHsrNameFromActivityData($activityData); + + return Piwik::translate('HeatmapSessionRecording_HeatmapScreenshotDeletedActivity', [$hsrName, $siteName]); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Activity/HeatmapUpdated.php b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/HeatmapUpdated.php new file mode 100644 index 0000000..16b0636 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/HeatmapUpdated.php @@ -0,0 +1,42 @@ +formatActivityData($idSiteHsr, $idSite); + } + + public function getTranslatedDescription($activityData, $performingUser) + { + $siteName = $this->getSiteNameFromActivityData($activityData); + $hsrName = $this->getHsrNameFromActivityData($activityData); + + return Piwik::translate('HeatmapSessionRecording_HeatmapUpdatedActivity', [$hsrName, $siteName]); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Activity/RecordedPageviewDeleted.php b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/RecordedPageviewDeleted.php new file mode 100644 index 0000000..0c608b3 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/RecordedPageviewDeleted.php @@ -0,0 +1,46 @@ +formatActivityData($idSiteHsr, $idSite); + } + + public function getTranslatedDescription($activityData, $performingUser) + { + $siteName = $this->getSiteNameFromActivityData($activityData); + $hsrName = $this->getHsrNameFromActivityData($activityData); + + return Piwik::translate('HeatmapSessionRecording_RecordedPageviewDeletedActivity', [$hsrName, $siteName]); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Activity/RecordedSessionDeleted.php b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/RecordedSessionDeleted.php new file mode 100644 index 0000000..0eb581a --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/RecordedSessionDeleted.php @@ -0,0 +1,46 @@ +formatActivityData($idSiteHsr, $idSite); + } + + public function getTranslatedDescription($activityData, $performingUser) + { + $siteName = $this->getSiteNameFromActivityData($activityData); + $hsrName = $this->getHsrNameFromActivityData($activityData); + + return Piwik::translate('HeatmapSessionRecording_RecordedSessionDeletedActivity', [$hsrName, $siteName]); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Activity/SessionRecordingAdded.php b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/SessionRecordingAdded.php new file mode 100644 index 0000000..6178a0e --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/SessionRecordingAdded.php @@ -0,0 +1,41 @@ +formatActivityData($idSiteHsr, $idSite); + } + + public function getTranslatedDescription($activityData, $performingUser) + { + $siteName = $this->getSiteNameFromActivityData($activityData); + $hsrName = $this->getHsrNameFromActivityData($activityData); + + return Piwik::translate('HeatmapSessionRecording_SessionRecordingAddedActivity', [$hsrName, $siteName]); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Activity/SessionRecordingDeleted.php b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/SessionRecordingDeleted.php new file mode 100644 index 0000000..2f35f7f --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/SessionRecordingDeleted.php @@ -0,0 +1,46 @@ +formatActivityData($idSiteHsr, $idSite); + } + + public function getTranslatedDescription($activityData, $performingUser) + { + $siteName = $this->getSiteNameFromActivityData($activityData); + $hsrName = $this->getHsrNameFromActivityData($activityData); + + return Piwik::translate('HeatmapSessionRecording_SessionRecordingDeletedActivity', [$hsrName, $siteName]); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Activity/SessionRecordingEnded.php b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/SessionRecordingEnded.php new file mode 100644 index 0000000..dfd0df0 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/SessionRecordingEnded.php @@ -0,0 +1,44 @@ +formatActivityData($idSiteHsr, $idSite); + } + + public function getTranslatedDescription($activityData, $performingUser) + { + $siteName = $this->getSiteNameFromActivityData($activityData); + $hsrName = $this->getHsrNameFromActivityData($activityData); + + return Piwik::translate('HeatmapSessionRecording_SessionRecordingEndedActivity', [$hsrName, $siteName]); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Activity/SessionRecordingPaused.php b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/SessionRecordingPaused.php new file mode 100644 index 0000000..7b376fd --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/SessionRecordingPaused.php @@ -0,0 +1,46 @@ +formatActivityData($idSiteHsr, $idSite); + } + + public function getTranslatedDescription($activityData, $performingUser) + { + $siteName = $this->getSiteNameFromActivityData($activityData); + $hsrName = $this->getHsrNameFromActivityData($activityData); + + return Piwik::translate('HeatmapSessionRecording_SessionRecordingPausedActivity', [$hsrName, $siteName]); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Activity/SessionRecordingResumed.php b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/SessionRecordingResumed.php new file mode 100644 index 0000000..b89500d --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/SessionRecordingResumed.php @@ -0,0 +1,46 @@ +formatActivityData($idSiteHsr, $idSite); + } + + public function getTranslatedDescription($activityData, $performingUser) + { + $siteName = $this->getSiteNameFromActivityData($activityData); + $hsrName = $this->getHsrNameFromActivityData($activityData); + + return Piwik::translate('HeatmapSessionRecording_SessionRecordingResumedActivity', [$hsrName, $siteName]); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Activity/SessionRecordingUpdated.php b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/SessionRecordingUpdated.php new file mode 100644 index 0000000..fd35fa1 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Activity/SessionRecordingUpdated.php @@ -0,0 +1,42 @@ +formatActivityData($idSiteHsr, $idSite); + } + + public function getTranslatedDescription($activityData, $performingUser) + { + $siteName = $this->getSiteNameFromActivityData($activityData); + $hsrName = $this->getHsrNameFromActivityData($activityData); + + return Piwik::translate('HeatmapSessionRecording_SessionRecordingUpdatedActivity', [$hsrName, $siteName]); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Archiver/Aggregator.php b/files/plugin-HeatmapSessionRecording-5.6.0/Archiver/Aggregator.php new file mode 100644 index 0000000..bc15cef --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Archiver/Aggregator.php @@ -0,0 +1,476 @@ +forceSleepInQuery) { + $extraWhere = 'SLEEP(1) AND'; + } + + $query = sprintf( + 'SELECT /* HeatmapSessionRecording.findRecording */ hsrsite.idsitehsr, + min(hsr.idloghsr) as idloghsr + FROM %s hsr + LEFT JOIN %s hsrsite ON hsr.idloghsr = hsrsite.idloghsr + LEFT JOIN %s hsrevent ON hsrevent.idloghsr = hsr.idloghsr and hsrevent.event_type = %s + LEFT JOIN %s sitehsr ON hsrsite.idsitehsr = sitehsr.idsitehsr + WHERE %s hsr.idvisit = ? and sitehsr.record_type = ? and hsrevent.idhsrblob is not null and hsrsite.idsitehsr is not null + GROUP BY hsrsite.idsitehsr + LIMIT 1', + Common::prefixTable('log_hsr'), + Common::prefixTable('log_hsr_site'), + Common::prefixTable('log_hsr_event'), + RequestProcessor::EVENT_TYPE_INITIAL_DOM, + Common::prefixTable('site_hsr'), + $extraWhere + ); + + $readerDb = $this->getDbReader(); + $query = DbHelper::addMaxExecutionTimeHintToQuery($query, $this->getLiveQueryMaxExecutionTime()); + + try { + return $readerDb->fetchRow($query, array($idVisit, SiteHsrDao::RECORD_TYPE_SESSION)); + } catch (\Exception $e) { + Model::handleMaxExecutionTimeError($readerDb, $e, '', Date::now(), Date::now(), null, 0, ['sql' => $query]); + throw $e; + } + } + + private function getDbReader() + { + if (method_exists(Db::class, 'getReader')) { + return Db::getReader(); + } else { + return Db::get(); + } + } + + public function findRecordings($visitIds) + { + if (empty($visitIds)) { + return array(); + } + + $visitIds = array_map('intval', $visitIds); + + $extraWhere = ''; + if ($this->forceSleepInQuery) { + $extraWhere = 'SLEEP(1) AND'; + } + + $query = sprintf( + 'SELECT /* HeatmapSessionRecording.findRecordings */ hsrsite.idsitehsr, + min(hsr.idloghsr) as idloghsr, + hsr.idvisit + FROM %s hsr + LEFT JOIN %s hsrsite ON hsr.idloghsr = hsrsite.idloghsr + LEFT JOIN %s hsrevent ON hsrevent.idloghsr = hsr.idloghsr and hsrevent.event_type = %s + LEFT JOIN %s sitehsr ON hsrsite.idsitehsr = sitehsr.idsitehsr + WHERE %s hsr.idvisit IN ("%s") and sitehsr.record_type = ? and hsrevent.idhsrblob is not null and hsrsite.idsitehsr is not null + GROUP BY hsr.idvisit, hsrsite.idsitehsr', + Common::prefixTable('log_hsr'), + Common::prefixTable('log_hsr_site'), + Common::prefixTable('log_hsr_event'), + RequestProcessor::EVENT_TYPE_INITIAL_DOM, + Common::prefixTable('site_hsr'), + $extraWhere, + implode('","', $visitIds) + ); + + $readerDb = $this->getDbReader(); + $query = DbHelper::addMaxExecutionTimeHintToQuery($query, $this->getLiveQueryMaxExecutionTime()); + + try { + return $readerDb->fetchAll($query, array(SiteHsrDao::RECORD_TYPE_SESSION)); + } catch (\Exception $e) { + Model::handleMaxExecutionTimeError($readerDb, $e, '', Date::now(), Date::now(), null, 0, ['sql' => $query]); + throw $e; + } + } + + private function getLiveQueryMaxExecutionTime() + { + return Config::getInstance()->General['live_query_max_execution_time']; + } + + public function getEmbedSessionInfo($idSite, $idSiteHsr, $idLogHsr) + { + $logHsr = Common::prefixTable('log_hsr'); + $logHsrSite = Common::prefixTable('log_hsr_site'); + $logAction = Common::prefixTable('log_action'); + $logEvent = Common::prefixTable('log_hsr_event'); + $logBlob = Common::prefixTable('log_hsr_blob'); + + $query = sprintf( + 'SELECT laction.name as base_url, + laction.url_prefix, hsrblob.`value` as initial_mutation, hsrblob.compressed + FROM %s hsr + LEFT JOIN %s laction ON laction.idaction = hsr.idaction_url + LEFT JOIN %s hsr_site ON hsr_site.idloghsr = hsr.idloghsr + LEFT JOIN %s hsrevent ON hsrevent.idloghsr = hsr.idloghsr and hsrevent.event_type = %s + LEFT JOIN %s hsrblob ON hsrevent.idhsrblob = hsrblob.idhsrblob + WHERE hsr.idloghsr = ? and hsr.idsite = ? and hsr_site.idsitehsr = ? + and hsrevent.idhsrblob is not null and `hsrblob`.`value` is not null + LIMIT 1', + $logHsr, + $logAction, + $logHsrSite, + $logEvent, + RequestProcessor::EVENT_TYPE_INITIAL_DOM, + $logBlob + ); + + $row = $this->getDbReader()->fetchRow($query, array($idLogHsr, $idSite, $idSiteHsr)); + + if (!empty($row['compressed'])) { + $row['initial_mutation'] = gzuncompress($row['initial_mutation']); + } + + return $row; + } + + public function getRecordedSession($idLogHsr) + { + $select = 'log_action.name as url, + log_visit.idvisit, + log_visit.idvisitor, + log_hsr.idsite, + log_visit.location_country, + log_visit.location_region, + log_visit.location_city, + log_visit.config_os, + log_visit.config_device_type, + log_visit.config_device_model, + log_visit.config_browser_name, + log_hsr.time_on_page, + log_hsr.server_time, + log_hsr.viewport_w_px, + log_hsr.viewport_h_px, + log_hsr.scroll_y_max_relative, + log_hsr.fold_y_relative'; + + $logHsr = Common::prefixTable('log_hsr'); + $logVisit = Common::prefixTable('log_visit'); + $logAction = Common::prefixTable('log_action'); + + $query = sprintf('SELECT %s + FROM %s log_hsr + LEFT JOIN %s log_visit ON log_hsr.idvisit = log_visit.idvisit + LEFT JOIN %s log_action ON log_action.idaction = log_hsr.idaction_url + WHERE log_hsr.idloghsr = ?', $select, $logHsr, $logVisit, $logAction); + + return $this->getDbReader()->fetchRow($query, array($idLogHsr)); + } + + public function getRecordedSessions($idSite, $idSiteHsr, $period, $date, $segment) + { + $period = Period\Factory::build($period, $date); + $segment = new Segment($segment, array($idSite)); + $site = new Site($idSite); + + $from = array( + 'log_hsr', + array( + 'table' => 'log_hsr_site', + 'joinOn' => 'log_hsr_site.idloghsr = log_hsr.idloghsr' + ), + array( + 'table' => 'log_visit', + 'joinOn' => 'log_visit.idvisit = log_hsr.idvisit' + ), + array( + 'table' => 'log_action', + 'joinOn' => 'log_action.idaction = log_hsr.idaction_url' + ), + array( + 'table' => 'log_hsr_event', + 'joinOn' => 'log_hsr_event.idloghsr = log_hsr.idloghsr and log_hsr_event.event_type = ' . RequestProcessor::EVENT_TYPE_INITIAL_DOM + ) + ); + + // we need to make sure to show only sessions that have an initial mutation with time_since_load = 0, otherwise + // the recording won't work. + $logHsrEventTable = Common::prefixTable('log_hsr_event'); + + $actionQuery = sprintf('SELECT count(*) FROM %1$s as hsr_ev + WHERE hsr_ev.idloghsr = log_hsr_site.idloghsr and hsr_ev.event_type not in (%2$s, %3$s)', $logHsrEventTable, RequestProcessor::EVENT_TYPE_CSS, RequestProcessor::EVENT_TYPE_INITIAL_DOM); + + $select = 'log_hsr.idvisit as label, + count(*) as nb_pageviews, + log_hsr.idvisit, + SUBSTRING_INDEX(GROUP_CONCAT(CAST(log_action.name AS CHAR) ORDER BY log_hsr.server_time ASC SEPARATOR \'##\'), \'##\', 1) as first_url, + SUBSTRING_INDEX(GROUP_CONCAT(CAST(log_action.name AS CHAR) ORDER BY log_hsr.server_time DESC SEPARATOR \'##\'), \'##\', 1) as last_url, + sum(log_hsr.time_on_page) as time_on_site, + (' . $actionQuery . ') as total_events, + min(log_hsr_site.idloghsr) as idloghsr, + log_visit.idvisitor, + log_visit.location_country, + log_visit.location_region, + log_visit.location_city, + log_visit.config_os, + log_visit.config_device_type, + log_visit.config_device_model, + log_visit.config_browser_name, + min(log_hsr.server_time) as server_time'; + + $params = new ArchiveProcessor\Parameters($site, $period, $segment); + $logAggregator = new LogAggregator($params); + + $where = $logAggregator->getWhereStatement('log_hsr', 'server_time'); + $where .= sprintf(" and log_hsr_site.idsitehsr = %d and log_hsr_event.idhsrblob is not null", (int) $idSiteHsr); + $groupBy = 'log_hsr.idvisit'; + $orderBy = 'log_hsr.server_time DESC'; + + $revertSubselect = $this->applyForceSubselect($segment, 'log_hsr.idvisit,log_hsr_site.idloghsr'); + + $query = $logAggregator->generateQuery($select, $from, $where, $groupBy, $orderBy); + + if (!empty($revertSubselect) && is_callable($revertSubselect)) { + call_user_func($revertSubselect); + } + + $dbReader = $this->getDbReader(); + $query['sql'] = DbHelper::addMaxExecutionTimeHintToQuery($query['sql'], $this->getLiveQueryMaxExecutionTime()); + + try { + return $dbReader->fetchAll($query['sql'], $query['bind']); + } catch (\Exception $e) { + Model::handleMaxExecutionTimeError($dbReader, $e, '', Date::now(), Date::now(), null, 0, $query); + throw $e; + } + } + + private function applyForceSubselect($segment, $subselectForced) + { + // for performance reasons we use this and not `LogAggregator->allowUsageSegmentCache()` + // That's because this is a LIVE query and not archived... and HSR tables usually have few entries < 5000 + // so segmentation should be fairly fast using this method compared to allowUsageSegmentCache + // which would query the entire log_visit over several days with the applied query and then create the temp table + // and only then apply the log_hsr query. + // it should be a lot faster this way + if (class_exists('Piwik\DataAccess\LogQueryBuilder') && !$segment->isEmpty()) { + $logQueryBuilder = StaticContainer::get('Piwik\DataAccess\LogQueryBuilder'); + if ( + method_exists($logQueryBuilder, 'getForcedInnerGroupBySubselect') && + method_exists($logQueryBuilder, 'forceInnerGroupBySubselect') + ) { + $forceGroupByBackup = $logQueryBuilder->getForcedInnerGroupBySubselect(); + $logQueryBuilder->forceInnerGroupBySubselect($subselectForced); + + return function () use ($forceGroupByBackup, $logQueryBuilder) { + $logQueryBuilder->forceInnerGroupBySubselect($forceGroupByBackup); + }; + } + } + } + + public function getRecordedPageViewsInSession($idSite, $idSiteHsr, $idVisit, $period, $date, $segment) + { + $period = Period\Factory::build($period, $date); + $segment = new Segment($segment, array($idSite)); + $site = new Site($idSite); + + $from = array( + 'log_hsr', + array( + 'table' => 'log_hsr_site', + 'joinOn' => 'log_hsr_site.idloghsr = log_hsr.idloghsr' + ), + array( + 'table' => 'log_visit', + 'joinOn' => 'log_visit.idvisit = log_hsr.idvisit' + ), + array( + 'table' => 'log_action', + 'joinOn' => 'log_action.idaction = log_hsr.idaction_url' + ), + array( + 'table' => 'log_hsr_event', + 'joinOn' => 'log_hsr_event.idloghsr = log_hsr.idloghsr and log_hsr_event.event_type = ' . RequestProcessor::EVENT_TYPE_INITIAL_DOM + ) + ); + + // we need to make sure to show only sessions that have an initial mutation with time_since_load = 0, otherwise + // the recording won't work. If this happens often, we might "end / finish" a configured session recording + // earlier since we have eg recorded 1000 sessions, but user sees only 950 which will be confusing but we can + // for now not take this into consideration during tracking when we get number of available samples only using + // log_hsr_site to detect if the number of configured sessions have been reached. ideally we would at some point + // also make sure to include this check there but will be slower. + + $select = 'log_action.name as label, + log_visit.idvisitor, + log_hsr_site.idloghsr, + log_hsr.time_on_page as time_on_page, + CONCAT(log_hsr.viewport_w_px, "x", log_hsr.viewport_h_px) as resolution, + log_hsr.server_time, + log_hsr.scroll_y_max_relative, + log_hsr.fold_y_relative'; + + $params = new ArchiveProcessor\Parameters($site, $period, $segment); + $logAggregator = new LogAggregator($params); + + $where = $logAggregator->getWhereStatement('log_hsr', 'server_time'); + $where .= sprintf(" and log_hsr_site.idsitehsr = %d and log_hsr.idvisit = %d and log_hsr_event.idhsrblob is not null ", (int) $idSiteHsr, (int) $idVisit); + $groupBy = ''; + $orderBy = 'log_hsr.server_time ASC'; + + $revertSubselect = $this->applyForceSubselect($segment, 'log_hsr.idvisit,log_hsr_site.idloghsr'); + + $query = $logAggregator->generateQuery($select, $from, $where, $groupBy, $orderBy); + + if (!empty($revertSubselect) && is_callable($revertSubselect)) { + call_user_func($revertSubselect); + } + + return $this->getDbReader()->fetchAll($query['sql'], $query['bind']); + } + + public function aggregateHeatmap($idSiteHsr, $heatmapType, $deviceType, $idSite, $period, $date, $segment) + { + $heatmapTypeWhere = ''; + if ($heatmapType == RequestProcessor::EVENT_TYPE_CLICK) { + $heatmapTypeWhere .= 'log_hsr_event.event_type = ' . (int) $heatmapType; + } elseif ($heatmapType == RequestProcessor::EVENT_TYPE_MOVEMENT) { + $heatmapTypeWhere .= 'log_hsr_event.event_type IN(' . (int) RequestProcessor::EVENT_TYPE_MOVEMENT . ',' . (int) RequestProcessor::EVENT_TYPE_CLICK . ')'; + } else { + throw new \Exception('Heatmap type not supported'); + } + + $period = Period\Factory::build($period, $date); + $segment = new Segment($segment, array($idSite)); + $site = new Site($idSite); + + $from = array( + 'log_hsr', + array( + 'table' => 'log_hsr_site', + 'joinOn' => 'log_hsr_site.idloghsr = log_hsr.idloghsr' + ), + array( + 'table' => 'log_hsr_event', + 'joinOn' => 'log_hsr_site.idloghsr = log_hsr_event.idloghsr' + ), + array( + 'table' => 'log_action', + 'joinOn' => 'log_action.idaction = log_hsr_event.idselector' + ) + ); + + $select = 'log_action.name as selector, + log_hsr_event.x as offset_x, + log_hsr_event.y as offset_y, + count(*) as value'; + + $params = new ArchiveProcessor\Parameters($site, $period, $segment); + $logAggregator = new LogAggregator($params); + + $where = $logAggregator->getWhereStatement('log_hsr', 'server_time'); + $where .= ' and log_hsr_site.idsitehsr = ' . (int) $idSiteHsr . ' and log_hsr_event.idselector is not null and ' . $heatmapTypeWhere; + $where .= ' and log_hsr.device_type = ' . (int) $deviceType; + + $groupBy = 'log_hsr_event.idselector, log_hsr_event.x, log_hsr_event.y'; + $orderBy = ''; + + $query = $logAggregator->generateQuery($select, $from, $where, $groupBy, $orderBy); + + return $this->getDbReader()->fetchAll($query['sql'], $query['bind']); + } + + public function getRecordedHeatmapMetadata($idSiteHsr, $idSite, $period, $date, $segment) + { + $period = Period\Factory::build($period, $date); + $segment = new Segment($segment, array($idSite)); + $site = new Site($idSite); + + $from = array( + 'log_hsr', + array( + 'table' => 'log_hsr_site', + 'joinOn' => 'log_hsr_site.idloghsr = log_hsr.idloghsr' + ) + ); + + $select = 'log_hsr.device_type, count(*) as value, avg(log_hsr.fold_y_relative) as avg_fold'; + + $params = new ArchiveProcessor\Parameters($site, $period, $segment); + $logAggregator = new LogAggregator($params); + + $where = $logAggregator->getWhereStatement('log_hsr', 'server_time'); + $where .= ' and log_hsr_site.idsitehsr = ' . (int) $idSiteHsr; + $groupBy = 'log_hsr.device_type'; + $orderBy = ''; + + $query = $logAggregator->generateQuery($select, $from, $where, $groupBy, $orderBy); + + return $this->getDbReader()->fetchAll($query['sql'], $query['bind']); + } + + public function aggregateScrollHeatmap($idSiteHsr, $deviceType, $idSite, $period, $date, $segment) + { + $period = Period\Factory::build($period, $date); + $segment = new Segment($segment, array($idSite)); + $site = new Site($idSite); + + $from = array('log_hsr', + array( + 'table' => 'log_hsr_site', + 'joinOn' => 'log_hsr_site.idloghsr = log_hsr.idloghsr' + ), + ); + + $select = 'log_hsr.scroll_y_max_relative as label, + count(*) as value'; + + $params = new ArchiveProcessor\Parameters($site, $period, $segment); + $logAggregator = new LogAggregator($params); + $where = $logAggregator->getWhereStatement('log_hsr', 'server_time'); + $where .= ' and log_hsr_site.idsitehsr = ' . (int) $idSiteHsr; + $where .= ' and log_hsr.device_type = ' . (int) $deviceType; + + $groupBy = 'log_hsr.scroll_y_max_relative'; + $orderBy = 'label ASC'; // labels are no from 0-1000 i.e page from top to bottom, so top label should always come first 0..100..500..1000 + + $query = $logAggregator->generateQuery($select, $from, $where, $groupBy, $orderBy); + + return $this->getDbReader()->fetchAll($query['sql'], $query['bind']); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/CHANGELOG.md b/files/plugin-HeatmapSessionRecording-5.6.0/CHANGELOG.md new file mode 100644 index 0000000..342c4a6 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/CHANGELOG.md @@ -0,0 +1,514 @@ +## Changelog + +5.6.0 - 2026-08-03 +- Added option to export heatmaps to png +- Added a heatmap settings menu with a new delete and retake snapshot workflow +- Added empty state screens for heatmaps without a snapshot +- Deleting a snapshot is now possible for heatmaps that have finished recording +- The `HeatmapSessionRecording.deleteHeatmapScreenshot` API now accepts a `captureManually` parameter + +5.5.4 - 2026-07-27 +- Updated 2FA logic +- Added unsubscribe button to email when heatmap or session recording has finished + +5.5.3 - 2026-07-20 +- Updated Marketplace description +- Updated the finished heatmap and session recording email links in order to show the correct notification if the heatmap or session recording was deleted + +5.5.2 - 2026-07-13 +- Updated heatmap colors to be more accessible +- Removed horizontal line on target page component + +5.5.1 - 2026-07-06 +- Added two modes to target component, simple & advanced +- Redesigned view heatmap report page + +5.5.0 - 2026-06-29 +- Added email to notify create user when heatmap or session recording has finished + +5.4.1 - 2026-06-22 +- Added code to harden the client-side check before displaying DOM values +- Added code to make advanced settings an accordion + +5.4.0 - 2026-05-25 +- Added descriptions to heatmap and session recording creation forms +- Updated heatmap and session recording creation inline helptexts + +5.3.5 - 2026-04-14 +- Added code to ignore paused state when deleting logs + +5.3.4 - 2026-04-13 +- Added code to block more xss tags + +5.3.3 - 2025-12-08 +- Updated preview screenshot and video url + +5.3.2 - 2025-11-10 +- Fixes constant not loading errors during configs.php request + +5.3.1 - 2025-10-27 +- Update some CNIL policy setting text to be more readable + +5.3.0 - 2025-10-13 +- Added support for core CNIL policy control of setting values +- Added triggering of event when reports are copied + +5.2.7 - 2025-09-29 +- Adjusted action icons to ensure that delete is last + +5.2.6 - 2025-08-04 +- Added ability to copy configured heatmaps when Matomo 5.4.0-b4 or later is installed + +5.2.5 - 2025-07-07 +- Textual changes + +5.2.4 - 2025-06-09 +- Started showing the troubleshooting link even when no heatmap sample has been recorded +- Do not crash when displaying a heatmap for a page with invalid HTML + +5.2.3 - 2025-01-20 +- Added an activity for pause and resume action +- Added a troubleshooting FAQ link for heatmaps + +5.2.2 - 2024-12-16 +- Fixes PHP deprecation warnings + +5.2.1 - 2024-12-02 +- Added activities to track deleting recorded sessions and page views + +5.2.0 - 2024-11-04 +- Implemented a tooltip which displays click count and rate + +5.1.8 - 2024-10-17 +- Fixes excluded_elements not working for escaped values for a heatmap + +5.1.7 - 2024-10-11 +- Fixes classes with word script being removed due to xss filtering + +5.1.6 - 2024-08-26 +- Pricing updated + +5.1.5 +- Added cover image for marketplace + +5.1.4 +- Fixes captureInitialDom not working for single heatmap + +5.1.3 +- Added code to disable matomo.js file writable check code for Matomo Cloud + +5.1.2 +- Added code to alert if matomo.js is not writable + +5.1.1 +- Fixed applying segment returns error for SessionRecording + +5.1.0 +- Added an option to capture Heatmap DOM on demand + +5.0.10 +- Added total actions column in Session Recording listing page + +5.0.9 +- Added code to keep playing on resize event +- Added code to update Translation keys via event + +5.0.8 +- Changes for README.md +- Fixed an error that occurs when viewing posts that have heatmaps associated in WordPress. + +5.0.7 +- Fixed issue where form fields that were supposed to be unmasked weren't +- Added code to pause/resume heatmap for Matomo Cloud + +5.0.6 +- Fixed input[type="button"] background being ignored +- Added code to display AdBlocker banner when detected + +5.0.5 +- Fixed regression where good configs were disabled + +5.0.4 +- Fixed location provider not loading for cloud customers + +5.0.3 +- Fixed error when location provider is null + +5.0.2 +- Added option to fire heatmap/session recording only for certain geographies + +5.0.1 +- Compatibility with Matomo 5.0.0-b4 + +5.0.0 +- Compatibility with Matomo 5 + +4.5.10 +- Started skipping deletion of heatmap and session recordings for proxysite + +4.5.9 +- Started hiding period selector when viewing heatmaps + +4.5.8 +- Fixed scroll data not displaying correctly due to sort missing + +4.5.7 +- Fixed deprecation warnings for PHP 8.1 + +4.5.6 +- Changed time_on_page column to BIGINT for new installation for log_hsr and log_hsr_event table + +4.5.5 +- Fixed session recording not masking image with `data-matomo-mask` attribute set on parent node + +4.5.4 +- Fixed unmasking issue for text-node elements +- Fixed recording to not end on tabs switch + +4.5.3 +- Added support to pass media attribute if present for external stylesheets + +4.5.2 +- Made regex to work consistently, #PG-373 +- Added examples of possible xss from portswigger.net + +4.5.1 +- Fixed mutation id bug to load css from DB + +4.5.0 +- Starting migrating AngularJS to Vue. +- Migrated view code to VueJs +- Updated code to respect max execution time during Archiving + +4.4.3 +- Added code to remove attributes with possible XSS values + +4.4.2 +- Added support for lazy loaded images + +4.4.1 +- Fixed masking issue for dynamically added DOM elements + +4.4.0 +- Added option to disable heatmap independently +- Stopped showing visitor profile icon in session recording when visitor profile is disabled + +4.3.1 +- Fixed recorded session link not working for segmented logs in visit action + +4.3.0 +- Started storing CSS content in DB +- Fixed range error when range is disabled + +4.2.1 +- Fixed double encoded segments + +4.2.0 +- Fixed heatmap not triggering when tracker configured directly. +- Added masking for images with height and width +- Added masking for [input type="image"] +- Fixed non-masking bug for child elements with data-matomo-unmask + +4.1.2 +- Fix to record inputs with data-matomo-unmask + +4.1.1 +- Removed masking for input type button, submit and reset + +4.1.0 +- Added option to disable session recording independently + +4.0.14 +- Support Matomo's new content security policy header + +4.0.13 +- Fix sharing a session might not work anymore with latest Matomo version + +4.0.12 +- Ensure configs.php is loaded correctly with multiple trackers +- Translation updates + +4.0.11 +- Improve handling of attribute changes +- Add translations for Czech, Dutch & Portuguese + +4.0.10 +- Further improvements for loading for iframes + +4.0.9 +- Improve loading for iframes + +4.0.8 +- Improve tracking react pages + +4.0.7 +- Add category help texts +- Increase possible sample limit +- jQuery 3 compatibility for WP + +4.0.6 +- Performance improvements + +4.0.4 +- Compatibility with Matomo 4.X + +4.0.3 +- Compatibility with Matomo 4.X + +4.0.2 +- Compatibility with Matomo 4.X + +4.0.1 +- Handle base URLs better + +4.0.0 +- Compatibility with Matomo 4.X + +3.2.39 +- Better handling for base URL + +3.2.38 +- Improve SPA tracking + +3.2.37 +- Improve sorting of server time + +3.2.36 +- Fix number of recorded pages may be wrong when a segment is applied + +3.2.35 +- Improve widgetize feature when embedded as iframe + +3.2.34 +- Further improvements for WordPress + +3.2.33 +- Improve compatibilty with WordPress + +3.2.32 +- Improve checking for number of previously recorded sessions + +3.2.31 +- Matomo for WordPress support + +3.2.30 +- Send less tracking requests by queueing more requests together + +3.2.29 +- Use DB reader in Aggregator for better compatibility with Matomo 3.12 + +3.2.28 +- Improvements for Matomo 3.12 to support faster segment archiving +- Better support for single page applications + +3.2.27 + - Show search box for entities + - Support usage of a reader DB when configured + +3.2.26 + - Tracker improvements + +3.2.25 + - Tracker improvements + +3.2.24 + - Generate correct session recording link when a visitor matches multiple recordings in the visitor log + +3.2.23 + - Internal tracker performance improvements + +3.2.22 + - Add more translations + - Tracker improvements + - Internal changes + +3.2.21 + - title-text of JavaScript Tracking option help box shows HTML + - Add primary key to log_event table for new installs (existing users should receive the update with Matomo 4) + +3.2.20 + - Fix tracker may under circumstances not enable tracking after disabling it manually + +3.2.19 + - Add possibility to delete an already taken heatmap screenshot so it can be re-taken + +3.2.18 + - Performance improvements for high traffic websites + +3.2.17 + - Add possibility to define alternative CSS file through `data-matomo-href` + - Added new API method `HeatmapSessionRecording.deleteHeatmapScreenshot` to delete an already taken heatmap screenshot + - Add possibility to delete an already taken heatmap screenshot so it can be re-taken + +3.2.16 + - Add useDateUrl=0 to default Heatmap export URL so it can be used easier + +3.2.15 + - Support a URL parameter &useDateUrl=1 in exported heatmaps to fetch heatmaps only for a specific date range + +3.2.14 + - Improve compatibility with tag manager + - Fix possible notice when matching url array parameters + - Add command to remove a stored heatmap + +3.2.13 + - Fix some coordinate cannot be calculated for SVG elements + - Added more languages + - Use new brand colors + - If time on page is too high, abort the tracking request + +3.2.12 + - Update tracker file + +3.2.11 + - Add possibility to mask images + +3.2.10 + - Make sure to replay scrolling in element correctly + +3.2.9 + - Change min height of heatmaps to 400 pixels. + +3.2.8 + - When widgetizing the session player it bursts out of the iframe + - Log more debug information in tracker + - Use API calls instead of model + +3.2.7 + - Support new "Write" role + +3.2.6 + - Improve compatibility with styled-components and similar projects + - Add possibility to not record mouse and touch movements. + +3.2.5 + - Compatibility with SiteUrlTrackingID plugin + - Ensure selectors are generated correctly + +3.2.4 + - Allow users to pass sample limit of zero for unlimited recordings + - Show which page view within a session is currently being replayed + +3.2.3 + - In configs.php return a 403 if Matomo is not installed yet + +3.2.2 + - Validate an entered regular expression when configuring a heatmap or session recording + - Improve heatmap rendering of sharepoint sites + +3.2.1 + - Improve the rendering of heatmaps and session recordings + +3.2.0 + - Optimize tracker cache file + - Prevent recording injected CSS resources that only work on a visitors' computer such as Kaspersky Antivirus CSS. + - For better GDPR compliance disable capture keystroke in sessions by default. + - Added logic to support Matomo GDPR features + - Only specifically whitelisted form fields can now be recorded in plain text + - Some form fields that could potentially include personal information such as an address will be always masked and anonymized + - Trim any whitespace when configuring target pages + +3.1.9 + - Support new attribute `data-matomo-mask` which works similar to `data-piwik-mask` but additionally allows to mask content of elements. + +3.1.8 + - Support new CSS rendering classes matomoHsr, matomoHeatmap and matomoSessionRecording + - For input text fields prefer a set value on the element directly + - Differentiate between scrolling of the window and scrolling within an element (part of the window) + - Replay in the recorded session when a user is scrolling within an element + +3.1.7 + - Make sure validating URL works correctly with HTML entities + - Prevent possible fatal error when opening manage screen for all websites + +3.1.6 + - Renamed Piwik to Matomo + +3.1.5 + - Fix requested stylesheet URLs were requested lowercase when using a relative base href in the recorded page + - Show more accurate time on page and record pageviews for a longer period in case a user is not active right away. + +3.1.4 + - Prevent target rules in heatmap or session recording to visually disappear under circumstances when not using the cancel or back button. + - Respect URL prefix (eg www.) when replaying a session recording, may fix some displaying issues if website does not work without www. + - Improved look of widgetized session recording + +3.1.3 + - Make Heatmap & Session Recording compatible with canvas and webgl libraries like threejs and earcut + - Better detected of the embedded heatmap height + - Fix scroll heatmap did not paint the last scroll section correctly + - It is now possible to configure the sample limits in the config via `[HeatmapSessionRecording] session_recording_sample_limits = 50,100,...` + +3.1.2 + - Added URL to view heatmap and to replay a session recording to the API response + - Fix widgetized URL for heatmaps and sessions redirected to another page when authenticated via token_auth + +3.1.1 + - Better error code when a site does not exist + - Fix configs.php may fail if plugins directory is a symlink + - Available sessions are now also displayed in the visitor profile + +3.1.0 + - Added autoplay feature for page views within a visit + - Added possibility to change replay speed + - Added possibility to skip long pauses in a session recording automatically + - Better base URL detection in case a relative base URL is used + +3.0.15 + - Fix only max 100 heatmaps or session recordings were shown when managing them for a specific site. + - Mask closing body in embedded page so it won't be replaced by some server logic + +3.0.14 + - Make sure to find all matches for a root folder when "equals simple" is used + +3.0.13 + - Fix a custom set based URL was ignored. + +3.0.12 + - Fix session recording stops when a user changes a file form field because form value is not allowed to be changed. + +3.0.11 + - Improve the performance of a DB query of a daily task when cleaning up blob entries. + +3.0.10 + - Improve the performance of a DB query of a daily task + - Respect the new config setting `enable_internet_features` in the system check + +3.0.9 + - Make sure page rules work fine when using HTML entities + +3.0.8 + - Fix possible notice when tracking + - Avoid some logs in chrome when viewing a heatmaps or session recordings + - Always prefer same protocol when replaying sessions as currently used + +3.0.7 + - When using an "equals exactly" comparison, ignore a trailing slash when there is no path set + - Let users customize if the tracking code should be included only when active records are configured + +3.0.6 + - Fix link to replay session in visitor log may not work under circumstances + +3.0.5 + - More detailed "no data message" when nothing has been recorded yet + - Fix select fields were not recorded + +3.0.4 + - Only add tracker code when heatmap or sessions are actually active in any site + - Added index on site_hsr table + - Add custom stylesheets for custom styling + +3.0.3 + - Add system check for configs.php + - On install, if .htaccess was not created, create the file manually + +3.0.2 + - Enrich system summary widget + - Show an arrow instead of a dash between entry and exit url + - Added some German translations + +3.0.1 + - Updated translations + +3.0.0 + - Heatmap & Session Recording for Piwik 3 diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Categories/HeatmapCategory.php b/files/plugin-HeatmapSessionRecording-5.6.0/Categories/HeatmapCategory.php new file mode 100644 index 0000000..946c2ea --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Categories/HeatmapCategory.php @@ -0,0 +1,26 @@ +' . Piwik::translate('HeatmapSessionRecording_ManageHeatmapSubcategoryHelp') . '

'; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Categories/ManageSessionRecordingSubcategory.php b/files/plugin-HeatmapSessionRecording-5.6.0/Categories/ManageSessionRecordingSubcategory.php new file mode 100644 index 0000000..fa5c615 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Categories/ManageSessionRecordingSubcategory.php @@ -0,0 +1,32 @@ +' . Piwik::translate('HeatmapSessionRecording_ManageSessionRecordingSubcategoryHelp') . '

'; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Categories/SessionRecordingsCategory.php b/files/plugin-HeatmapSessionRecording-5.6.0/Categories/SessionRecordingsCategory.php new file mode 100644 index 0000000..f376af1 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Categories/SessionRecordingsCategory.php @@ -0,0 +1,26 @@ +getMetric($row, 'config_browser_name'); + } + + public function getDependentMetrics() + { + return array( + 'config_browser_name', + ); + } + + public function showsHtml() + { + return true; + } + + public function format($value, Formatter $formatter) + { + if (empty($value) || $value === 'UNK') { + return false; + } + + $title = \Piwik\Plugins\DevicesDetection\getBrowserName($value); + + return ''; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Columns/Metrics/Device.php b/files/plugin-HeatmapSessionRecording-5.6.0/Columns/Metrics/Device.php new file mode 100644 index 0000000..c0c0c6b --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Columns/Metrics/Device.php @@ -0,0 +1,78 @@ + $this->getMetric($row, 'config_device_type'), + 'model' => $this->getMetric($row, 'config_device_model') + ); + } + + public function getDependentMetrics() + { + return array( + 'config_device_type', + 'config_device_model', + ); + } + + public function showsHtml() + { + return true; + } + + public function format($value, Formatter $formatter) + { + if (empty($value['type']) && $value['type'] !== 0 && $value['type'] !== '0') { + return false; + } + + $title = \Piwik\Plugins\DevicesDetection\getDeviceTypeLabel($value['type']); + + if (!empty($value['model'])) { + $title .= ', ' . SafeDecodeLabel::decodeLabelSafe($value['model']); + } + + return ''; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Columns/Metrics/Location.php b/files/plugin-HeatmapSessionRecording-5.6.0/Columns/Metrics/Location.php new file mode 100644 index 0000000..671a658 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Columns/Metrics/Location.php @@ -0,0 +1,86 @@ + $this->getMetric($row, 'location_country'), + 'region' => $this->getMetric($row, 'location_region'), + 'city' => $this->getMetric($row, 'location_city'), + ); + } + + public function getDependentMetrics() + { + return array( + 'location_country', + 'location_region', + 'location_city' + ); + } + + public function showsHtml() + { + return true; + } + + public function format($value, Formatter $formatter) + { + if (empty($value['country']) || $value['country'] === Visit::UNKNOWN_CODE) { + return false; + } + + $title = \Piwik\Plugins\UserCountry\countryTranslate($value['country']); + + if (!empty($value['region']) && $value['region'] !== Visit::UNKNOWN_CODE) { + $title .= ', ' . \Piwik\Plugins\UserCountry\getRegionNameFromCodes($value['country'], $value['region']); + } + + if (!empty($value['city'])) { + $title .= ', ' . SafeDecodeLabel::decodeLabelSafe($value['city']); + } + + return ''; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Columns/Metrics/OperatingSystem.php b/files/plugin-HeatmapSessionRecording-5.6.0/Columns/Metrics/OperatingSystem.php new file mode 100644 index 0000000..d78691b --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Columns/Metrics/OperatingSystem.php @@ -0,0 +1,67 @@ +getMetric($row, 'config_os'); + } + + public function getDependentMetrics() + { + return array( + 'config_os', + ); + } + + public function showsHtml() + { + return true; + } + + public function format($value, Formatter $formatter) + { + if (empty($value) || $value === 'UNK') { + return false; + } + + $title = \Piwik\Plugins\DevicesDetection\getOsFullName($value); + + return ''; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Columns/Metrics/SessionTime.php b/files/plugin-HeatmapSessionRecording-5.6.0/Columns/Metrics/SessionTime.php new file mode 100644 index 0000000..0de98a4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Columns/Metrics/SessionTime.php @@ -0,0 +1,97 @@ +dateFormat = $dateFormat; + } + + public function getName() + { + return 'server_time'; + } + + public function getTranslatedName() + { + return Piwik::translate('HeatmapSessionRecording_ColumnTime'); + } + + public function getDocumentation() + { + return Piwik::translate('HeatmapSessionRecording_ColumnTimeDocumentation'); + } + + public function compute(Row $row) + { + return $this->getMetric($row, 'server_time'); + } + + public function getDependentMetrics() + { + return array($this->getName()); + } + + public function format($value, Formatter $formatter) + { + $date = Date::factory($value, $this->timezone); + + $dateTimeFormatProvider = StaticContainer::get('Piwik\Intl\Data\Provider\DateTimeFormatProvider'); + + $template = $dateTimeFormatProvider->getFormatPattern($this->dateFormat); + $template = str_replace(array(' y ', '.y '), ' ', $template); + + return $date->getLocalized($template); + } + + public function beforeFormat($report, DataTable $table) + { + $this->idSite = DataTableFactory::getSiteIdFromMetadata($table); + if (empty($this->idSite)) { + $this->idSite = Common::getRequestVar('idSite', 0, 'int'); + } + if (!empty($this->idSite)) { + $this->timezone = Site::getTimezoneFor($this->idSite); + return true; + } + return false; // skip formatting if there is no site to get currency info from + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Columns/Metrics/TimeOnPage.php b/files/plugin-HeatmapSessionRecording-5.6.0/Columns/Metrics/TimeOnPage.php new file mode 100644 index 0000000..506472d --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Columns/Metrics/TimeOnPage.php @@ -0,0 +1,65 @@ +getMetric($row, $this->getName()); + } + + public function getDependentMetrics() + { + return array($this->getName()); + } + + public function format($value, Formatter $formatter) + { + if (!empty($value)) { + $value = round($value / 1000, 1); // convert ms to seconds + $value = (int) round($value); + } + + $time = $formatter->getPrettyTimeFromSeconds($value, $asSentence = false); + + if (strpos($time, '00:') === 0) { + $time = substr($time, 3); + } + + return $time; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Columns/Metrics/TimeOnSite.php b/files/plugin-HeatmapSessionRecording-5.6.0/Columns/Metrics/TimeOnSite.php new file mode 100644 index 0000000..2ba27d2 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Columns/Metrics/TimeOnSite.php @@ -0,0 +1,37 @@ +getMetric($row, 'total_events'); + } + + public function getDependentMetrics() + { + return []; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Commands/RemoveHeatmapScreenshot.php b/files/plugin-HeatmapSessionRecording-5.6.0/Commands/RemoveHeatmapScreenshot.php new file mode 100644 index 0000000..86783de --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Commands/RemoveHeatmapScreenshot.php @@ -0,0 +1,101 @@ +setName('heatmapsessionrecording:remove-heatmap-screenshot'); + $this->setDescription('Removes a saved heatmap screenshot which can be useful if you want Matomo to re-take this screenshot. If the heatmap is currently ended, it will automatically restart it.'); + $this->addRequiredValueOption('idsite', null, 'The ID of the site the heatmap belongs to'); + $this->addRequiredValueOption('idheatmap', null, 'The ID of the heatamp'); + } + + /** + * @return int + */ + protected function doExecute(): int + { + $this->checkAllRequiredOptionsAreNotEmpty(); + $input = $this->getInput(); + $output = $this->getOutput(); + $idSite = $input->getOption('idsite'); + $idHeatmap = $input->getOption('idheatmap'); + + $heatmap = Request::processRequest('HeatmapSessionRecording.getHeatmap', array( + 'idSite' => $idSite, + 'idSiteHsr' => $idHeatmap + )); + + if ($heatmap['status'] === SiteHsrDao::STATUS_ENDED) { + $logHsrSite = new LogHsrSite(); + $numSamplesTakenSoFar = $logHsrSite->getNumPageViews($idHeatmap); + + $currentSampleLimit = $heatmap['sample_limit']; + $newSampleLimit = $numSamplesTakenSoFar + 50; // 50 heatmaps should be enough to collect at least once the dom. + + $update = array('status' => SiteHsrDao::STATUS_ACTIVE); + if ($currentSampleLimit >= $newSampleLimit) { + $output->writeln('Sample limit remains unchanged at ' . $currentSampleLimit); + if ($currentSampleLimit - $numSamplesTakenSoFar > 75) { + $output->writeln('make sure to end the heatmap again as soon as a screenshot has been taken!'); + } + } else { + $output->writeln('Going to increase sample limit from ' . $currentSampleLimit . ' to ' . $newSampleLimit . ' so a screenshot can be retaken. The heatmap will be automatically ended after about 50 new recordings have been recorded.'); + $output->writeln('Note: This means when you manage this heatmap the selected sample wont be shown correctly in the select field'); + $update['sample_limit'] = $newSampleLimit; + } + + $output->writeln('Going to change status of heatmap from ended to active'); + + $siteHsr = StaticContainer::get(SiteHsrDao::class); + $siteHsr->updateHsrColumns($idSite, $idHeatmap, array( + 'status' => SiteHsrDao::STATUS_ACTIVE, + 'sample_limit' => $newSampleLimit + )); + $output->writeln('Done'); + } + + $success = Request::processRequest('HeatmapSessionRecording.deleteHeatmapScreenshot', array( + 'idSite' => $idSite, + 'idSiteHsr' => $idHeatmap + )); + + if ($success) { + Filesystem::deleteAllCacheOnUpdate(); + /** @var HeatmapSessionRecording $hsr */ + $hsr = Plugin\Manager::getInstance()->getLoadedPlugin('HeatmapSessionRecording'); + $hsr->updatePiwikTracker(); + $output->writeln('Screenhot removed'); + + return self::SUCCESS; + } + + $output->writeln('Heatmap not found'); + return self::FAILURE; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Configuration.php b/files/plugin-HeatmapSessionRecording-5.6.0/Configuration.php new file mode 100644 index 0000000..b8dd31a --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Configuration.php @@ -0,0 +1,145 @@ +getConfig(); + $config->HeatmapSessionRecording = array( + self::KEY_OPTIMIZE_TRACKING_CODE => self::DEFAULT_OPTIMIZE_TRACKING_CODE, + self::KEY_SESSION_RECORDING_SAMPLE_LIMITS => self::DEFAULT_SESSION_RECORDING_SAMPLE_LIMITS, + self::KEY_ENABLE_LOAD_CSS_FROM_DB => self::DEFAULT_ENABLE_LOAD_CSS_FROM_DB, + self::MAX_ALLOWED_TIME_ON_PAGE_COLUMN_LIMIT => pow(2, 63), + self::KEY_DEFAULT_HEATMAP_WIDTH => self::DEFAULT_HEATMAP_WIDTH + + ); + $config->forceSave(); + } + + public function uninstall() + { + $config = $this->getConfig(); + $config->HeatmapSessionRecording = array(); + $config->forceSave(); + } + + public function shouldOptimizeTrackingCode() + { + $value = $this->getConfigValue(self::KEY_OPTIMIZE_TRACKING_CODE, self::DEFAULT_OPTIMIZE_TRACKING_CODE); + + return !empty($value); + } + + public function isAnonymousSessionRecordingAccessEnabled($idSite) + { + $value = $this->getDiValue(self::KEY_ENABLE_ANONYMOUS_SESSION_RECORDING_ACCESS, self::DEFAULT_ENABLE_ANONYMOUS_SESSION_RECORDING_ACCESS); + $idSites = explode(',', $value); + $idSites = array_map('trim', $idSites); + $idSites = array_filter($idSites); + return in_array($idSite, $idSites); + } + + public function getSessionRecordingSampleLimits() + { + $value = $this->getConfigValue(self::KEY_SESSION_RECORDING_SAMPLE_LIMITS, self::DEFAULT_SESSION_RECORDING_SAMPLE_LIMITS); + + if (empty($value)) { + $value = self::DEFAULT_SESSION_RECORDING_SAMPLE_LIMITS; + } + + $value = explode(',', $value); + $value = array_filter($value, function ($val) { + return !empty($val); + }); + $value = array_map(function ($val) { + return intval(trim($val)); + }, $value); + natsort($value); + + if (empty($value)) { + // just a fallback in case config is completely misconfigured + $value = explode(',', self::DEFAULT_SESSION_RECORDING_SAMPLE_LIMITS); + } + + return array_values($value); + } + + public function isLoadCSSFromDBEnabled() + { + return $this->getConfigValue(self::KEY_ENABLE_LOAD_CSS_FROM_DB, self::DEFAULT_ENABLE_LOAD_CSS_FROM_DB); + } + + public function getMaximumAllowedPageTime() + { + return $this->getConfigValue(self::MAX_ALLOWED_TIME_ON_PAGE_COLUMN_LIMIT, ''); + } + + public function getDefaultHeatmapWidth() + { + $width = $this->getConfigValue(self::KEY_DEFAULT_HEATMAP_WIDTH, 1280); + if (!in_array($width, self::HEATMAP_ALLOWED_WIDTHS)) { + $width = self::DEFAULT_HEATMAP_WIDTH; + } + + return $width; + } + + private function getConfig() + { + return Config::getInstance(); + } + + private function getConfigValue($name, $default) + { + $config = $this->getConfig(); + $values = $config->HeatmapSessionRecording; + if (isset($values[$name])) { + return $values[$name]; + } + return $default; + } + + private function getDiValue($name, $default) + { + $value = $default; + try { + $value = StaticContainer::get('HeatmapSessionRecording.' . $name); + } catch (NotFoundException $ex) { + // ignore + } + return $value; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Controller.php b/files/plugin-HeatmapSessionRecording-5.6.0/Controller.php new file mode 100644 index 0000000..8225d00 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Controller.php @@ -0,0 +1,664 @@ +validator = $validator; + $this->siteHsrModel = $model; + $this->systemSettings = $settings; + $this->mutationManipulator = $mutationManipulator; + $this->mutationManipulator->generateNonce(); + $this->configuration = $configuration; + $this->imageProxy = $imageProxy; + } + + /** + * Proxies an external image referenced by a heatmap screenshot so it can be embedded in an exported image. + * + * Reached as a controller action (authenticated by the browser session), so no token_auth is placed in the + * URL that gets injected into the recorded page. Outputs the raw image bytes and terminates the request. On + * failure it logs a sanitized warning and returns a non-200 response so the client degrades gracefully. + * + * @return void + */ + public function getProxiedImage() + { + if (!\Piwik\SettingsPiwik::isInternetEnabled()) { + throw new \Exception('Internet features are disabled.'); + } + + $request = \Piwik\Request::fromRequest(); + $idSite = $request->getIntegerParameter('idSite', 0); + $idSiteHsr = $request->getIntegerParameter('idSiteHsr', 0); + $nonce = $request->getStringParameter('nonce', ''); + $url = Common::unsanitizeInputValue($request->getStringParameter('url', '')); + + Piwik::checkUserHasAdminAccess($idSite); + $this->siteHsrModel->checkHeatmapExists($idSite, $idSiteHsr); + + if (!\Piwik\Nonce::verifyNonce(self::PROXY_NONCE_ID, $nonce, Url::getCurrentHost())) { + throw new \Exception('Invalid nonce.'); + } + + $host = null; + $tmpPath = null; + + try { + $clean = $this->imageProxy->validateUrl($url); + $host = parse_url($clean, PHP_URL_HOST); + $this->imageProxy->assertHostNotInternal($host); + $res = $this->imageProxy->fetchToFile($clean); + $tmpPath = $res['path']; + + Common::sendHeader('Content-Type: ' . $res['mime']); + Common::sendHeader('X-Content-Type-Options: nosniff'); + Common::sendHeader("Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'"); + readfile($tmpPath); + } catch (\Exception $e) { + $logger = StaticContainer::get(\Psr\Log\LoggerInterface::class); + $logger->debug('HeatmapSessionRecording: failed to proxy image for idSite {idSite} idSiteHsr {idSiteHsr} host {host}: {reason}', [ + 'idSite' => $idSite, + 'idSiteHsr' => $idSiteHsr, + 'host' => $host ?: 'unknown', + 'reason' => $e->getMessage(), + ]); + + Common::sendResponseCode(400); + } finally { + if ($tmpPath !== null) { + @unlink($tmpPath); + } + } + + exit; + } + + public function manageHeatmap() + { + $idSite = Common::getRequestVar('idSite'); + + if (strtolower($idSite) === 'all') { + // prevent fatal error... redirect to a specific site as it is not possible to manage for all sites + $this->validator->checkHasSomeWritePermission(); + $this->redirectToIndex('HeatmapSessionRecording', 'manageHeatmap'); + exit; + } + + $this->checkSitePermission(); + $this->validator->checkHeatmapReportWritePermission($this->idSite); + + return $this->renderTemplate('manageHeatmap', array( + 'breakpointMobile' => (int) $this->systemSettings->breakpointMobile->getValue(), + 'breakpointTablet' => (int) $this->systemSettings->breakpointTablet->getValue(), + 'pauseReason' => Piwik::translate(HeatmapSessionRecording::getTranslationKey('pause'), [Piwik::translate('HeatmapSessionRecording_Heatmap')]), + 'isMatomoJsWritable' => HeatmapSessionRecording::isMatomoJsWritable() + )); + } + + public function manageSessions() + { + $idSite = Common::getRequestVar('idSite'); + + if (strtolower($idSite) === 'all') { + // prevent fatal error... redirect to a specific site as it is not possible to manage for all sites + $this->validator->checkHasSomeWritePermission(); + $this->redirectToIndex('HeatmapSessionRecording', 'manageSessions'); + exit; + } + + $this->checkSitePermission(); + $this->validator->checkSessionReportWritePermission($this->idSite); + + return $this->renderTemplate('manageSessions', array( + 'pauseReason' => Piwik::translate(HeatmapSessionRecording::getTranslationKey('pause'), [Piwik::translate('HeatmapSessionRecording_SessionRecording')]), + 'isMatomoJsWritable' => HeatmapSessionRecording::isMatomoJsWritable() + )); + } + + /** + * Entry point for the links embedded in "finished" notification emails. Validates that the + * referenced heatmap/session recording still exists before handing off to the reporting SPA, + * so a deleted item does not silently fall back to another available report. If the item is + * gone the user is redirected to the relevant Manage page which shows a "no longer exists" + * notification instead. + */ + public function goToReport() + { + $request = PiwikRequest::fromRequest(); + $idSiteHsr = $request->getIntegerParameter('idSiteHsr', 0); + $recordType = $request->getIntegerParameter('recordType', SiteHsrDao::RECORD_TYPE_HEATMAP); + + if ($recordType === SiteHsrDao::RECORD_TYPE_SESSION) { + $this->validator->checkSessionReportViewPermission($this->idSite); + $category = 'HeatmapSessionRecording_SessionRecordings'; + $manageSubcategory = 'HeatmapSessionRecording_ManageSessionRecordings'; + $exists = $this->siteHsrModel->sessionRecordingExists($this->idSite, $idSiteHsr); + } else { + $this->validator->checkHeatmapReportViewPermission($this->idSite); + $category = 'HeatmapSessionRecording_Heatmaps'; + $manageSubcategory = 'HeatmapSessionRecording_ManageHeatmaps'; + $exists = $this->siteHsrModel->heatmapExists($this->idSite, $idSiteHsr); + } + + $url = $this->buildReportRedirectUrl((int) $this->idSite, $idSiteHsr, $category, $manageSubcategory, $exists); + + Url::redirectToUrl($url); + } + + /** + * Builds the reporting SPA URL the "finished" email link redirects to. When the referenced + * item still exists the URL opens its report directly; otherwise it points at the Manage page + * with the hsrReportNotFound flag so the SPA can show the "no longer exists" notification. + */ + protected function buildReportRedirectUrl(int $idSite, int $idSiteHsr, string $category, string $manageSubcategory, bool $exists): string + { + $url = rtrim(SettingsPiwik::getPiwikUrl(), '/') . '/' + . 'index.php?module=CoreHome&action=index' + . '&idSite=' . $idSite + . '&period=day&date=today' + . '#?idSite=' . $idSite + . '&period=day&date=today' + . '&category=' . urlencode($category); + + if ($exists) { + $url .= '&subcategory=' . $idSiteHsr; + } else { + $url .= '&subcategory=' . urlencode($manageSubcategory) + . '&hsrReportNotFound=1'; + } + + return $url; + } + + private function checkNotInternetExplorerWhenUsingToken() + { + if (Common::getRequestVar('token_auth', '', 'string') && !empty($_SERVER['HTTP_USER_AGENT'])) { + // we want to detect device type only once for faster performance + $ddFactory = StaticContainer::get(\Piwik\DeviceDetector\DeviceDetectorFactory::class); + $deviceDetector = $ddFactory->makeInstance($_SERVER['HTTP_USER_AGENT']); + $client = $deviceDetector->getClient(); + + if ( + (!empty($client['short_name']) && $client['short_name'] === 'IE') + || (!empty($client['name']) && $client['name'] === 'Internet Explorer') + || (!empty($client['name']) && $client['name'] === 'Opera Mini') + ) { + // see https://caniuse.com/?search=noreferrer + // and https://caniuse.com/?search=referrerpolicy + throw new \Exception('For security reasons this feature doesn\'t work in this browser when using authentication using token_auth. Please try a different browser or log in to view this.'); + } + } + } + + public function replayRecording() + { + $this->validator->checkSessionReportViewPermission($this->idSite); + $this->checkNotInternetExplorerWhenUsingToken(); + + $idLogHsr = Common::getRequestVar('idLogHsr', null, 'int'); + $idSiteHsr = Common::getRequestVar('idSiteHsr', null, 'int'); + + $_GET['period'] = 'year'; // setting it randomly to not having to pass it in the URL + $_GET['date'] = 'today'; // date is ignored anyway + + $recording = Request::processRequest('HeatmapSessionRecording.getRecordedSession', array( + 'idSite' => $this->idSite, + 'idLogHsr' => $idLogHsr, + 'idSiteHsr' => $idSiteHsr, + 'filter_limit' => '-1' + ), $default = []); + + $currentPage = null; + if (!empty($recording['pageviews']) && is_array($recording['pageviews'])) { + $allPageviews = array_values($recording['pageviews']); + foreach ($allPageviews as $index => $pageview) { + if (!empty($pageview['idloghsr']) && $idLogHsr == $pageview['idloghsr']) { + $currentPage = $index + 1; + break; + } + } + } + + $settings = $this->getPluginSettings(); + $settings = $settings->load(); + $skipPauses = !empty($settings['skip_pauses']); + $autoPlayEnabled = !empty($settings['autoplay_pageviews']); + $replaySpeed = !empty($settings['replay_speed']) ? (int) $settings['replay_speed'] : 1; + $isVisitorProfileEnabled = Manager::getInstance()->isPluginActivated('Live') && Live::isVisitorProfileEnabled(); + + if (!empty($recording['events'])) { + foreach ($recording['events'] as $recordingEventIndex => $recordingEventValue) { + if ( + !empty($recordingEventValue['event_type']) && + ( + $recordingEventValue['event_type'] == RequestProcessor::EVENT_TYPE_INITIAL_DOM || + $recordingEventValue['event_type'] == RequestProcessor::EVENT_TYPE_MUTATION + ) && + !empty( + $recordingEventValue['text'] + ) + ) { + $recording['events'][$recordingEventIndex]['text'] = $this->mutationManipulator->manipulate($recordingEventValue['text'], $idSiteHsr, $idLogHsr); + break; + } + } + } + + return $this->renderTemplate('replayRecording', array( + 'idLogHsr' => $idLogHsr, + 'idSiteHsr' => $idSiteHsr, + 'recording' => $recording, + 'scrollAccuracy' => LogHsr::SCROLL_ACCURACY, + 'offsetAccuracy' => LogHsrEvent::OFFSET_ACCURACY, + 'autoPlayEnabled' => $autoPlayEnabled, + 'visitorProfileEnabled' => $isVisitorProfileEnabled, + 'skipPausesEnabled' => $skipPauses, + 'replaySpeed' => $replaySpeed, + 'currentPage' => $currentPage + )); + } + + protected function setBasicVariablesView($view) + { + parent::setBasicVariablesView($view); + + if ( + Common::getRequestVar('module', '', 'string') === 'Widgetize' + && Common::getRequestVar('action', '', 'string') === 'iframe' + && Common::getRequestVar('moduleToWidgetize', '', 'string') === 'HeatmapSessionRecording' + ) { + $action = Common::getRequestVar('actionToWidgetize', '', 'string'); + if (in_array($action, array('replayRecording', 'showHeatmap'), true)) { + $view->enableFrames = true; + } + } + } + + private function getPluginSettings() + { + $login = Piwik::getCurrentUserLogin(); + + $settings = new PluginSettingsTable('HeatmapSessionRecording', $login); + return $settings; + } + + public function saveSessionRecordingSettings() + { + Piwik::checkUserHasSomeViewAccess(); + $this->validator->checkSessionRecordingEnabled(); + // there is no nonce for this action but that should also not be needed here. as it is just replay settings + + $autoPlay = Common::getRequestVar('autoplay', '0', 'int'); + $replaySpeed = Common::getRequestVar('replayspeed', '1', 'int'); + $skipPauses = Common::getRequestVar('skippauses', '0', 'int'); + + $settings = $this->getPluginSettings(); + $settings->save(array('autoplay_pageviews' => $autoPlay, 'replay_speed' => $replaySpeed, 'skip_pauses' => $skipPauses)); + } + + /** + * Public page reached from the "unsubscribe" link in a finished-recording email. It authenticates + * the request with a signed token (no login required) and turns off future finished notifications + * for the recipient. On GET it shows a confirmation form; on POST (confirm) it applies the change. + */ + public function unsubscribe() + { + $view = new View('@HeatmapSessionRecording/unsubscribe'); + $this->setBasicVariablesView($view); + $view->linkTitle = Piwik::getRandomTitle(); + + $matomoLink = 'https://matomo.org/'; + if (method_exists(Url::class, 'addCampaignParametersToMatomoLink')) { + $matomoLink = Url::addCampaignParametersToMatomoLink($matomoLink); + } + $view->matomoLink = $matomoLink; + + $token = Common::getRequestVar('token', '', 'string'); + $login = Common::getRequestVar('login', '', 'string'); + + if (empty($token) || empty($login)) { + $view->error = Piwik::translate('HeatmapSessionRecording_UnsubscribeNoTokenProvided'); + return $view->render(); + } + + $user = Access::doAsSuperUser(function () use ($login) { + return (new UsersManagerModel())->getUser($login); + }); + + if ( + empty($user) + || empty($user['email']) + || !hash_equals(Unsubscribe::generateToken($login, $user['email']), $token) + ) { + $view->error = Piwik::translate('HeatmapSessionRecording_UnsubscribeInvalid'); + return $view->render(); + } + + $confirm = Common::getRequestVar('confirm', '', 'string'); + + if (!empty($confirm)) { + Nonce::checkNonce('HeatmapSessionRecording.Unsubscribe'); + + Access::doAsSuperUser(function () use ($login) { + $setting = new UserSetting('notifyFinishedRecordings', true, FieldConfig::TYPE_BOOL, 'HeatmapSessionRecording', $login); + $setting->setValue(false); + $setting->save(); + }); + + $view->success = true; + } else { + $view->nonce = Nonce::getNonce('HeatmapSessionRecording.Unsubscribe'); + } + + return $view->render(); + } + + private function initHeatmapAuth() + { + // todo remove in Matomo 5 when we hopefully no longer support IE 11. + // This is mostly there to prevent forwarding tokens through referrer to third parties + // most browsers support this except IE11 + // we said we're technically OK with IE11 forwarding a view token in worst case but we still have this here for now + $token_auth = Common::getRequestVar('token_auth', '', 'string'); + + if (!empty($token_auth)) { + $auth = StaticContainer::get('Piwik\Auth'); + $auth->setTokenAuth($token_auth); + $auth->setPassword(null); + $auth->setPasswordHash(null); + $auth->setLogin(null); + + Session::start(); + $sessionInitializer = new SessionInitializer(); + $sessionInitializer->initSession($auth); + + $_SESSION[HeatmapSessionRecording::SESSION_TOKEN_EMBED_KEY] = true; + + $url = preg_replace('/&token_auth=[^&]{20,38}|$/i', '', Url::getCurrentUrl()); + if ($url) { + Url::redirectToUrl($url); + return; + } + } + + // if no token_auth, we just rely on an existing session auth check + } + + protected function setBasicVariablesNoneAdminView($view) + { + parent::setBasicVariablesNoneAdminView($view); + if (Piwik::getAction() === 'embedPage' && Piwik::getModule() === 'HeatmapSessionRecording') { + $view->setXFrameOptions('allow'); + } + } + + public function embedPage() + { + $this->checkNotInternetExplorerWhenUsingToken(); + $this->initHeatmapAuth(); + $nonceRandom = ''; + + if ( + property_exists($this, 'securityPolicy') && + method_exists($this->securityPolicy, 'allowEmbedPage') + ) { + $toSearch = array("'unsafe-inline' ", "'unsafe-eval' ", "'unsafe-inline'", "'unsafe-eval'"); + $nonceRandom = $this->mutationManipulator->getNonce(); + $this->securityPolicy->overridePolicy('default-src', $this->securityPolicy::RULE_EMBEDDED_FRAME); + $this->securityPolicy->overridePolicy('img-src', $this->securityPolicy::RULE_EMBEDDED_FRAME); + $this->securityPolicy->addPolicy('script-src', str_replace($toSearch, '', $this->securityPolicy::RULE_DEFAULT) . "'nonce-$nonceRandom'"); + } + + $pathPrefix = HeatmapSessionRecording::getPathPrefix(); + $jQueryPath = 'node_modules/jquery/dist/jquery.min.js'; + if (HeatmapSessionRecording::isMatomoForWordPress()) { + $jQueryPath = includes_url('js/jquery/jquery.js'); + } + + $idLogHsr = Common::getRequestVar('idLogHsr', 0, 'int'); + $idSiteHsr = Common::getRequestVar('idSiteHsr', null, 'int'); + + $_GET['period'] = 'year'; // setting it randomly to not having to pass it in the URL + $_GET['date'] = 'today'; // date is ignored anyway + + if (empty($idLogHsr)) { + $this->validator->checkHeatmapReportViewPermission($this->idSite); + + $heatmap = $this->getHeatmap($this->idSite, $idSiteHsr); + + if (isset($heatmap[0])) { + $heatmap = $heatmap[0]; + } + + $baseUrl = $heatmap['screenshot_url']; + $initialMutation = $heatmap['page_treemirror']; + } else { + $this->validator->checkSessionReportViewPermission($this->idSite); + $this->checkSessionRecordingExists($this->idSite, $idSiteHsr); + + $recording = Request::processRequest('HeatmapSessionRecording.getEmbedSessionInfo', [ + 'idSite' => $this->idSite, + 'idSiteHsr' => $idSiteHsr, + 'idLogHsr' => $idLogHsr, + ], $default = []); + + if (empty($recording)) { + throw new \Exception(Piwik::translate('HeatmapSessionRecording_ErrorSessionRecordingDoesNotExist')); + } + + $baseUrl = $recording['base_url']; + $map = array_flip(PageUrl::$urlPrefixMap); + + if (isset($recording['url_prefix']) !== null && isset($map[$recording['url_prefix']])) { + $baseUrl = $map[$recording['url_prefix']] . $baseUrl; + } + + if (!empty($recording['initial_mutation'])) { + $initialMutation = $recording['initial_mutation']; + } else { + $initialMutation = ''; + } + } + + $initialMutation = $this->mutationManipulator->manipulate($initialMutation, $idSiteHsr, $idLogHsr); + + return $this->renderTemplate('embedPage', array( + 'idLogHsr' => $idLogHsr, + 'idSiteHsr' => $idSiteHsr, + 'initialMutation' => $initialMutation, + 'baseUrl' => $baseUrl, + 'pathPrefix' => $pathPrefix, + 'jQueryPath' => $jQueryPath, + 'nonceRandom' => $nonceRandom + )); + } + + public function showHeatmap() + { + $this->validator->checkHeatmapReportViewPermission($this->idSite); + $this->checkNotInternetExplorerWhenUsingToken(); + + $idSiteHsr = Common::getRequestVar('idSiteHsr', null, 'int'); + $heatmapType = Common::getRequestVar('heatmapType', RequestProcessor::EVENT_TYPE_CLICK, 'int'); + $deviceType = Common::getRequestVar('deviceType', LogHsr::DEVICE_TYPE_DESKTOP, 'int'); + + $heatmap = Request::processRequest('HeatmapSessionRecording.getHeatmap', array( + 'idSite' => $this->idSite, + 'idSiteHsr' => $idSiteHsr + ), $default = []); + + if (isset($heatmap[0])) { + $heatmap = $heatmap[0]; + } + + $requestDate = $this->siteHsrModel->getPiwikRequestDate($heatmap); + $period = $requestDate['period']; + $dateRange = $requestDate['date']; + + if ( + !PeriodFactory::isPeriodEnabledForAPI($period) || + Common::getRequestVar('useDateUrl', 0, 'int') + ) { + $period = Common::getRequestVar('period', null, 'string'); + $dateRange = Common::getRequestVar('date', null, 'string'); + } + + try { + PeriodFactory::checkPeriodIsEnabled($period); + } catch (\Exception $e) { + $periodEscaped = Common::sanitizeInputValue(Piwik::translate('HeatmapSessionRecording_PeriodDisabledErrorMessage', $period)); + return '
' . $periodEscaped . '
'; + } + + $metadata = Request::processRequest('HeatmapSessionRecording.getRecordedHeatmapMetadata', array( + 'idSite' => $this->idSite, + 'idSiteHsr' => $idSiteHsr, + 'period' => $period, + 'date' => $dateRange + ), $default = []); + + if (isset($metadata[0])) { + $metadata = $metadata[0]; + } + + $editUrl = 'index.php' . Url::getCurrentQueryStringWithParametersModified(array( + 'module' => 'HeatmapSessionRecording', + 'action' => 'manageHeatmap' + )) . '#?idSiteHsr=' . (int)$idSiteHsr; + + $reportDocumentation = ''; + if ($heatmap['status'] == SiteHsrDao::STATUS_ACTIVE) { + $reportDocumentation = Piwik::translate('HeatmapSessionRecording_RecordedHeatmapDocStatusActive', array($heatmap['sample_limit'], $heatmap['sample_rate'] . '%')); + } elseif ($heatmap['status'] == SiteHsrDao::STATUS_ENDED) { + $reportDocumentation = Piwik::translate('HeatmapSessionRecording_RecordedHeatmapDocStatusEnded'); + } elseif ($heatmap['status'] == SiteHsrDao::STATUS_ONHOLD) { + if (!empty($heatmap['scheduled_date_pretty'])) { + $reportDocumentation = Piwik::translate( + 'HeatmapSessionRecording_RecordedHeatmapDocStatusOnHoldWithDate', + $heatmap['scheduled_date_pretty'] + ); + } else { + $reportDocumentation = Piwik::translate('HeatmapSessionRecording_RecordedHeatmapDocStatusOnHold'); + } + } + + $includedCountries = $this->systemSettings->getIncludedCountries(); + + return $this->renderTemplate('showHeatmap', array( + 'idSiteHsr' => $idSiteHsr, + 'editUrl' => $editUrl, + 'heatmapType' => $heatmapType, + 'deviceType' => $deviceType, + 'heatmapPeriod' => $period, + 'heatmapDate' => $dateRange, + 'heatmap' => $heatmap, + 'isActive' => $heatmap['status'] == SiteHsrDao::STATUS_ACTIVE, + 'heatmapMetadata' => $metadata, + 'reportDocumentation' => $reportDocumentation, + 'isScroll' => $heatmapType == RequestProcessor::EVENT_TYPE_SCROLL, + 'offsetAccuracy' => LogHsrEvent::OFFSET_ACCURACY, + 'heatmapTypes' => API::getInstance()->getAvailableHeatmapTypes(), + 'deviceTypes' => API::getInstance()->getAvailableDeviceTypes(), + 'includedCountries' => !empty($includedCountries) ? implode(', ', $includedCountries) : '', + 'desktopPreviewSize' => $this->configuration->getDefaultHeatmapWidth(), + 'allowedWidth' => Configuration::HEATMAP_ALLOWED_WIDTHS, + 'noDataMessageKey' => HeatmapSessionRecording::getTranslationKey('noDataHeatmap'), + 'isMatomoJsWritable' => HeatmapSessionRecording::isMatomoJsWritable(), + 'imageProxyNonce' => \Piwik\Nonce::getNonce(self::PROXY_NONCE_ID), + )); + } + + private function getHeatmap($idSite, $idSiteHsr) + { + $heatmap = Request::processRequest('HeatmapSessionRecording.getHeatmap', [ + 'idSite' => $idSite, + 'idSiteHsr' => $idSiteHsr, + ], $default = []); + if (empty($heatmap)) { + throw new \Exception(Piwik::translate('HeatmapSessionRecording_ErrorHeatmapDoesNotExist')); + } + return $heatmap; + } + + private function checkSessionRecordingExists($idSite, $idSiteHsr) + { + $sessionRecording = Request::processRequest('HeatmapSessionRecording.getSessionRecording', [ + 'idSite' => $idSite, + 'idSiteHsr' => $idSiteHsr, + ], $default = []); + if (empty($sessionRecording)) { + throw new \Exception(Piwik::translate('HeatmapSessionRecording_ErrorSessionRecordingDoesNotExist')); + } + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Dao/LogHsr.php b/files/plugin-HeatmapSessionRecording-5.6.0/Dao/LogHsr.php new file mode 100644 index 0000000..8851782 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Dao/LogHsr.php @@ -0,0 +1,375 @@ +tablePrefixed = Common::prefixTable($this->table); + $this->logHsrSite = $logHsrSite; + } + + private function getDb() + { + if (!isset($this->db)) { + $this->db = Db::get(); + } + return $this->db; + } + + public function install() + { + DbHelper::createTable($this->table, " + `idloghsr` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `idsite` INT UNSIGNED NOT NULL, + `idvisit` BIGINT UNSIGNED NOT NULL, + `idhsrview` CHAR(6) NOT NULL, + `idpageview` CHAR(6) NULL, + `idaction_url` INT(10) UNSIGNED NOT NULL DEFAULT 0, + `device_type` TINYINT(1) NOT NULL DEFAULT 1, + `server_time` DATETIME NOT NULL, + `time_on_page` BIGINT(8) UNSIGNED NOT NULL, + `viewport_w_px` SMALLINT(5) UNSIGNED DEFAULT 0, + `viewport_h_px` SMALLINT(5) UNSIGNED DEFAULT 0, + `scroll_y_max_relative` SMALLINT(5) UNSIGNED DEFAULT 0, + `fold_y_relative` SMALLINT(5) UNSIGNED DEFAULT 0, + PRIMARY KEY(`idloghsr`), + UNIQUE KEY idvisit_idhsrview (`idvisit`,`idhsrview`), + KEY idsite_servertime (`idsite`,`server_time`)"); + + // idpageview is only there so we can add it to visitor log later. Please note that idpageview is only set on + // the first tracking request. As the user may track a new pageview during the recording, the pageview may + // change over time. This is why we need the idhsrview. + + // we need the idhsrview as there can be many recordings during one visit and this way we can control when + // to trigger a new recording / heatmap in the tracker by changing this id + } + + public function uninstall() + { + Db::query(sprintf('DROP TABLE IF EXISTS `%s`', $this->tablePrefixed)); + } + + protected function getDeviceWidth($resolution) + { + if (!empty($resolution)) { + $parts = explode('x', $resolution); + if (count($parts) === 2 && $parts[0] > 1 && $parts[1] > 1) { + $width = $parts[0]; + return (int) $width; + } + } + + return 1280; // default desktop + } + + protected function getDeviceType($hsrSiteIds, $idSite, $userAgent, $deviceWidth) + { + $deviceType = null; + + // we want to detect device type only once for faster performance + $ddFactory = StaticContainer::get(\Piwik\DeviceDetector\DeviceDetectorFactory::class); + $deviceDetector = $ddFactory->makeInstance($userAgent); + $device = $deviceDetector->getDevice(); + + $checkWidth = false; + if ( + in_array( + $device, + array( + AbstractDeviceParser::DEVICE_TYPE_FEATURE_PHONE, + AbstractDeviceParser::DEVICE_TYPE_PHABLET, + AbstractDeviceParser::DEVICE_TYPE_SMARTPHONE, + AbstractDeviceParser::DEVICE_TYPE_CAMERA, + AbstractDeviceParser::DEVICE_TYPE_CAR_BROWSER + ), + $strict = true + ) + ) { + $deviceType = self::DEVICE_TYPE_MOBILE; + } elseif (in_array($device, array(AbstractDeviceParser::DEVICE_TYPE_TABLET), $strict = true)) { + $deviceType = self::DEVICE_TYPE_TABLET; + } elseif ($deviceType === AbstractDeviceParser::DEVICE_TYPE_DESKTOP) { + $deviceType = LogHsr::DEVICE_TYPE_DESKTOP; + $checkWidth = true; + } else { + $checkWidth = true; + } + + if ($checkWidth && !empty($deviceWidth)) { + $hsrs = $this->getCachedHsrs($idSite); + + foreach ($hsrs as $hsr) { + // the device type is only relevant for heatmaps so we only look for breakpoints in heatmaps + if ( + $hsr['record_type'] == SiteHsrDao::RECORD_TYPE_HEATMAP + && in_array($hsr['idsitehsr'], $hsrSiteIds) + ) { + if ($deviceWidth < $hsr['breakpoint_mobile']) { + // resolution has to be lower than this + $deviceType = self::DEVICE_TYPE_MOBILE; + } elseif ($deviceWidth < $hsr['breakpoint_tablet']) { + $deviceType = self::DEVICE_TYPE_TABLET; + } else { + $deviceType = self::DEVICE_TYPE_DESKTOP; + } + + break; + } + } + } + + if (empty($deviceType)) { + $deviceType = LogHsr::DEVICE_TYPE_DESKTOP; + } + + return $deviceType; + } + + protected function getCachedHsrs($idSite) + { + $cache = Tracker\Cache::getCacheWebsiteAttributes($idSite); + + if (!empty($cache['hsr'])) { + return $cache['hsr']; + } + + return array(); + } + + public function findIdLogHsr($idVisit, $idHsrView) + { + $query = sprintf('SELECT idloghsr FROM %s WHERE idvisit = ? and idhsrview = ? LIMIT 1', $this->tablePrefixed); + + return $this->getDb()->fetchOne($query, array($idVisit, $idHsrView)); + } + + public function hasRecordedIdVisit($idVisit, $idSiteHsr) + { + $siteTable = Common::prefixTable('log_hsr_site'); + $query = sprintf('SELECT lhsr.idvisit + FROM %s lhsr + LEFT JOIN %s lhsrsite ON lhsr.idloghsr=lhsrsite.idloghsr + WHERE lhsr.idvisit = ? and lhsrsite.idsitehsr = ? + LIMIT 1', $this->tablePrefixed, $siteTable); + $id = $this->getDb()->fetchOne($query, array($idVisit, $idSiteHsr)); + return !empty($id); + } + + // $hsrSiteIds => one recording may be long to several actual recordings. + public function record($hsrSiteIds, $idSite, $idVisit, $idHsrView, $idPageview, $url, $serverTime, $userAgent, $resolution, $timeOnPage, $viewportW, $viewportH, $scrollYMaxRelative, $foldYRelative) + { + if ($foldYRelative > self::SCROLL_ACCURACY) { + $foldYRelative = self::SCROLL_ACCURACY; + } + + if ($scrollYMaxRelative > self::SCROLL_ACCURACY) { + $scrollYMaxRelative = self::SCROLL_ACCURACY; + } + + $idLogHsr = $this->findIdLogHsr($idVisit, $idHsrView); + + if (empty($idLogHsr)) { + // to prevent race conditions we use atomic insert. It may lead to more gaps in auto increment but there is + // no way around it + + Piwik::postEvent('HeatmapSessionRecording.trackNewHsrSiteIds', array(&$hsrSiteIds, array('idSite' => $idSite, 'serverTime' => $serverTime, 'idVisit' => $idVisit))); + + if (empty($hsrSiteIds)) { + throw new \Exception('No hsrSiteIds'); + } + + $values = array( + 'idvisit' => $idVisit, + 'idsite' => $idSite, + 'idhsrview' => $idHsrView, + 'idpageview' => $idPageview, + 'server_time' => $serverTime, + 'time_on_page' => $timeOnPage, + 'viewport_w_px' => $viewportW, + 'viewport_h_px' => $viewportH, + 'scroll_y_max_relative' => (int)$scrollYMaxRelative, + 'fold_y_relative' => (int) $foldYRelative, + ); + + $columns = implode('`,`', array_keys($values)); + $bind = array_values($values); + $sql = sprintf('INSERT INTO %s (`%s`) VALUES(?,?,?,?,?,?,?,?,?,?)', $this->tablePrefixed, $columns); + + try { + $result = $this->getDb()->query($sql, $bind); + } catch (\Exception $e) { + if (Db::get()->isErrNo($e, \Piwik\Updater\Migration\Db::ERROR_CODE_DUPLICATE_ENTRY)) { + // race condition where two tried to insert at same time... we need to update instead + + $idLogHsr = $this->findIdLogHsr($idVisit, $idHsrView); + $this->updateRecord($idLogHsr, $timeOnPage, $scrollYMaxRelative); + return $idLogHsr; + } + throw $e; + } + + $all = $this->getDb()->rowCount($result); + + $idLogHsr = $this->getDb()->lastInsertId(); + + if ($all === 1 || $all === '1') { + // was inserted, resolve idaction! would be 2 or 0 on update + // to be efficient we want to resolve idaction only once + $url = PageUrl::normalizeUrl($url); + $ids = TableLogAction::loadIdsAction(array('idaction_url' => array($url['url'], Action::TYPE_PAGE_URL, $url['prefixId']))); + + if (!empty($viewportW)) { + $deviceWidth = (int) $viewportW; + } else { + $deviceWidth = $this->getDeviceWidth($resolution); + } + $deviceType = $this->getDeviceType($hsrSiteIds, $idSite, $userAgent, $deviceWidth); + + $idaction = $ids['idaction_url']; + $this->getDb()->query( + sprintf('UPDATE %s set idaction_url = ?, device_type = ? where idloghsr = ?', $this->tablePrefixed), + array($idaction, $deviceType, $idLogHsr) + ); + + foreach ($hsrSiteIds as $hsrId) { + // for performance reasons we check the limit only on hsr start and we make this way sure to still + // accept all following requests to that hsr + $this->logHsrSite->linkRecord($idLogHsr, $hsrId); + } + } + } else { + $this->updateRecord($idLogHsr, $timeOnPage, $scrollYMaxRelative); + } + + return $idLogHsr; + } + + public function updateRecord($idLogHsr, $timeOnPage, $scrollYMaxRelative) + { + $sql = sprintf( + 'UPDATE %s SET + time_on_page = if(? > time_on_page, ?, time_on_page), + scroll_y_max_relative = if(? > scroll_y_max_relative, ?, scroll_y_max_relative) + WHERE idloghsr = ?', + $this->tablePrefixed + ); + + $bind = array(); + $bind[] = $timeOnPage; + $bind[] = $timeOnPage; + $bind[] = $scrollYMaxRelative; + $bind[] = $scrollYMaxRelative; + $bind[] = $idLogHsr; + + $this->getDb()->query($sql, $bind); + } + + public function getAllRecords() + { + return $this->getDb()->fetchAll('SELECT * FROM ' . $this->tablePrefixed); + } + + public function findLogHsrIdsInVisit($idSite, $idVisit) + { + $rows = Db::fetchAll(sprintf('SELECT idloghsr FROM %s WHERE idvisit = ? and idsite = ?', $this->tablePrefixed), array($idVisit, $idSite)); + + $idLogHsrs = array(); + foreach ($rows as $row) { + $idLogHsrs[] = (int) $row['idloghsr']; + } + + return $idLogHsrs; + } + + public function findDeletedLogHsrIds() + { + // DELETE ALL LOG ENTRIES WHOSE IDSITEHSR DOES NO LONGER EXIST + $rows = Db::fetchAll(sprintf( + 'SELECT DISTINCT log_hsr.idloghsr FROM %s log_hsr LEFT OUTER JOIN %s log_hsr_site ON log_hsr.idloghsr = log_hsr_site.idloghsr WHERE log_hsr_site.idsitehsr IS NULL', + $this->tablePrefixed, + Common::prefixTable('log_hsr_site') + )); + + $idLogHsrsToDelete = array(); + foreach ($rows as $row) { + $idLogHsrsToDelete[] = (int) $row['idloghsr']; + } + + return $idLogHsrsToDelete; + } + + public function deleteIdLogHsrsFromAllTables($idLogHsrsToDelete) + { + if (!is_array($idLogHsrsToDelete)) { + throw new \Exception('idLogHsrsToDelete is not an array'); + } + + if (empty($idLogHsrsToDelete)) { + return; + } + + // we delete them in chunks of 2500 + $idLogHsrsToDelete = array_chunk($idLogHsrsToDelete, 2500); + + $tablesToDelete = array( + Common::prefixTable('log_hsr_event'), + Common::prefixTable('log_hsr_site'), + Common::prefixTable('log_hsr'), + ); + foreach ($idLogHsrsToDelete as $idsToDelete) { + $idsToDelete = array_map('intval', $idsToDelete); + $idsToDelete = implode(',', $idsToDelete); + foreach ($tablesToDelete as $tableToDelete) { + $sql = sprintf('DELETE FROM %s WHERE idloghsr IN(%s)', $tableToDelete, $idsToDelete); + Db::query($sql); + } + } + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Dao/LogHsrBlob.php b/files/plugin-HeatmapSessionRecording-5.6.0/Dao/LogHsrBlob.php new file mode 100644 index 0000000..9945d50 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Dao/LogHsrBlob.php @@ -0,0 +1,180 @@ +tablePrefixed = Common::prefixTable($this->table); + } + + private function getDb() + { + if (!isset($this->db)) { + $this->db = Db::get(); + } + return $this->db; + } + + public function install() + { + DbHelper::createTable($this->table, " + `idhsrblob` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `hash` INT(10) UNSIGNED NOT NULL, + `compressed` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0, + `value` MEDIUMBLOB NULL DEFAULT NULL, + PRIMARY KEY (`idhsrblob`), + INDEX (`hash`)"); + + // we always build the hash on the raw text for simplicity + } + + public function uninstall() + { + Db::query(sprintf('DROP TABLE IF EXISTS `%s`', $this->tablePrefixed)); + } + + public function findEntry($textHash, $text, $textCompressed) + { + $sql = sprintf('SELECT idhsrblob FROM %s WHERE `hash` = ? and (`value` = ? or `value` = ?) LIMIT 1', $this->tablePrefixed); + $id = $this->getDb()->fetchOne($sql, array($textHash, $text, $textCompressed)); + + return $id; + } + + public function createEntry($textHash, $text, $isCompressed) + { + $sql = sprintf('INSERT INTO %s (`hash`, `compressed`, `value`) VALUES(?,?,?) ', $this->tablePrefixed); + $this->getDb()->query($sql, array($textHash, (int) $isCompressed, $text)); + + return $this->getDb()->lastInsertId(); + } + + public function record($text) + { + if ($text === null || $text === false) { + return null; + } + + $textHash = abs(crc32($text)); + $textCompressed = $this->compress($text); + + $id = $this->findEntry($textHash, $text, $textCompressed); + + if (!empty($id)) { + return $id; + } + + $isCompressed = 0; + if ($text !== $textCompressed && strlen($textCompressed) < strlen($text)) { + // detect if it is more efficient to store compressed or raw text + $text = $textCompressed; + $isCompressed = 1; + } + + return $this->createEntry($textHash, $text, $isCompressed); + } + + public function deleteUnusedBlobEntries() + { + $eventTable = Common::prefixTable('log_hsr_event'); + $blobTable = Common::prefixTable('log_hsr_blob'); + + $blobEntries = Db::fetchAll('SELECT distinct idhsrblob FROM ' . $eventTable . ' LIMIT 2'); + $blobEntries = array_filter($blobEntries, function ($val) { + return $val['idhsrblob'] !== null; + }); // remove null values. + + if (empty($blobEntries)) { + // no longer any blobs in use... delete all blobs + $sql = 'DELETE FROM ' . $blobTable; + Db::query($sql); + return $sql; + } + + $indexes = Db::fetchAll('SHOW INDEX FROM ' . $eventTable); + $indexSql = ''; + foreach ($indexes as $index) { + if ( + (!empty($index['Column_name']) && !empty($index['Key_name']) && $index['Column_name'] === 'idhsrblob') + || (!empty($index['Key_name']) && $index['Key_name'] === 'idhsrblob') + || (!empty($index['Key_name']) && $index['Key_name'] === 'index_idhsrblob') + ) { + $indexSql = 'FORCE INDEX FOR JOIN (' . $index['Key_name'] . ')'; + break; + } + } + + $sql = sprintf('DELETE hsrblob + FROM %s hsrblob + LEFT JOIN %s hsrevent %s on hsrblob.idhsrblob = hsrevent.idhsrblob + WHERE hsrevent.idloghsr is null', $blobTable, $eventTable, $indexSql); + + Db::query($sql); + return $sql; + } + + public function getAllRecords() + { + $blobs = $this->getDb()->fetchAll('SELECT * FROM ' . $this->tablePrefixed); + return $this->enrichRecords($blobs); + } + + private function enrichRecords($blobs) + { + if (!empty($blobs)) { + foreach ($blobs as $index => &$blob) { + if (!empty($blob['compressed'])) { + $blob['value'] = $this->uncompress($blob['value']); + } + } + } + + return $blobs; + } + + private function compress($data) + { + if (!empty($data)) { + return gzcompress($data); + } + + return $data; + } + + private function uncompress($data) + { + if (!empty($data)) { + return gzuncompress($data); + } + + return $data; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Dao/LogHsrEvent.php b/files/plugin-HeatmapSessionRecording-5.6.0/Dao/LogHsrEvent.php new file mode 100644 index 0000000..6a64065 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Dao/LogHsrEvent.php @@ -0,0 +1,166 @@ +tablePrefixed = Common::prefixTable($this->table); + $this->logBlobHsr = $logBlobHsr; + } + + private function getDb() + { + if (!isset($this->db)) { + $this->db = Db::get(); + } + return $this->db; + } + + public function install() + { + DbHelper::createTable($this->table, " + `idhsrevent` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `idloghsr` INT(10) UNSIGNED NOT NULL, + `time_since_load` BIGINT(8) UNSIGNED NOT NULL DEFAULT 0, + `event_type` TINYINT UNSIGNED NOT NULL DEFAULT 0, + `idselector` INT(10) UNSIGNED NULL DEFAULT NULL, + `x` SMALLINT(5) NOT NULL DEFAULT 0, + `y` SMALLINT(5) NOT NULL DEFAULT 0, + `idhsrblob` INT(10) UNSIGNED DEFAULT NULL, + PRIMARY KEY(`idhsrevent`), + INDEX idloghsr (`idloghsr`), + INDEX idhsrblob (`idhsrblob`)"); + // x and y is not unsigned on purpose as it may hold rarely a negative value + } + + public function uninstall() + { + Db::query(sprintf('DROP TABLE IF EXISTS `%s`', $this->tablePrefixed)); + } + + public function record($idloghsr, $timeSinceLoad, $eventType, $idSelector, $x, $y, $text) + { + if ($x > self::MAX_SIZE) { + $x = self::MAX_SIZE; + } + + if ($y > self::MAX_SIZE) { + $y = self::MAX_SIZE; + } + + if ($x === null || $x === false) { + $x = 0; + } + + if ($y === null || $y === false) { + $y = 0; + } + + $idHsrBlob = $this->logBlobHsr->record($text); + + $values = array( + 'idloghsr' => $idloghsr, + 'time_since_load' => $timeSinceLoad, + 'event_type' => $eventType, + 'idselector' => $idSelector, + 'x' => $x, + 'y' => $y, + 'idhsrblob' => $idHsrBlob, + ); + + $columns = implode('`,`', array_keys($values)); + + $sql = sprintf('INSERT INTO %s (`%s`) VALUES(?,?,?,?,?,?,?) ', $this->tablePrefixed, $columns); + + $bind = array_values($values); + + $this->getDb()->query($sql, $bind); + } + + public function getEventsForPageview($idLogHsr) + { + $sql = sprintf('SELECT %1$s.time_since_load, %1$s.event_type, %1$s.x, %1$s.y, %2$s.name as selector, %3$s.value as text, %3$s.compressed + FROM %1$s + LEFT JOIN %2$s ON %1$s.idselector = %2$s.idaction + LEFT JOIN %3$s ON %1$s.idhsrblob = %3$s.idhsrblob + WHERE %1$s.idloghsr = ? and %1$s.event_type != ? + ORDER BY time_since_load ASC', $this->tablePrefixed, Common::prefixTable('log_action'), Common::prefixTable('log_hsr_blob')); + + $rows = $this->getDb()->fetchAll($sql, array($idLogHsr, RequestProcessor::EVENT_TYPE_CSS)); + foreach ($rows as $index => $row) { + if (!empty($row['compressed'])) { + $rows[$index]['text'] = gzuncompress($row['text']); + } + unset($rows[$index]['compressed']); + } + return $rows; + } + + public function getCssEvents($idSiteHsr, $idLoghsr = '') + { + //idLogHsr will be empty in case of heatmaps, we cannot use it in where clause to resolve that, when its heatmap the where condition is `AND 1=1` and for session recording its `AND x.idloghsr=$idLoghsr` + $idLogHsrLhs = '1'; + $idLogHsrRhs = '1'; + if (!empty($idLoghsr)) { + $idLogHsrLhs = 'x.idloghsr'; + $idLogHsrRhs = $idLoghsr; + } + $sql = sprintf('SELECT distinct z.idhsrblob,a.name as url, z.value as text, z.compressed + FROM %2$s x,%3$s y,%4$s z,%5$s a + WHERE x.idsitehsr=? AND %1$s=? and y.event_type=? and x.idloghsr=y.idloghsr and y.idhsrblob = z.idhsrblob and a.idaction=y.idselector + order by z.idhsrblob ASC', $idLogHsrLhs, Common::prefixTable('log_hsr_site'), Common::prefixTable('log_hsr_event'), Common::prefixTable('log_hsr_blob'), Common::prefixTable('log_action')); + + $rows = $this->getDb()->fetchAll($sql, array($idSiteHsr, $idLogHsrRhs, RequestProcessor::EVENT_TYPE_CSS)); + foreach ($rows as $index => $row) { + if (!empty($row['compressed'])) { + $rows[$index]['text'] = gzuncompress($row['text']); + } + unset($rows[$index]['compressed']); + } + return $rows; + } + + public function getAllRecords() + { + return $this->getDb()->fetchAll('SELECT * FROM ' . $this->tablePrefixed); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Dao/LogHsrSite.php b/files/plugin-HeatmapSessionRecording-5.6.0/Dao/LogHsrSite.php new file mode 100644 index 0000000..aa116c9 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Dao/LogHsrSite.php @@ -0,0 +1,143 @@ +tablePrefixed = Common::prefixTable($this->table); + } + + private function getDb() + { + if (!isset($this->db)) { + $this->db = Db::get(); + } + return $this->db; + } + + public function install() + { + // it actually also has the advantage that removing an entry will be fast because when a user clicks on + // "delete heatmap" we could only remove this entry, and then have a daily cronjob to delete all entries that are + // no longer linked. instead of having to directly delete all data. Also it is more efficient to track when eg + // a session and a heatmap is being recording at the same time or when several heatmaps are being recorded at once + DbHelper::createTable($this->table, " + `idsitehsr` INT(10) UNSIGNED NOT NULL, + `idloghsr` INT(10) UNSIGNED NOT NULL, + PRIMARY KEY(`idsitehsr`, `idloghsr`), + INDEX index_idloghsr (`idloghsr`)"); + } + + public function uninstall() + { + Db::query(sprintf('DROP TABLE IF EXISTS `%s`', $this->tablePrefixed)); + } + + public function linkRecord($idLogHsr, $idSiteHsr) + { + $bind = array($idLogHsr,$idSiteHsr); + $sql = sprintf('INSERT INTO %s (`idloghsr`, `idsitehsr`) VALUES(?,?)', $this->tablePrefixed); + + try { + $this->getDb()->query($sql, $bind); + } catch (\Exception $e) { + if (Db::get()->isErrNo($e, \Piwik\Updater\Migration\Db::ERROR_CODE_DUPLICATE_ENTRY)) { + return; + } + throw $e; + } + } + + // should be fast as covered index + public function getNumPageViews($idSiteHsr) + { + $sql = sprintf('SELECT count(*) as numsamples FROM %s WHERE idsitehsr = ?', $this->tablePrefixed); + + return (int) $this->getDb()->fetchOne($sql, array($idSiteHsr)); + } + + // should be fast as covered index + public function getNumSessions($idSiteHsr) + { + $sql = sprintf( + 'SELECT count(distinct idvisit) + FROM %s loghsrsite + left join %s loghsr on loghsr.idloghsr = loghsrsite.idloghsr + left join %s loghsrevent on loghsr.idloghsr = loghsrevent.idloghsr and loghsrevent.event_type = %s + WHERE loghsrsite.idsitehsr = ? and loghsrevent.idhsrblob is not null', + $this->tablePrefixed, + Common::prefixTable('log_hsr'), + Common::prefixTable('log_hsr_event'), + RequestProcessor::EVENT_TYPE_INITIAL_DOM + ); + + return (int) $this->getDb()->fetchOne($sql, array($idSiteHsr)); + } + + public function unlinkRecord($idLogHsr, $idSiteHsr) + { + $sql = sprintf('DELETE FROM %s WHERE idsitehsr = ? and idloghsr = ?', $this->tablePrefixed); + + return $this->getDb()->query($sql, array($idSiteHsr, $idLogHsr)); + } + + public function unlinkSiteRecords($idSiteHsr) + { + $sql = sprintf('DELETE FROM %s WHERE idsitehsr = ?', $this->tablePrefixed); + + return $this->getDb()->query($sql, array($idSiteHsr)); + } + + public function getAllRecords() + { + return $this->getDb()->fetchAll('SELECT * FROM ' . $this->tablePrefixed); + } + + public function deleteNoLongerNeededRecords() + { + // DELETE ALL linked LOG ENTRIES WHOSE idsite does no longer exist or was removed + // we delete links for removed site_hsr entries, and for site_hsr entries with status deleted + // this query should only delete entries when they were deleted manually in the database basically. + // otherwise the application takes already care of removing the needed links + $sql = sprintf( + 'DELETE FROM %1$s WHERE %1$s.idsitehsr NOT IN (select site_hsr.idsitehsr from %2$s site_hsr where site_hsr.status = "%3$s" or site_hsr.status = "%4$s" or site_hsr.status = "%5$s" or site_hsr.status = "%6$s")', + Common::prefixTable('log_hsr_site'), + Common::prefixTable('site_hsr'), + SiteHsrDao::STATUS_ACTIVE, + SiteHsrDao::STATUS_ENDED, + SiteHsrDao::STATUS_PAUSED, + SiteHsrDao::STATUS_ONHOLD + ); + + Db::query($sql); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Dao/SiteHsrDao.php b/files/plugin-HeatmapSessionRecording-5.6.0/Dao/SiteHsrDao.php new file mode 100644 index 0000000..f839278 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Dao/SiteHsrDao.php @@ -0,0 +1,638 @@ +tablePrefixed = Common::prefixTable($this->table); + } + + private function getDb() + { + if (!isset($this->db)) { + $this->db = Db::get(); + } + return $this->db; + } + + public function install() + { + DbHelper::createTable($this->table, " + `idsitehsr` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `idsite` INT(10) UNSIGNED NOT NULL, + `name` VARCHAR(" . Name::MAX_LENGTH . ") NOT NULL, + `description` VARCHAR(" . Description::MAX_LENGTH . ") NOT NULL DEFAULT '', + `sample_rate` DECIMAL(4,1) UNSIGNED NOT NULL DEFAULT " . SampleRate::MAX_RATE . ", + `sample_limit` MEDIUMINT(8) UNSIGNED NOT NULL DEFAULT 1000, + `match_page_rules` TEXT DEFAULT '', + `excluded_elements` TEXT DEFAULT '', + `record_type` TINYINT(1) UNSIGNED DEFAULT 0, + `page_treemirror` MEDIUMBLOB NULL DEFAULT NULL, + `screenshot_url` VARCHAR(300) NULL DEFAULT NULL, + `breakpoint_mobile` SMALLINT(5) UNSIGNED NOT NULL DEFAULT 0, + `breakpoint_tablet` SMALLINT(5) UNSIGNED NOT NULL DEFAULT 0, + `min_session_time` SMALLINT(5) UNSIGNED NOT NULL DEFAULT 0, + `requires_activity` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0, + `capture_keystrokes` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0, + `created_date` DATETIME NOT NULL, + `updated_date` DATETIME NOT NULL, + `status` VARCHAR(10) NOT NULL DEFAULT '" . self::STATUS_ACTIVE . "', + `capture_manually` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0, + `screenshot_deleted_date` DATETIME NULL DEFAULT NULL, + `login` VARCHAR(100) NULL DEFAULT NULL, + `finished_notified` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0, + `auto_repeat` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0, + `repeat_source_idsitehsr` INT(10) UNSIGNED NULL DEFAULT NULL, + `scheduled_date` DATETIME NULL, + PRIMARY KEY(`idsitehsr`), + UNIQUE KEY unique_repeat_source (`repeat_source_idsitehsr`), + INDEX index_status_idsite (`status`, `idsite`), + INDEX index_idsite_record_type (`idsite`, `record_type`)"); + } + + /** + * @param string|null|false $login The login to store. Pass `false` (the default) to fall back to the current user, + * or an explicit value - including `null` - to store it as given. + * @param int|null $repeatSourceIdSiteHsr The heatmap this record is a repeat copy of. A unique key on the column + * makes creating a repeat copy idempotent. + */ + public function createHeatmapRecord( + $idSite, + $name, + $sampleLimit, + $sampleRate, + $matchPageRules, + $excludedElements, + $screenshotUrl, + $breakpointMobile, + $breakpointTablet, + $status, + $captureDomManually, + $createdDate, + $description = '', + $autoRepeat = false, + $login = false, + $scheduledDate = null, + $repeatSourceIdSiteHsr = null + ) { + $columns = array( + 'idsite' => $idSite, + 'name' => $name, + 'description' => (string) $description, + 'sample_limit' => $sampleLimit, + 'match_page_rules' => $matchPageRules, + 'sample_rate' => $sampleRate, + 'status' => $status, + 'record_type' => self::RECORD_TYPE_HEATMAP, + 'created_date' => $createdDate, + 'updated_date' => $createdDate, + 'capture_manually' => !empty($captureDomManually) ? 1 : 0, + 'auto_repeat' => !empty($autoRepeat) ? 1 : 0, + ); + + if (!empty($excludedElements)) { + $columns['excluded_elements'] = $excludedElements; + } + + if (!empty($screenshotUrl)) { + $columns['screenshot_url'] = $screenshotUrl; + } + if ($breakpointMobile !== false && $breakpointMobile !== null) { + $columns['breakpoint_mobile'] = $breakpointMobile; + } + + if ($breakpointTablet !== false && $breakpointTablet !== null) { + $columns['breakpoint_tablet'] = $breakpointTablet; + } + + if (!empty($scheduledDate)) { + $columns['scheduled_date'] = $scheduledDate; + } + + if (!empty($repeatSourceIdSiteHsr)) { + $columns['repeat_source_idsitehsr'] = (int) $repeatSourceIdSiteHsr; + } + + if ($login === false) { + $login = Piwik::getCurrentUserLogin(); + } + + if (!empty($login)) { + $columns['login'] = $login; + } + + return $this->insertColumns($columns); + } + + public function getRecordLogin($idSite, $idSiteHsr) + { + $sql = sprintf('SELECT login FROM %s WHERE idsite = ? AND idsitehsr = ? LIMIT 1', $this->tablePrefixed); + + return $this->getDb()->fetchOne($sql, array((int) $idSite, (int) $idSiteHsr)); + } + + public function getRecordStatus($idSite, $idSiteHsr, $recordType) + { + $sql = sprintf('SELECT status FROM %s WHERE idsite = ? AND idsitehsr = ? AND record_type = ? LIMIT 1', $this->tablePrefixed); + + return $this->getDb()->fetchOne($sql, array((int) $idSite, (int) $idSiteHsr, $recordType)); + } + + /** + * @param string|null|false $login The login to store. Pass `false` (the default) to fall back to the current user, + * or an explicit value - including `null` - to store it as given. + * @param int|null $repeatSourceIdSiteHsr The record this record is a repeat copy of. A unique key on the column + * makes creating a repeat copy idempotent. + */ + public function createSessionRecord( + $idSite, + $name, + $sampleLimit, + $sampleRate, + $matchPageRules, + $minSessionTime, + $requiresActivity, + $captureKeystrokes, + $status, + $createdDate, + $description = '', + $autoRepeat = false, + $login = false, + $scheduledDate = null, + $repeatSourceIdSiteHsr = null + ) { + $columns = array( + 'idsite' => $idSite, + 'name' => $name, + 'description' => (string) $description, + 'sample_limit' => $sampleLimit, + 'match_page_rules' => $matchPageRules, + 'sample_rate' => $sampleRate, + 'status' => $status, + 'record_type' => self::RECORD_TYPE_SESSION, + 'min_session_time' => !empty($minSessionTime) ? $minSessionTime : 0, + 'requires_activity' => !empty($requiresActivity) ? 1 : 0, + 'capture_keystrokes' => !empty($captureKeystrokes) ? 1 : 0, + 'created_date' => $createdDate, + 'updated_date' => $createdDate, + 'auto_repeat' => !empty($autoRepeat) ? 1 : 0, + ); + + if (!empty($scheduledDate)) { + $columns['scheduled_date'] = $scheduledDate; + } + + if (!empty($repeatSourceIdSiteHsr)) { + $columns['repeat_source_idsitehsr'] = (int) $repeatSourceIdSiteHsr; + } + + if ($login === false) { + $login = Piwik::getCurrentUserLogin(); + } + + if (!empty($login)) { + $columns['login'] = $login; + } + + return $this->insertColumns($columns); + } + + private function insertColumns($columns) + { + $columns = $this->encodeFieldsWhereNeeded($columns); + + $bind = array_values($columns); + $placeholder = Common::getSqlStringFieldsArray($columns); + + $sql = sprintf( + 'INSERT INTO %s (`%s`) VALUES(%s)', + $this->tablePrefixed, + implode('`,`', array_keys($columns)), + $placeholder + ); + + $this->getDb()->query($sql, $bind); + + $idSiteHsr = $this->getDb()->lastInsertId(); + + return (int) $idSiteHsr; + } + + protected function getCurrentTime() + { + return Date::now()->getDatetime(); + } + + public function updateHsrColumns($idSite, $idSiteHsr, $columns) + { + $columns = $this->encodeFieldsWhereNeeded($columns); + + if (!empty($columns)) { + if (!isset($columns['updated_date'])) { + $columns['updated_date'] = $this->getCurrentTime(); + } + + if (!empty($columns['page_treemirror'])) { + $columns['capture_manually'] = 0; + } elseif (!empty($columns['capture_manually'])) { + $columns['page_treemirror'] = null; + } + + $fields = array(); + $bind = array(); + foreach ($columns as $key => $value) { + $fields[] = ' ' . $key . ' = ?'; + $bind[] = $value; + } + $fields = implode(',', $fields); + + $query = sprintf('UPDATE %s SET %s WHERE idsitehsr = ? AND idsite = ?', $this->tablePrefixed, $fields); + $bind[] = (int) $idSiteHsr; + $bind[] = (int) $idSite; + + // we do not use $db->update() here as this method is as well used in Tracker mode and the tracker DB does not + // support "->update()". Therefore we use the query method where we know it works with tracker and regular DB + $this->getDb()->query($query, $bind); + } + } + + public function hasRecords($idSite, $recordType) + { + $sql = sprintf('SELECT idsite FROM %s WHERE record_type = ? and `status` IN(?,?,?) and idsite = ? LIMIT 1', $this->tablePrefixed); + $records = $this->getDb()->fetchRow($sql, array($recordType, self::STATUS_ENDED, self::STATUS_ACTIVE, self::STATUS_ONHOLD, $idSite)); + + return !empty($records); + } + + public function deleteRecord($idSite, $idSiteHsr) + { + // now we delete the heatmap manually and it should notice all log entries for that heatmap are no longer needed + $sql = sprintf('DELETE FROM %s WHERE idsitehsr = ? and idsite = ?', $this->tablePrefixed); + Db::query($sql, array($idSiteHsr, $idSite)); + } + + public function getFinishedRecordsPendingNotification() + { + $sql = sprintf('SELECT idsitehsr, idsite, name, record_type, login FROM %s WHERE status = ? AND finished_notified = 0 AND login IS NOT NULL', $this->tablePrefixed); + + return $this->getDb()->fetchAll($sql, array(self::STATUS_ENDED)); + } + + public function markNotified($idSiteHsr) + { + $sql = sprintf('UPDATE %s SET finished_notified = 1 WHERE idsitehsr = ?', $this->tablePrefixed); + $this->getDb()->query($sql, array((int) $idSiteHsr)); + } + + /** + * A record is pending a repeat while no copy referencing it exists yet. Deriving this from the copies themselves + * means a failed run simply retries, and the unique key on repeat_source_idsitehsr prevents a second copy. + */ + public function getRecordsPendingRepeat($recordType) + { + $sql = sprintf( + 'SELECT s.idsitehsr, s.idsite, s.name FROM %1$s s WHERE s.status = ? AND s.auto_repeat = 1 AND s.record_type = ? + AND NOT EXISTS (SELECT 1 FROM %1$s c WHERE c.repeat_source_idsitehsr = s.idsitehsr)', + $this->tablePrefixed + ); + + return $this->getDb()->fetchAll($sql, array(self::STATUS_ENDED, $recordType)); + } + + public function getOnHoldRecordsToActivate($recordType) + { + $sql = sprintf('SELECT idsitehsr, idsite FROM %s WHERE status = ? AND scheduled_date IS NOT NULL AND scheduled_date <= ? AND record_type = ?', $this->tablePrefixed); + + return $this->getDb()->fetchAll($sql, array(self::STATUS_ONHOLD, $this->getCurrentTime(), $recordType)); + } + + /** + * Activates an on hold record only while it is still on hold and still due, so a record that was ended, deleted or + * rescheduled after the batch was selected is left untouched. + * + * @return bool whether the record was activated + */ + public function activateOnHoldRecord($idSite, $idSiteHsr, $activatedDate, $recordType) + { + $sql = sprintf( + 'UPDATE %s SET status = ?, created_date = ?, updated_date = ? + WHERE idsitehsr = ? AND idsite = ? AND record_type = ? AND status = ? AND scheduled_date IS NOT NULL AND scheduled_date <= ?', + $this->tablePrefixed + ); + + $query = $this->getDb()->query($sql, array( + self::STATUS_ACTIVE, + $activatedDate, + $activatedDate, + (int) $idSiteHsr, + (int) $idSite, + $recordType, + self::STATUS_ONHOLD, + $this->getCurrentTime(), + )); + + return $query->rowCount() > 0; + } + + public function anonymizeLogin($userLogin) + { + $sql = sprintf('UPDATE %s SET login = NULL WHERE login = ?', $this->tablePrefixed); + $this->getDb()->query($sql, array($userLogin)); + } + + private function getAllFieldNames($includePageTreeMirror) + { + $fields = '`idsitehsr`,`idsite`,`name`, `description`, `sample_rate`, `sample_limit`, `match_page_rules`, `excluded_elements`, `record_type`, '; + if (!empty($includePageTreeMirror)) { + $fields .= '`page_treemirror`,'; + } + $fields .= '`screenshot_url`, `breakpoint_mobile`, `breakpoint_tablet`, `min_session_time` , `requires_activity`, `capture_keystrokes`, `created_date`, `updated_date`, `status`, '; + $fields .= '`capture_manually`, `screenshot_deleted_date`, `auto_repeat`, `scheduled_date`'; + return $fields; + } + + public function getRecords($idSite, $recordType, $includePageTreeMirror) + { + $fields = $this->getAllFieldNames($includePageTreeMirror); + $sql = sprintf('SELECT ' . $fields . ' FROM %s WHERE record_type = ? and `status` IN(?,?,?,?) and idsite = ? order by created_date desc', $this->tablePrefixed); + $records = $this->getDb()->fetchAll($sql, array($recordType, self::STATUS_ENDED, self::STATUS_ACTIVE, self::STATUS_PAUSED, self::STATUS_ONHOLD, $idSite)); + + return $this->enrichRecords($records); + } + + /** + * Returns only active heatmaps that have a snapshot, page_treemirror included. + * + * Unlike {@see getRecords()} this filters in SQL, so ended/paused heatmaps and their + * potentially large page_treemirror blobs are never loaded into memory. + */ + public function getActiveHeatmapsWithPageTreemirror($idSite) + { + $fields = $this->getAllFieldNames($includePageTreeMirror = true); + $sql = sprintf('SELECT ' . $fields . ' FROM %s WHERE record_type = ? and `status` = ? and idsite = ? and page_treemirror IS NOT NULL and page_treemirror != \'\' order by created_date desc', $this->tablePrefixed); + $records = $this->getDb()->fetchAll($sql, array(self::RECORD_TYPE_HEATMAP, self::STATUS_ACTIVE, $idSite)); + + return $this->enrichRecords($records); + } + + public function getRecord($idSite, $idSiteHsr, $recordType) + { + $sql = sprintf('SELECT * FROM %s WHERE record_type = ? and `status` IN(?,?,?,?) and idsite = ? and idsitehsr = ? LIMIT 1', $this->tablePrefixed); + $record = $this->getDb()->fetchRow($sql, array($recordType, self::STATUS_ENDED, self::STATUS_ACTIVE, self::STATUS_PAUSED, self::STATUS_ONHOLD, $idSite, $idSiteHsr)); + + return $this->enrichRecord($record); + } + + public function getNumRecordsTotal($recordType) + { + $sql = sprintf('SELECT count(*) as total FROM %s WHERE record_type = ? and `status` IN(?,?,?,?)', $this->tablePrefixed); + return $this->getDb()->fetchOne($sql, array($recordType, self::STATUS_ENDED, self::STATUS_ACTIVE, self::STATUS_PAUSED, self::STATUS_ONHOLD)); + } + + public function hasActiveRecordsAcrossSites() + { + $query = $this->getQueryActiveRequests(); + + $sql = sprintf("SELECT count(*) as numrecords FROM %s WHERE %s LIMIT 1", $this->tablePrefixed, $query['where']); + $numRecords = $this->getDb()->fetchOne($sql, $query['bind']); + + return !empty($numRecords); + } + + private function getQueryActiveRequests() + { + // for sessions we also need to return ended sessions to make sure to record all page views once a user takes part in + // a session recording. Otherwise as soon as the limit of sessions has reached, it would stop recording any further page views in already started session recordings + + // we only fetch recorded sessions with status ended for the last 24 hours to not expose any potential config and for faster processing etc + $oneDayAgo = Date::now()->subDay(1)->getDatetime(); + + return array( + 'where' => '(status = ? or (record_type = ? and status = ? and updated_date > ?))', + 'bind' => array(self::STATUS_ACTIVE, self::RECORD_TYPE_SESSION, self::STATUS_ENDED, $oneDayAgo) + ); + } + + /** + * For performance reasons the page_treemirror will be read only partially! + * @param $idSite + * @return mixed + * @throws \Piwik\Tracker\Db\DbException + */ + public function getActiveRecords($idSite) + { + $query = $this->getQueryActiveRequests(); + + $bind = $query['bind']; + $bind[] = $idSite; + + $fields = $this->getAllFieldNames(false); + // we want to avoid needing to read all the entire treemirror every time the tracking cache will be updated + // as in worst case every treemirror can be 16MB or in rare cases even more. Most of the time it's only like 50KB or so + // but we want to avoid fetching heaps of unneeded data + $fields .= ', SUBSTRING(page_treemirror, 1, 10) as page_treemirror'; + + // NOTE: If you adjust this query, you might also + $sql = sprintf("SELECT " . $fields . " FROM %s WHERE %s and idsite = ? ORDER BY idsitehsr asc", $this->tablePrefixed, $query['where']); + $records = $this->getDb()->fetchAll($sql, $bind); + + foreach ($records as $index => $record) { + if (!empty($record['page_treemirror'])) { + // avoids an error when it tries to uncompress + $records[$index]['page_treemirror'] = $this->compress($record['page_treemirror']); + } + } + + return $this->enrichRecords($records); + } + + private function enrichRecords($records) + { + if (empty($records)) { + return $records; + } + + foreach ($records as $index => $record) { + $records[$index] = $this->enrichRecord($record); + } + + return $records; + } + + private function enrichRecord($record) + { + if (empty($record)) { + return $record; + } + + $record['idsitehsr'] = (int) $record['idsitehsr']; + $record['idsite'] = (int) $record['idsite']; + $record['sample_rate'] = number_format($record['sample_rate'], 1, '.', ''); + $record['record_type'] = (int) $record['record_type']; + $record['sample_limit'] = (int) $record['sample_limit']; + $record['min_session_time'] = (int) $record['min_session_time']; + $record['breakpoint_mobile'] = (int) $record['breakpoint_mobile']; + $record['breakpoint_tablet'] = (int) $record['breakpoint_tablet']; + $record['match_page_rules'] = $this->decodeField($record['match_page_rules']); + $record['requires_activity'] = !empty($record['requires_activity']); + $record['capture_keystrokes'] = !empty($record['capture_keystrokes']); + $record['capture_manually'] = !empty($record['capture_manually']) ? 1 : 0; + $record['auto_repeat'] = !empty($record['auto_repeat']) ? 1 : 0; + + if (!empty($record['page_treemirror'])) { + $record['page_treemirror'] = $this->uncompress($record['page_treemirror']); + } else { + $record['page_treemirror'] = ''; + } + + if ($record['record_type'] === self::RECORD_TYPE_SESSION) { + // screenshot_deleted_date is heatmap-only; never expose it on session records + unset($record['screenshot_deleted_date']); + } + + // these columns are internal and should not be exposed via getRecord()/getRecords(). + // getAllEntities() restores them explicitly. + unset($record['login'], $record['finished_notified'], $record['repeat_source_idsitehsr']); + + return $record; + } + + public function uninstall() + { + Db::query(sprintf('DROP TABLE IF EXISTS `%s`', $this->tablePrefixed)); + } + + public function getAllEntities() + { + $records = $this->getDb()->fetchAll('SELECT * FROM ' . $this->tablePrefixed); + + $enriched = $this->enrichRecords($records); + + // enrichRecord() strips the internal login/finished_notified columns; restore them here + foreach ($enriched as $index => $record) { + if (array_key_exists('login', $records[$index])) { + $enriched[$index]['login'] = $records[$index]['login']; + } + if (array_key_exists('finished_notified', $records[$index])) { + $enriched[$index]['finished_notified'] = (int) $records[$index]['finished_notified']; + } + if (array_key_exists('repeat_source_idsitehsr', $records[$index])) { + $source = $records[$index]['repeat_source_idsitehsr']; + $enriched[$index]['repeat_source_idsitehsr'] = $source === null ? null : (int) $source; + } + } + + return $enriched; + } + + private function encodeFieldsWhereNeeded($columns) + { + foreach ($columns as $column => $value) { + if ($column === 'match_page_rules') { + $columns[$column] = $this->encodeField($value); + } elseif ($column === 'page_treemirror') { + if (!empty($value)) { + $columns[$column] = $this->compress($value); + } else { + $columns[$column] = null; + } + } elseif (in_array($column, array('breakpoint_mobile', 'breakpoint_tablet', 'min_session_time', 'sample_rate'), $strict = true)) { + if ($value > self::MAX_SMALLINT) { + $columns[$column] = self::MAX_SMALLINT; + } + } elseif (in_array($column, array('requires_activity', 'capture_keystrokes'), $strict = true)) { + if (!empty($value)) { + $columns[$column] = 1; + } else { + $columns[$column] = 0; + } + } + } + + return $columns; + } + + private function compress($data) + { + if (!empty($data)) { + return gzcompress($data); + } + + return $data; + } + + private function uncompress($data) + { + if (!empty($data)) { + return gzuncompress($data); + } + + return $data; + } + + private function encodeField($field) + { + if (empty($field) || !is_array($field)) { + $field = array(); + } + + return json_encode($field); + } + + private function decodeField($field) + { + if (!empty($field)) { + $field = @json_decode($field, true); + } + + if (empty($field) || !is_array($field)) { + $field = array(); + } + + return $field; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/DataTable/Filter/EnrichRecordedSessions.php b/files/plugin-HeatmapSessionRecording-5.6.0/DataTable/Filter/EnrichRecordedSessions.php new file mode 100644 index 0000000..0a306e7 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/DataTable/Filter/EnrichRecordedSessions.php @@ -0,0 +1,73 @@ +getRowsWithoutSummaryRow() as $row) { + if ($isAnonymous) { + foreach (self::getBlockedFields() as $blockedField) { + if ($row->getColumn($blockedField) !== false) { + $row->setColumn($blockedField, false); + } + } + } else { + $row->setColumn('idvisitor', bin2hex($row->getColumn('idvisitor'))); + } + } + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Diagnostic/ConfigsPhpCheck.php b/files/plugin-HeatmapSessionRecording-5.6.0/Diagnostic/ConfigsPhpCheck.php new file mode 100644 index 0000000..c436154 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Diagnostic/ConfigsPhpCheck.php @@ -0,0 +1,112 @@ +translator = $translator; + } + + public function execute() + { + $label = $this->translator->translate('Heatmap & Session Recording Tracking'); + + $site = new Model(); + $idSites = $site->getSitesId(); + $idSite = array_shift($idSites); + + $baseUrl = SettingsPiwik::getPiwikUrl(); + if (!Common::stringEndsWith($baseUrl, '/')) { + $baseUrl .= '/'; + } + + $baseUrl .= HeatmapSessionRecording::getPathPrefix() . '/'; + $baseUrl .= 'HeatmapSessionRecording/configs.php'; + $testUrl = $baseUrl . '?idsite=' . (int) $idSite . '&trackerid=5lX6EM&url=http%3A%2F%2Ftest.test%2F'; + + $error = null; + $response = null; + + $errorResult = $this->translator->translate('HeatmapSessionRecording_ConfigsPhpErrorResult'); + $manualCheck = $this->translator->translate('HeatmapSessionRecording_ConfigsPhpManualCheck'); + + if (method_exists('\Piwik\SettingsPiwik', 'isInternetEnabled')) { + $isInternetEnabled = SettingsPiwik::isInternetEnabled(); + if (!$isInternetEnabled) { + $unknown = $this->translator->translate('HeatmapSessionRecording_ConfigsInternetDisabled', $testUrl) . ' ' . $manualCheck; + return array(DiagnosticResult::singleResult($label, DiagnosticResult::STATUS_WARNING, $unknown)); + } + } + + try { + $response = Http::sendHttpRequest($testUrl, $timeout = 2); + } catch (\Exception $e) { + $error = $e->getMessage(); + } + + if (!empty($response)) { + $response = Common::mb_strtolower($response); + if (strpos($response, 'piwik.heatmapsessionrecording') !== false) { + $message = $this->translator->translate('HeatmapSessionRecording_ConfigsPhpSuccess', $baseUrl); + return array(DiagnosticResult::singleResult($label, DiagnosticResult::STATUS_OK, $message)); + } elseif (strpos($response, 'forbidden') !== false || strpos($response, ' forbidden') !== false || strpos($response, ' denied ') !== false || strpos($response, '403 ') !== false || strpos($response, '404 ') !== false) { + // Likely the server returned eg a 403 HTML + $message = $this->translator->translate('HeatmapSessionRecording_ConfigsPhpNotAccessible', array($testUrl)) . ' ' . $errorResult; + return array(DiagnosticResult::singleResult($label, DiagnosticResult::STATUS_ERROR, $message)); + } + } + + if (!empty($error)) { + $error = Common::mb_strtolower($error); + + if (strpos($error, 'forbidden ') !== false || strpos($error, ' forbidden') !== false || strpos($error, 'denied ') !== false || strpos($error, '403 ') !== false || strpos($error, '404 ') !== false) { + $message = $this->translator->translate('HeatmapSessionRecording_ConfigsPhpNotAccessible', array($testUrl)) . ' ' . $errorResult; + return array(DiagnosticResult::singleResult($label, DiagnosticResult::STATUS_ERROR, $message)); + } + + if (strpos($error, 'ssl ') !== false || strpos($error, ' ssl') !== false || strpos($error, 'self signed') !== false || strpos($error, 'certificate ') !== false) { + $message = $this->translator->translate('HeatmapSessionRecording_ConfigsPhpSelfSignedError', array($testUrl)) . ' ' . $manualCheck; + return array(DiagnosticResult::singleResult($label, DiagnosticResult::STATUS_WARNING, $message)); + } + + $unknownError = $this->translator->translate('HeatmapSessionRecording_ConfigsPhpUnknownError', array($testUrl, $error)) . ' ' . $errorResult; + return array(DiagnosticResult::singleResult($label, DiagnosticResult::STATUS_WARNING, $unknownError)); + } + + $unknown = $this->translator->translate('HeatmapSessionRecording_ConfigsPhpUnknown', $testUrl) . $manualCheck; + return array(DiagnosticResult::singleResult($label, DiagnosticResult::STATUS_WARNING, $unknown)); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Emails/FinishedEmail.php b/files/plugin-HeatmapSessionRecording-5.6.0/Emails/FinishedEmail.php new file mode 100644 index 0000000..f10b809 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Emails/FinishedEmail.php @@ -0,0 +1,132 @@ +toEmail = $toEmail; + $this->name = $name; + $this->idSite = $idSite; + $this->idSiteHsr = $idSiteHsr; + $this->login = $login; + + $this->setUpEmail(); + } + + private function setUpEmail() + { + $this->setDefaultFromPiwik(); + $this->addTo($this->toEmail); + $this->setSubject($this->getEmailSubject()); + $this->addReplyTo($this->getFrom(), $this->getFromName()); + $this->setWrappedHtmlBody($this->getBodyView()); + } + + protected function getBodyView() + { + $view = new View('@HeatmapSessionRecording/_finishedEmail.twig'); + $view->bodyText = $this->getEmailBodyText(); + $view->greeting = Piwik::translate('HeatmapSessionRecording_EmailFinishedGreeting'); + $view->regards = Piwik::translate('HeatmapSessionRecording_EmailFinishedRegards'); + $view->team = Piwik::translate('HeatmapSessionRecording_EmailFinishedTeam'); + $view->unsubscribeText = $this->getUnsubscribeText(); + + return $view; + } + + protected function buildReportUrl(): string + { + $baseUrl = rtrim(SettingsPiwik::getPiwikUrl(), '/') . '/'; + + $idSite = (int) $this->idSite; + $idSiteHsr = (int) $this->idSiteHsr; + $recordType = (int) $this->getRecordType(); + + // Route through the goToReport action so the id can be validated server-side before the + // reporting SPA loads. If the heatmap/session recording was deleted after the email was + // sent, the action redirects to the Manage page with a "no longer exists" notice instead + // of silently falling back to another available report. + return $baseUrl + . 'index.php?module=HeatmapSessionRecording&action=goToReport' + . '&idSite=' . $idSite + . '&idSiteHsr=' . $idSiteHsr + . '&recordType=' . $recordType; + } + + protected function getEmailBodyText(): string + { + // Escape both the link and the (user-controlled) name so they are safe under {{ bodyText|raw }}. + $safeUrl = Common::sanitizeInputValue($this->buildReportUrl()); + $linkText = Common::sanitizeInputValue(Piwik::translate('HeatmapSessionRecording_EmailViewResultsLink')); + $link = '' . $linkText . ''; + $safeName = Common::sanitizeInputValue(Common::unsanitizeInputValue($this->name)); + + return Piwik::translate($this->getBodyTranslationKey(), array($safeName, $link)); + } + + protected function getUnsubscribeText(): string + { + // Escape the link so it is safe under {{ unsubscribeText|raw }}. + $safeUrl = Common::sanitizeInputValue(Unsubscribe::buildUrl($this->login, $this->toEmail)); + $linkText = Common::sanitizeInputValue(Piwik::translate('HeatmapSessionRecording_Unsubscribe')); + $link = '' . $linkText . ''; + + return Piwik::translate('HeatmapSessionRecording_EmailUnsubscribeText', array($link)); + } + + abstract protected function getEmailSubject(): string; + + abstract protected function getBodyTranslationKey(): string; + + abstract protected function getRecordType(): int; +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Emails/HeatmapFinishedEmail.php b/files/plugin-HeatmapSessionRecording-5.6.0/Emails/HeatmapFinishedEmail.php new file mode 100644 index 0000000..7ae0eb9 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Emails/HeatmapFinishedEmail.php @@ -0,0 +1,38 @@ + + private const WEIGHT_TEXT = 1; // headings / paragraphs (stray clicks) + + /** tags that are not rendered / have no visible area, so movement never lands on them */ + private const NON_VISIBLE_TAGS = [ + 'script', 'style', 'head', 'title', 'meta', 'link', 'base', 'noscript', + 'template', 'br', 'wbr', 'param', 'source', 'track', 'col', 'colgroup', + ]; + + /** @var array weighted interactive targets for clicks */ + private $clickTargets = []; + /** @var int */ + private $totalWeight = 0; + /** @var string[] selectors of all visible elements, for (near-uniform) mouse movement */ + private $moveTargets = []; + + public function __construct(string $treemirrorJson) + { + $decoded = json_decode($treemirrorJson, true); + if (!is_array($decoded) || empty($decoded['children'])) { + return; + } + $this->buildTargets(new HsrCssPath($decoded)); + } + + public function hasTargets(): bool + { + return $this->totalWeight > 0; + } + + private function buildTargets(HsrCssPath $cssPath): void + { + foreach ($cssPath->elementIds() as $id) { + $tag = $cssPath->getTagName($id); + $isVisible = !in_array($tag, self::NON_VISIBLE_TAGS, true); + $weight = $this->weightFor($cssPath, $id, $tag); + if ($weight <= 0 && !$isVisible) { + continue; // neither clickable nor a valid movement target + } + + $selector = $cssPath->getSelector($id); + if ($selector === '') { + continue; + } + if ($weight > 0) { + $this->clickTargets[] = ['selector' => $selector, 'weight' => $weight]; + $this->totalWeight += $weight; + } + if ($isVisible) { + $this->moveTargets[] = $selector; + } + } + } + + private function weightFor(HsrCssPath $cssPath, int $id, ?string $tag): int + { + switch ($tag) { + case 'a': + return self::WEIGHT_LINK; + case 'button': + return self::WEIGHT_CTA; + case 'select': + case 'textarea': + return self::WEIGHT_FIELD; + case 'input': + $type = strtolower((string) $cssPath->getAttribute($id, 'type')); + if (in_array($type, ['submit', 'button', 'reset'], true)) { + return self::WEIGHT_CTA; + } + if (in_array($type, ['hidden', 'checkbox', 'radio'], true)) { + return 0; + } + return self::WEIGHT_FIELD; + case 'img': + return $cssPath->isWithinTag($id, 'a') ? self::WEIGHT_LINK_IMAGE : 0; + case 'h1': + case 'h2': + case 'h3': + case 'p': + return self::WEIGHT_TEXT; + default: + if (strtolower((string) $cssPath->getAttribute($id, 'role')) === 'button') { + return self::WEIGHT_CTA; + } + return 0; + } + } + + /** + * Build one recording's worth of events for a visit. + * + * The viewport is taken from the visit's tracked screen resolution when given: the `res` + * tracking param determines the heatmap device group (LogHsr::getDeviceType()), so a mobile + * visit must not carry a desktop-sized viewport/fold. + * + * @param mixed $faker \Faker\Generator; Faker ships in VisitorGenerator's vendor dir, so the + * class is not resolvable from this plugin and the param stays untyped + * @param int|null $screenWidth the visit's tracked resolution, used as the viewport + * @param int|null $screenHeight + * @return array{events:array,total_time:int,viewport_width:int,viewport_height:int,scroll_max:int,fold:int}|null + */ + public function generateVisit($faker, ?int $screenWidth = null, ?int $screenHeight = null): ?array + { + if (!$this->hasTargets()) { + return null; + } + + if (!empty($screenWidth) && !empty($screenHeight)) { + [$vw, $vh] = [$screenWidth, $screenHeight]; + } else { + [$vw, $vh] = $faker->randomElement([[1920, 1080], [1536, 864], [1440, 900], [1366, 768]]); + } + $docHeight = $faker->numberBetween(2400, 6000); + $fold = (int) round(($vh / $docHeight) * self::SCROLL_ACCURACY); + + $numClicks = $faker->numberBetween(1, 6); + $numMoves = $faker->numberBetween(2, 8); + $numScrolls = $faker->numberBetween(1, 4); + + $events = []; + $time = $faker->numberBetween(200, 900); + + // interleave moves and clicks + $actions = array_merge(array_fill(0, $numClicks, 'click'), array_fill(0, $numMoves, 'move')); + shuffle($actions); + foreach ($actions as $action) { + $time += $faker->numberBetween(150, 1400); + $isClick = ($action === 'click'); + $events[] = [ + 'ti' => $time, + 'ty' => $isClick ? RequestProcessor::EVENT_TYPE_CLICK : RequestProcessor::EVENT_TYPE_MOVEMENT, + 's' => $isClick ? $this->pickClickSelector($faker) : $this->pickMoveSelector($faker), + // even numbers only, matching the tracker (it bumps odd offsets to even to + // reduce row cardinality when grouping / archiving, see tracker.js) + 'x' => $faker->numberBetween(self::OFFSET_MIN / 2, self::OFFSET_MAX / 2) * 2, + 'y' => $faker->numberBetween(self::OFFSET_MIN / 2, self::OFFSET_MAX / 2) * 2, + ]; + } + + // scrolls (down the page) + $scrollMax = $faker->numberBetween(150, 350); + for ($i = 0; $i < $numScrolls; $i++) { + $time += $faker->numberBetween(300, 2000); + $scrollMax = min(self::SCROLL_ACCURACY, $scrollMax + $faker->numberBetween(0, 200)); + $events[] = ['ti' => $time, 'ty' => RequestProcessor::EVENT_TYPE_SCROLL, 'x' => 0, 'y' => $scrollMax]; + } + + usort($events, function ($a, $b) { + return $a['ti'] <=> $b['ti']; + }); + + return [ + 'events' => $events, + 'total_time' => $time + $faker->numberBetween(500, 3000), + 'viewport_width' => $vw, + 'viewport_height' => $vh, + 'scroll_max' => $scrollMax, + 'fold' => $fold, + ]; + } + + /** Weighted toward interactive elements, with some spread. */ + private function pickClickSelector($faker): string + { + // 30% uniform for spread, otherwise weighted toward interactive elements + if ($faker->boolean(30)) { + return $this->clickTargets[$faker->numberBetween(0, count($this->clickTargets) - 1)]['selector']; + } + $r = $faker->numberBetween(1, $this->totalWeight); + foreach ($this->clickTargets as $target) { + $r -= $target['weight']; + if ($r <= 0) { + return $target['selector']; + } + } + return $this->clickTargets[count($this->clickTargets) - 1]['selector']; + } + + /** Near-uniform across all visible elements - movement is not tied to interactive elements. */ + private function pickMoveSelector($faker): string + { + if (empty($this->moveTargets)) { + return $this->pickClickSelector($faker); + } + return $this->moveTargets[$faker->numberBetween(0, count($this->moveTargets) - 1)]; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Fake/HsrCssPath.php b/files/plugin-HeatmapSessionRecording-5.6.0/Fake/HsrCssPath.php new file mode 100644 index 0000000..87c8d53 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Fake/HsrCssPath.php @@ -0,0 +1,283 @@ + id => node (nodeType, tagName, attributes, parentId, childIds, elementChildIds) */ + private $nodes = []; + + /** + * @param array $treemirror decoded page_treemirror: ['rootId' => int, 'children' => [...]] + */ + public function __construct(array $treemirror) + { + // Synthetic document node so top-level elements have a DOCUMENT parent, matching the browser DOM. + // A non-numeric sentinel key is used instead of the treemirror rootId: some snapshots number + // their nodes starting from the same value as rootId, which would otherwise overwrite the + // document node and create a parent-pointer cycle (infinite loop when walking ancestors). + $this->nodes[self::DOCUMENT_KEY] = ['nodeType' => self::NODE_DOCUMENT, 'tagName' => null, 'attributes' => [], 'parentId' => null, 'childIds' => []]; + foreach (($treemirror['children'] ?? []) as $child) { + $this->addNode($child, self::DOCUMENT_KEY); + } + // precompute element-only child lists (matches JS parent.children) + foreach ($this->nodes as $id => $node) { + $this->nodes[$id]['elementChildIds'] = array_values(array_filter( + $node['childIds'], + function ($cid) { + return $this->nodes[$cid]['nodeType'] === self::NODE_ELEMENT; + } + )); + } + } + + private function addNode(array $node, $parentId): void + { + if (!isset($node['id'])) { + return; + } + $id = (int) $node['id']; + $this->nodes[$id] = [ + 'nodeType' => $node['nodeType'] ?? 0, + 'tagName' => $node['tagName'] ?? null, + 'attributes' => $node['attributes'] ?? [], + 'parentId' => $parentId, + 'childIds' => [], + ]; + $this->nodes[$parentId]['childIds'][] = $id; + foreach (($node['childNodes'] ?? []) as $child) { + $this->addNode($child, $id); + } + } + + /** @return int[] ids of all element nodes */ + public function elementIds(): array + { + $ids = []; + foreach ($this->nodes as $id => $node) { + if ($node['nodeType'] === self::NODE_ELEMENT) { + $ids[] = $id; + } + } + return $ids; + } + + public function getTagName(int $id): ?string + { + $tag = $this->nodes[$id]['tagName'] ?? null; + return $tag === null ? null : strtolower($tag); + } + + public function getAttribute(int $id, string $name): ?string + { + return $this->nodes[$id]['attributes'][$name] ?? null; + } + + /** Whether the node has an ancestor with the given (lowercase) tag name. */ + public function isWithinTag(int $id, string $tagName): bool + { + $parentId = $this->nodes[$id]['parentId'] ?? null; + while ($parentId !== null && isset($this->nodes[$parentId])) { + if (strtolower((string) ($this->nodes[$parentId]['tagName'] ?? '')) === $tagName) { + return true; + } + $parentId = $this->nodes[$parentId]['parentId'] ?? null; + } + return false; + } + + public function getSelector(int $id): string + { + return $this->cssPath($id); + } + + private function cssPath(int $id): string + { + if (($this->nodes[$id]['nodeType'] ?? null) !== self::NODE_ELEMENT) { + return ''; + } + $steps = []; + $contextId = $id; + while ($contextId !== null && isset($this->nodes[$contextId])) { + $step = $this->cssPathStep($contextId, $contextId === $id); + if ($step === null) { + break; + } + $steps[] = $step['value']; + if ($step['optimized']) { + break; + } + $contextId = $this->nodes[$contextId]['parentId']; + } + return implode(' > ', array_reverse($steps)); + } + + /** @return array{value:string,optimized:bool}|null */ + private function cssPathStep($id, bool $isTargetNode): ?array + { + $node = $this->nodes[$id]; + if ($node['nodeType'] !== self::NODE_ELEMENT) { + return null; + } + + $idAttr = $node['attributes']['id'] ?? null; + $nodeName = strtolower($node['tagName']); + + if ($idAttr !== null && $idAttr !== '') { + return ['value' => $nodeName . $this->idSelector($idAttr), 'optimized' => true]; + } + + $parentId = $node['parentId']; + $parent = ($parentId !== null) ? ($this->nodes[$parentId] ?? null) : null; + if ($parent === null || $parent['nodeType'] === self::NODE_DOCUMENT) { + return ['value' => $nodeName, 'optimized' => true]; + } + + $prefixedOwn = $this->prefixedClassNames($node); + $needsClassNames = false; + $needsNthChild = false; + $ownIndex = -1; + + $siblings = $parent['elementChildIds']; + $count = count($siblings); + for ($i = 0; ($ownIndex === -1 || !$needsNthChild) && $i < $count; $i++) { + $sibId = $siblings[$i]; + if ($sibId === $id) { + $ownIndex = $i; + continue; + } + if ($needsNthChild) { + continue; + } + $sibling = $this->nodes[$sibId]; + if (strtolower($sibling['tagName']) !== $nodeName) { + continue; + } + $needsClassNames = true; + $ownClassNames = array_fill_keys($prefixedOwn, true); + $ownClassNameCount = count($prefixedOwn); + if ($ownClassNameCount === 0) { + $needsNthChild = true; + continue; + } + foreach ($this->prefixedClassNames($sibling) as $siblingClass) { + if (!array_key_exists($siblingClass, $ownClassNames)) { + continue; + } + unset($ownClassNames[$siblingClass]); + $ownClassNameCount--; + if ($ownClassNameCount === 0) { + $needsNthChild = true; + break; + } + } + } + + $result = $nodeName; + $attrs = $node['attributes']; + if ($isTargetNode && $nodeName === 'input' && !empty($attrs['type']) && empty($attrs['id']) && empty($attrs['class'])) { + $result .= '[type="' . $attrs['type'] . '"]'; + } + if ($needsNthChild) { + $result .= ':nth-child(' . ($ownIndex + 1) . ')'; + } elseif ($needsClassNames) { + foreach ($prefixedOwn as $prefixed) { + $result .= '.' . $this->escapeIdentifierIfNeeded(substr($prefixed, 1)); + } + } + return ['value' => $result, 'optimized' => false]; + } + + /** @return string[] class names prefixed with "$" (matches JS prefixedElementClassNames) */ + private function prefixedClassNames(array $node): array + { + $class = (string) ($node['attributes']['class'] ?? ''); + if ($class === '') { + return []; + } + $parts = preg_split('/\s+/', $class, -1, PREG_SPLIT_NO_EMPTY); + return array_map(function ($n) { + return '$' . $n; + }, $parts); + } + + private function idSelector(string $id): string + { + return '#' . $this->escapeIdentifierIfNeeded($id); + } + + private function escapeIdentifierIfNeeded(string $ident): string + { + if ($this->isCssIdentifier($ident)) { + return $ident; + } + $shouldEscapeFirst = (bool) preg_match('/^(?:[0-9]|-[0-9-]?)/', $ident); + $chars = preg_split('//u', $ident, -1, PREG_SPLIT_NO_EMPTY); + $lastIndex = count($chars) - 1; + $out = ''; + foreach ($chars as $i => $c) { + if (($shouldEscapeFirst && $i === 0) || !$this->isCssIdentChar($c)) { + $out .= $this->escapeAsciiChar($c, $i === $lastIndex); + } else { + $out .= $c; + } + } + return $out; + } + + private function escapeAsciiChar(string $c, bool $isLast): string + { + return '\\' . $this->toHexByte($c) . ($isLast ? '' : ' '); + } + + private function toHexByte(string $c): string + { + $code = mb_ord($c, 'UTF-8'); + $hex = dechex($code); + if (strlen($hex) === 1) { + $hex = '0' . $hex; + } + return $hex; + } + + private function isCssIdentChar(string $c): bool + { + if (preg_match('/[a-zA-Z0-9_\-]/', $c)) { + return true; + } + return mb_ord($c, 'UTF-8') >= 0xA0; + } + + private function isCssIdentifier(string $value): bool + { + return (bool) preg_match('/^-?[a-zA-Z_][a-zA-Z0-9_\-]*$/', $value); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/HeatmapSessionRecording.php b/files/plugin-HeatmapSessionRecording-5.6.0/HeatmapSessionRecording.php new file mode 100644 index 0000000..04d5145 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/HeatmapSessionRecording.php @@ -0,0 +1,1088 @@ +register_route('HeatmapSessionRecording', 'getHeatmap'); + $api->register_route('HeatmapSessionRecording', 'getHeatmaps'); + $api->register_route('HeatmapSessionRecording', 'getRecordedHeatmapMetadata'); + $api->register_route('HeatmapSessionRecording', 'getRecordedHeatmap'); + + $api->register_route('HeatmapSessionRecording', 'getSessionRecording'); + $api->register_route('HeatmapSessionRecording', 'getSessionRecordings'); + $api->register_route('HeatmapSessionRecording', 'getRecordedSessions'); + $api->register_route('HeatmapSessionRecording', 'getRecordedSession'); + }); + + /** + * @param array $actions + * @param \WP_Post $post + * + * @return mixed + */ + function add_new_heat_map_link($actions, $post) + { + if ( + !$post + || !is_plugin_active('matomo/matomo.php') + || !current_user_can('write_matomo') + ) { + return $actions; + } + + if ($post->post_status !== 'publish') { + // the permalink url wouldn't be correct yet for unpublished post + return $actions; + } + + $postUrl = get_permalink($post); + $rules = array(array( + 'attribute' => 'url', + 'type' => 'equals_simple', + 'inverted' => 0, + 'value' => $postUrl + )); + + $hsrParams = array( + 'idSite' => 1, + 'idSiteHsr' => 0, + 'name' => $post->post_title, + // Encoded to avoid pitfalls of decoding multi-dimensional array URL params in JavaScript + 'matchPageRules' => json_encode($rules) + ); + + $url = Menu::get_matomo_reporting_url( + 'HeatmapSessionRecording_Heatmaps', + 'HeatmapSessionRecording_ManageHeatmaps', + $hsrParams + ); + + $actions['create_heatmap'] = 'Create Heatmap'; + return $actions; + } + + function get_matomo_heatmaps() + { + static $heatmaps_cached; + + global $wpdb; + + if (!isset($heatmaps_cached)) { + $site = new Site(); + $idsite = $site->get_current_matomo_site_id(); + + if (!$idsite) { + $heatmaps_cached = array(); // prevent it not being executed again + } else { + $wpDbSettings = new \WpMatomo\Db\Settings(); + $tableName = $wpDbSettings->prefix_table_name('site_hsr'); + $idsite = (int) $idsite;// needed cause we don't bind parameters below + + $heatmaps_cached = $wpdb->get_results( + "select * from $tableName WHERE record_type = 1 AND idsite = $idsite AND status != 'deleted'", + ARRAY_A + ); + } + } + return $heatmaps_cached; + } + + /** + * @param array $actions + * @param \WP_Post $post + * + * @return mixed + */ + function add_view_heat_map_link($actions, $post) + { + if ( + !$post + || !is_plugin_active('matomo/matomo.php') + || !current_user_can('write_matomo') + ) { + return $actions; + } + + $heatmaps = get_matomo_heatmaps(); + + if (empty($heatmaps)) { + return $actions; + } + + $postUrl = get_permalink($post); + + if (!$postUrl) { + return $actions; + } + + if (class_exists(Bootstrap::class)) { + Bootstrap::do_bootstrap(); + } + + require_once('Tracker/PageRuleMatcher.php'); + require_once('Tracker/HsrMatcher.php'); + + $heatmaps = array_values(array_filter($heatmaps, function ($heatmap) use ($postUrl) { + $systemSettings = StaticContainer::get(SystemSettings::class); + $includedCountries = $systemSettings->getIncludedCountries(); + return HsrMatcher::matchesAllPageRules(json_decode($heatmap['match_page_rules'], true), $postUrl) && HsrMatcher::isIncludedCountry($includedCountries); + })); + + $numMatches = count($heatmaps); + foreach ($heatmaps as $i => $heatmap) { + $url = Menu::get_matomo_reporting_url( + 'HeatmapSessionRecording_Heatmaps', + $heatmap['idsitehsr'], + array() + ); + $linkText = 'View Heatmap'; + if ($numMatches > 1) { + $linkText .= ' #' . ($i + 1); + } + $actions['view_heatmap_' . $i] = + '' . esc_html($linkText) . ''; + } + + return $actions; + } +} + +class HeatmapSessionRecording extends \Piwik\Plugin +{ + public const EMBED_SESSION_TIME = 43200; // half day in seconds + public const SESSION_TOKEN_EMBED_KEY = 'hsr.tokenEmbed'; + public const ULR_PARAM_FORCE_SAMPLE = 'pk_hsr_forcesample'; + public const ULR_PARAM_FORCE_CAPTURE_SCREEN = 'pk_hsr_capturescreen'; + public const EMBED_SESSION_NAME = 'HSR_EMBED_SESSID'; + + public const TRACKER_READY_HOOK_NAME = '/*!! hsrTrackerReadyHook */'; + public const TRACKER_READY_HOOK_NAME_WHEN_MINIFIED = '/*!!! hsrTrackerReadyHook */'; + + public function registerEvents() + { + return array( + 'Db.getActionReferenceColumnsByTable' => 'addActionReferenceColumnsByTable', + 'Tracker.Cache.getSiteAttributes' => 'addSiteTrackerCache', + 'AssetManager.getStylesheetFiles' => 'getStylesheetFiles', + 'AssetManager.getJavaScriptFiles' => 'getJsFiles', + 'Translate.getClientSideTranslationKeys' => 'getClientSideTranslationKeys', + 'Template.jsGlobalVariables' => 'addJsGlobalVariables', + 'Category.addSubcategories' => 'addSubcategories', + 'SitesManager.deleteSite.end' => 'onDeleteSite', + 'UsersManager.deleteUser' => 'onUserDeleted', + 'Tracker.PageUrl.getQueryParametersToExclude' => 'getQueryParametersToExclude', + 'Widget.addWidgetConfigs' => 'addWidgetConfigs', + 'System.addSystemSummaryItems' => 'addSystemSummaryItems', + 'API.HeatmapSessionRecording.addHeatmap.end' => 'updatePiwikTracker', + 'API.HeatmapSessionRecording.addSessionRecording.end' => 'updatePiwikTracker', + 'CustomJsTracker.shouldAddTrackerFile' => 'shouldAddTrackerFile', + 'Updater.componentUpdated' => 'installHtAccess', + 'Live.visitorLogViewBeforeActionsInfo' => 'visitorLogViewBeforeActionsInfo', + 'Widgetize.shouldEmbedIframeEmpty' => 'shouldEmbedIframeEmpty', + 'Session.beforeSessionStart' => 'changeSessionLengthIfEmbedPage', + 'TwoFactorAuth.requiresTwoFactorAuthentication' => 'requiresTwoFactorAuthentication', + 'API.getPagesComparisonsDisabledFor' => 'getPagesComparisonsDisabledFor', + 'CustomJsTracker.manipulateJsTracker' => 'disableHeatmapsDefaultIfNeeded', + 'AssetManager.addStylesheets' => [ + 'function' => 'addStylesheets', + 'after' => true, + ], + 'Db.getTablesInstalled' => 'getTablesInstalled', + 'VisitorGenerator.VisitsFake.trackVisit' => 'generateHeatmapData', + ); + } + + /** + * Adds fabricated heatmap data (clicks/moves/scrolls) to visits created by the VisitorGenerator plugin. + * + * For active heatmaps that already have a snapshot, selectors are derived from the stored + * page_treemirror so the generated events align with the elements the heatmap renders. The request + * is queued on the tracker's bulk actions (not sent directly), per the VisitorGenerator contract. + * + * @param \MatomoTracker $tracker + * @param mixed $faker \Faker\Generator; Faker ships in VisitorGenerator's vendor dir, so the + * class is not resolvable from this plugin and the param stays untyped + */ + public function generateHeatmapData($tracker, $faker) + { + // only a portion of generated visits carry heatmap data, mimicking real-world sample rates + if (!$faker->boolean(60)) { + return; + } + + $idSite = (int) $tracker->idSite; + $generators = $this->getHeatmapGenerators($idSite); + if (empty($generators)) { + return; + } + + $idSiteHsr = $faker->randomElement(array_keys($generators)); + + // use the visit's tracked resolution as the viewport: `res` determines the heatmap + // device group (LogHsr::getDeviceType()), so a mobile visit must not get a desktop viewport + $screenWidth = !empty($tracker->width) ? (int) $tracker->width : null; + $screenHeight = !empty($tracker->height) ? (int) $tracker->height : null; + $visit = $generators[$idSiteHsr]->generateVisit($faker, $screenWidth, $screenHeight); + if ($visit === null) { + return; + } + + $tracker->setCustomTrackingParameter('ca', '1'); + $tracker->setCustomTrackingParameter('pv_id', Common::getRandomString(6)); + $tracker->setCustomTrackingParameter(RequestProcessor::TRACKING_PARAM_HSR_ID_VIEW, Common::getRandomString(6)); + // hsr_ids and hsr_ev are arrays by design: http_build_query() serializes them into the + // request and the tracker endpoint requires arrays (RequestProcessor::afterRequestProcessed); + // only the docblock of setCustomTrackingParameter() is narrower than what it supports + // @phpstan-ignore argument.type + $tracker->setCustomTrackingParameter(RequestProcessor::TRACKING_PARAM_HSR_IDS, [$idSiteHsr]); + // @phpstan-ignore argument.type + $tracker->setCustomTrackingParameter(RequestProcessor::TRACKING_PARAM_EVENTS, $visit['events']); + $tracker->setCustomTrackingParameter(RequestProcessor::TRACKING_PARAM_TOTAL_TIME, (string) $visit['total_time']); + $tracker->setCustomTrackingParameter(RequestProcessor::TRACKING_PARAM_VIEWPORT_WIDTH, (string) $visit['viewport_width']); + $tracker->setCustomTrackingParameter(RequestProcessor::TRACKING_PARAM_VIEWPORT_HEIGHT, (string) $visit['viewport_height']); + $tracker->setCustomTrackingParameter(RequestProcessor::TRACKING_PARAM_FOLD_Y_PERCENT, (string) $visit['fold']); + $tracker->setCustomTrackingParameter(RequestProcessor::TRACKING_PARAM_SCROLL_MAX_PERCENTAGE, (string) $visit['scroll_max']); + + // queue with the visit's other requests; presence of hsr_vid makes the server skip the pageview/action + $tracker->storedTrackingActions[] = $tracker->getUrlTrackPageView('Heatmap data (generated)'); + $tracker->clearCustomTrackingParameters(); + } + + /** + * @param int $idSite + * @return HeatmapDataGenerator[] indexed by idsitehsr (active heatmaps that have a snapshot) + */ + private function getHeatmapGenerators($idSite) + { + $cache = Cache::getTransientCache(); + $cacheKey = 'HeatmapSessionRecording.heatmapDataGenerators.' . (int) $idSite; + if ($cache->contains($cacheKey)) { + return $cache->fetch($cacheKey); + } + + $generators = []; + $model = StaticContainer::get(SiteHsrModel::class); + foreach ($model->getActiveHeatmapsWithPageTreemirror($idSite) as $heatmap) { + if (empty($heatmap['page_treemirror'])) { + continue; + } + $generator = new HeatmapDataGenerator($heatmap['page_treemirror']); + if ($generator->hasTargets()) { + $generators[(int) $heatmap['idsitehsr']] = $generator; + } + } + + $cache->save($cacheKey, $generators); + return $generators; + } + + public function disableHeatmapsDefaultIfNeeded(&$content) + { + $settings = StaticContainer::get(SystemSettings::class); + if ($settings->disableTrackingByDefault->getValue()) { + $replace = 'Matomo.HeatmapSessionRecording._setDisabled();'; + } else { + $replace = ''; + } + + $content = str_replace(array(self::TRACKER_READY_HOOK_NAME_WHEN_MINIFIED, self::TRACKER_READY_HOOK_NAME), $replace, $content); + } + + /** + * Register the new tables, so Matomo knows about them. + * + * @param array $allTablesInstalled + */ + public function getTablesInstalled(&$allTablesInstalled) + { + $allTablesInstalled[] = Common::prefixTable('log_hsr'); + $allTablesInstalled[] = Common::prefixTable('log_hsr_blob'); + $allTablesInstalled[] = Common::prefixTable('log_hsr_event'); + $allTablesInstalled[] = Common::prefixTable('log_hsr_site'); + $allTablesInstalled[] = Common::prefixTable('site_hsr'); + } + + public static function getPathPrefix() + { + $webRootDirs = Manager::getInstance()->getWebRootDirectoriesForCustomPluginDirs(); + if (!empty($webRootDirs['HeatmapSessionRecording'])) { + $baseUrl = trim($webRootDirs['HeatmapSessionRecording'], '/'); + } else { + $baseUrl = 'plugins'; + } + return $baseUrl; + } + + public static function isMatomoForWordPress() + { + return defined('ABSPATH') && function_exists('add_action'); + } + + public function addStylesheets(&$mergedContent) + { + if (self::isMatomoForWordPress()) { + // we hide this icon since it uses the widgetize feature which is disabled in WordPress + $mergedContent .= '.manageHsr .action .icon-show { display: none; }'; + } + } + public function getPagesComparisonsDisabledFor(&$pages) + { + $pages[] = 'HeatmapSessionRecording_Heatmaps.*'; + $pages[] = 'HeatmapSessionRecording_SessionRecordings.*'; + } + + public function addJsGlobalVariables() + { + $idSite = Common::getRequestVar('idSite', 0, 'int'); + + if ($idSite > 0 && Piwik::isUserHasWriteAccess($idSite)) { + echo 'piwik.heatmapWriteAccess = true;'; + } else { + echo 'piwik.heatmapWriteAccess = false;'; + } + + if ($idSite > 0 && Piwik::isUserHasAdminAccess($idSite)) { + echo 'piwik.heatmapAdminAccess = true;'; + } else { + echo 'piwik.heatmapAdminAccess = false;'; + } + } + + public function requiresTwoFactorAuthentication(&$requiresAuth, $module, $action, $parameters) + { + if ( + $module == 'HeatmapSessionRecording' + && $action === 'embedPage' + && !empty($_SESSION[self::SESSION_TOKEN_EMBED_KEY]) + ) { + $requiresAuth = false; + } + } + + public function shouldEmbedIframeEmpty(&$shouldEmbedEmpty, $controllerName, $actionName) + { + if ($controllerName == 'HeatmapSessionRecording' && ($actionName == 'replayRecording' || $actionName == 'embedPage')) { + $shouldEmbedEmpty = true; + } + } + + /** + * Fallback to add play session link for Matomo < 3.1.0 + * + * NOTE: TO BE REMOVED EG FROM FEBRUARY OR MARCH 2018 + * + * @param string $out + * @param Row $visitor + */ + public function visitorLogViewBeforeActionsInfo(&$out, $visitor) + { + if (class_exists('\\Piwik\\Plugins\\Live\\VisitorDetailsAbstract')) { + return; + } + + $idVisit = $visitor->getColumn('idVisit'); + $idSite = (int) $visitor->getColumn('idSite'); + + if (empty($idSite) || empty($idVisit) || !$this->getValidator()->canViewSessionReport($idSite)) { + return; + } + + $aggregator = new Aggregator(); + $recording = $aggregator->findRecording($idVisit); + if (!empty($recording['idsitehsr'])) { + $title = Piwik::translate('HeatmapSessionRecording_ReplayRecordedSession'); + $out .= ' ' . $title . '
'; + } + } + + public function shouldAddTrackerFile(&$shouldAdd, $pluginName) + { + if ($pluginName === 'HeatmapSessionRecording') { + if (defined('PIWIK_TEST_MODE') && PIWIK_TEST_MODE) { + $this->install(); + } + $config = new Configuration(); + + $siteHsrDao = $this->getSiteHsrDao(); + if ($config->shouldOptimizeTrackingCode() && !$siteHsrDao->hasActiveRecordsAcrossSites()) { + // saves requests to configs.php while no heatmap or session recording configured. + $shouldAdd = false; + } + } + } + + public function updatePiwikTracker() + { + if (Plugin\Manager::getInstance()->isPluginActivated('CustomJsTracker')) { + $trackerUpdater = StaticContainer::get('Piwik\Plugins\CustomJsTracker\TrackerUpdater'); + if (!empty($trackerUpdater)) { + $trackerUpdater->update(); + } + } + } + + public function addSystemSummaryItems(&$systemSummary) + { + $dao = $this->getSiteHsrDao(); + $numHeatmaps = $dao->getNumRecordsTotal(SiteHsrDao::RECORD_TYPE_HEATMAP); + $numSessions = $dao->getNumRecordsTotal(SiteHsrDao::RECORD_TYPE_SESSION); + + $systemSummary[] = new SystemSummary\Item( + $key = 'heatmaps', + Piwik::translate('HeatmapSessionRecording_NHeatmaps', $numHeatmaps), + $value = null, + array('module' => 'HeatmapSessionRecording', 'action' => 'manageHeatmap'), + $icon = 'icon-drop', + $order = 6 + ); + $systemSummary[] = new SystemSummary\Item( + $key = 'sessionrecordings', + Piwik::translate('HeatmapSessionRecording_NSessionRecordings', $numSessions), + $value = null, + array('module' => 'HeatmapSessionRecording', 'action' => 'manageSessions'), + $icon = 'icon-play', + $order = 7 + ); + } + + public function getQueryParametersToExclude(&$parametersToExclude) + { + // these are used by the tracker + $parametersToExclude[] = self::ULR_PARAM_FORCE_CAPTURE_SCREEN; + $parametersToExclude[] = self::ULR_PARAM_FORCE_SAMPLE; + } + + public function onDeleteSite($idSite) + { + $model = $this->getSiteHsrModel(); + $model->deactivateRecordsForSite($idSite); + } + + public function onUserDeleted($userLogin) + { + $this->getSiteHsrDao()->anonymizeLogin($userLogin); + } + + private function getSiteHsrModel() + { + return StaticContainer::get('Piwik\Plugins\HeatmapSessionRecording\Model\SiteHsrModel'); + } + + private function getValidator() + { + return StaticContainer::get('Piwik\Plugins\HeatmapSessionRecording\Input\Validator'); + } + + public function addWidgetConfigs(&$configs) + { + $idSite = Common::getRequestVar('idSite', 0, 'int'); + + if (!$this->getValidator()->canViewHeatmapReport($idSite)) { + return; + } + + $heatmaps = $this->getHeatmaps($idSite); + + foreach ($heatmaps as $heatmap) { + $widget = new WidgetConfig(); + $widget->setCategoryId('HeatmapSessionRecording_Heatmaps'); + $widget->setSubcategoryId($heatmap['idsitehsr']); + $widget->setModule('HeatmapSessionRecording'); + $widget->setAction('showHeatmap'); + $widget->setParameters(array('idSiteHsr' => $heatmap['idsitehsr'])); + $widget->setIsNotWidgetizable(); + $configs[] = $widget; + } + } + + public function addSubcategories(&$subcategories) + { + $idSite = Common::getRequestVar('idSite', 0, 'int'); + + if (empty($idSite)) { + // fallback for eg API.getReportMetadata which uses idSites + $idSite = Common::getRequestVar('idSites', 0, 'int'); + + if (empty($idSite)) { + return; + } + } + + if ($this->getValidator()->canViewHeatmapReport($idSite)) { + $heatmaps = $this->getHeatmaps($idSite); + + // we list recently created heatmaps first + $order = 20; + foreach ($heatmaps as $heatmap) { + $subcategory = new Subcategory(); + $subcategory->setName($heatmap['name']); + $subcategory->setCategoryId('HeatmapSessionRecording_Heatmaps'); + $subcategory->setId($heatmap['idsitehsr']); + $subcategory->setOrder($order++); + $subcategories[] = $subcategory; + } + } + + if ($this->getValidator()->canViewSessionReport($idSite)) { + $recordings = $this->getSessionRecordings($idSite); + + // we list recently created recordings first + $order = 20; + foreach ($recordings as $recording) { + $subcategory = new Subcategory(); + $subcategory->setName($recording['name']); + $subcategory->setCategoryId('HeatmapSessionRecording_SessionRecordings'); + $subcategory->setId($recording['idsitehsr']); + $subcategory->setOrder($order++); + $subcategories[] = $subcategory; + } + } + } + + public function getClientSideTranslationKeys(&$result) + { + $result[] = 'General_Save'; + $result[] = 'General_Done'; + $result[] = 'General_Actions'; + $result[] = 'General_Yes'; + $result[] = 'General_No'; + $result[] = 'General_Add'; + $result[] = 'General_Remove'; + $result[] = 'General_Id'; + $result[] = 'General_Ok'; + $result[] = 'General_Cancel'; + $result[] = 'General_Name'; + $result[] = 'General_Loading'; + $result[] = 'General_LoadingData'; + $result[] = 'General_Mobile'; + $result[] = 'General_All'; + $result[] = 'General_Search'; + $result[] = 'CorePluginsAdmin_Status'; + $result[] = 'DevicesDetection_Tablet'; + $result[] = 'CoreUpdater_UpdateTitle'; + $result[] = 'DevicesDetection_Device'; + $result[] = 'Installation_Legend'; + $result[] = 'HeatmapSessionRecording_HeatmapSettings'; + $result[] = 'HeatmapSessionRecording_LoadingHeatmap'; + $result[] = 'HeatmapSessionRecording_EditHeatmap'; + $result[] = 'HeatmapSessionRecording_DeleteRetakeSnapshot'; + $result[] = 'HeatmapSessionRecording_DeleteRetakeSnapshotTitle'; + $result[] = 'HeatmapSessionRecording_DeleteRetakeSnapshotDescription'; + $result[] = 'HeatmapSessionRecording_DeleteRetakeAutomatically'; + $result[] = 'HeatmapSessionRecording_DeleteRetakeManually'; + $result[] = 'HeatmapSessionRecording_DeleteSnapshotTitle'; + $result[] = 'HeatmapSessionRecording_DeleteSnapshotDescription'; + $result[] = 'HeatmapSessionRecording_DeleteSnapshotConfirm'; + $result[] = 'HeatmapSessionRecording_EmptyStateNoInteractionsTitle'; + $result[] = 'HeatmapSessionRecording_EmptyStateNoInteractionsSubtitle'; + $result[] = 'HeatmapSessionRecording_EmptyStateSnapshotDeletedTitle'; + $result[] = 'HeatmapSessionRecording_EmptyStateSnapshotDeletedAutoSubtitle'; + $result[] = 'HeatmapSessionRecording_EmptyStateSnapshotDeletedManualSubtitle'; + $result[] = 'HeatmapSessionRecording_EmptyStateSnapshotDeletedPausedSubtitle'; + $result[] = 'HeatmapSessionRecording_EmptyStateSnapshotDeletedEndedSubtitle'; + $result[] = 'HeatmapSessionRecording_ReadTheGuide'; + $result[] = 'HeatmapSessionRecording_enable'; + $result[] = 'HeatmapSessionRecording_disable'; + $result[] = 'HeatmapSessionRecording_ChangeReplaySpeed'; + $result[] = 'HeatmapSessionRecording_ClickToSkipPauses'; + $result[] = 'HeatmapSessionRecording_AutoPlayNextPageview'; + $result[] = 'HeatmapSessionRecording_XSamples'; + $result[] = 'HeatmapSessionRecording_StatusActive'; + $result[] = 'HeatmapSessionRecording_StatusEnded'; + $result[] = 'HeatmapSessionRecording_StatusPaused'; + $result[] = 'HeatmapSessionRecording_StatusOnHold'; + $result[] = 'HeatmapSessionRecording_StatusOnHoldHelp'; + $result[] = 'HeatmapSessionRecording_StatusOnHoldHelpSessionRecording'; + $result[] = 'HeatmapSessionRecording_RecordedHeatmapDocStatusOnHold'; + $result[] = 'HeatmapSessionRecording_RecordedHeatmapDocStatusOnHoldWithDate'; + $result[] = 'HeatmapSessionRecording_RequiresActivity'; + $result[] = 'HeatmapSessionRecording_RequiresActivityHelp'; + $result[] = 'HeatmapSessionRecording_CaptureKeystrokes'; + $result[] = 'HeatmapSessionRecording_CaptureKeystrokesHelp'; + $result[] = 'HeatmapSessionRecording_SessionRecording'; + $result[] = 'HeatmapSessionRecording_Heatmap'; + $result[] = 'HeatmapSessionRecording_ReportNoLongerExists'; + $result[] = 'HeatmapSessionRecording_ActivityClick'; + $result[] = 'HeatmapSessionRecording_ActivityMove'; + $result[] = 'HeatmapSessionRecording_ActivityScroll'; + $result[] = 'HeatmapSessionRecording_ActivityResize'; + $result[] = 'HeatmapSessionRecording_ActivityFormChange'; + $result[] = 'HeatmapSessionRecording_ActivityPageChange'; + $result[] = 'HeatmapSessionRecording_HeatmapWidth'; + $result[] = 'HeatmapSessionRecording_Width'; + $result[] = 'HeatmapSessionRecording_CustomWidth'; + $result[] = 'HeatmapSessionRecording_LegendLow'; + $result[] = 'HeatmapSessionRecording_LegendHigh'; + $result[] = 'HeatmapSessionRecording_Action'; + $result[] = 'HeatmapSessionRecording_ExportImage'; + $result[] = 'HeatmapSessionRecording_ExportImageError'; + $result[] = 'HeatmapSessionRecording_ExportImageInProgress'; + $result[] = 'HeatmapSessionRecording_DeviceType'; + $result[] = 'HeatmapSessionRecording_PlayerDurationXofY'; + $result[] = 'HeatmapSessionRecording_PlayerPlay'; + $result[] = 'HeatmapSessionRecording_PlayerPause'; + $result[] = 'HeatmapSessionRecording_PlayerRewindFast'; + $result[] = 'HeatmapSessionRecording_PlayerForwardFast'; + $result[] = 'HeatmapSessionRecording_PlayerReplay'; + $result[] = 'HeatmapSessionRecording_PlayerPageViewPrevious'; + $result[] = 'HeatmapSessionRecording_PlayerPageViewNext'; + $result[] = 'HeatmapSessionRecording_SessionRecordingsUsageBenefits'; + $result[] = 'HeatmapSessionRecording_ManageSessionRecordings'; + $result[] = 'HeatmapSessionRecording_ManageHeatmaps'; + $result[] = 'HeatmapSessionRecording_NoSessionRecordingsFound'; + $result[] = 'HeatmapSessionRecording_FieldIncludedTargetsHelpSessions'; + $result[] = 'HeatmapSessionRecording_NoHeatmapsFound'; + $result[] = 'HeatmapSessionRecording_AvgFold'; + $result[] = 'HeatmapSessionRecording_AvgAboveFoldDescription'; + $result[] = 'HeatmapSessionRecording_TargetPage'; + $result[] = 'HeatmapSessionRecording_TargetPages'; + $result[] = 'HeatmapSessionRecording_ViewReport'; + $result[] = 'HeatmapSessionRecording_SampleLimit'; + $result[] = 'HeatmapSessionRecording_SessionNameHelpText'; + $result[] = 'HeatmapSessionRecording_HeatmapDescriptionHelpText'; + $result[] = 'HeatmapSessionRecording_DescriptionOptional'; + $result[] = 'HeatmapSessionRecording_HeatmapSampleLimit'; + $result[] = 'HeatmapSessionRecording_SessionSampleLimit'; + $result[] = 'HeatmapSessionRecording_HeatmapSampleLimitHelp'; + $result[] = 'HeatmapSessionRecording_SessionSampleLimitHelp'; + $result[] = 'HeatmapSessionRecording_MinSessionTime'; + $result[] = 'HeatmapSessionRecording_MinSessionTimeHelp'; + $result[] = 'HeatmapSessionRecording_EditX'; + $result[] = 'HeatmapSessionRecording_StopX'; + $result[] = 'HeatmapSessionRecording_HeatmapUsageBenefits'; + $result[] = 'HeatmapSessionRecording_AdvancedOptions'; + $result[] = 'HeatmapSessionRecording_ShowAdvancedOptions'; + $result[] = 'HeatmapSessionRecording_HideAdvancedOptions'; + $result[] = 'HeatmapSessionRecording_HeatmapAdvancedOptionsSummary'; + $result[] = 'HeatmapSessionRecording_AutomaticSnapshot'; + $result[] = 'HeatmapSessionRecording_ManualSnapshot'; + $result[] = 'HeatmapSessionRecording_SampleRate'; + $result[] = 'HeatmapSessionRecording_HeatmapSampleRateHelp'; + $result[] = 'HeatmapSessionRecording_SessionSampleRateHelp'; + $result[] = 'HeatmapSessionRecording_HeatmapAdvancedSectionRecordingSettings'; + $result[] = 'HeatmapSessionRecording_HeatmapAdvancedSectionRecordingSettingsHelp'; + $result[] = 'HeatmapSessionRecording_HeatmapAdvancedSectionPageSnapshot'; + $result[] = 'HeatmapSessionRecording_HeatmapAdvancedSectionPageSnapshotHelp'; + $result[] = 'HeatmapSessionRecording_HeatmapAdvancedSectionContentMasking'; + $result[] = 'HeatmapSessionRecording_HeatmapAdvancedSectionContentMaskingHelp'; + $result[] = 'HeatmapSessionRecording_HeatmapAdvancedSectionDeviceBreakpoints'; + $result[] = 'HeatmapSessionRecording_HeatmapAdvancedSectionDeviceBreakpointsHelp'; + $result[] = 'HeatmapSessionRecording_ExcludedElements'; + $result[] = 'HeatmapSessionRecording_ExcludedElementsHelp'; + $result[] = 'HeatmapSessionRecording_ScreenshotUrl'; + $result[] = 'HeatmapSessionRecording_ScreenshotUrlHelp'; + $result[] = 'HeatmapSessionRecording_BreakpointX'; + $result[] = 'HeatmapSessionRecording_BreakpointGeneralHelp'; + $result[] = 'HeatmapSessionRecording_BreakpointMobileManageHelp'; + $result[] = 'HeatmapSessionRecording_BreakpointTabletManageHelp'; + $result[] = 'HeatmapSessionRecording_Rule'; + $result[] = 'HeatmapSessionRecording_UrlParameterValueToMatchPlaceholder'; + $result[] = 'HeatmapSessionRecording_EditHeatmapX'; + $result[] = 'HeatmapSessionRecording_TargetTypeIsAny'; + $result[] = 'HeatmapSessionRecording_TargetTypeIsNot'; + $result[] = 'HeatmapSessionRecording_PersonalInformationNote'; + $result[] = 'HeatmapSessionRecording_UpdatingData'; + $result[] = 'HeatmapSessionRecording_FieldIncludedTargetsHelp'; + $result[] = 'HeatmapSessionRecording_FieldIncludedTargetsHelpSimple'; + $result[] = 'HeatmapSessionRecording_FieldIncludedTargetsHelpSimpleSessions'; + $result[] = 'HeatmapSessionRecording_FieldIncludedTargetsHelpAdvanced'; + $result[] = 'HeatmapSessionRecording_FieldIncludedTargetsHelpAdvancedSessions'; + $result[] = 'HeatmapSessionRecording_UseAdvancedTargeting'; + $result[] = 'HeatmapSessionRecording_UseSimpleTargeting'; + $result[] = 'HeatmapSessionRecording_DeleteX'; + $result[] = 'HeatmapSessionRecording_DeleteHeatmapConfirm'; + $result[] = 'HeatmapSessionRecording_BreakpointGeneralHelpManage'; + $result[] = 'HeatmapSessionRecording_TargetPageTestTitle'; + $result[] = 'HeatmapSessionRecording_TargetPageTestErrorInvalidUrl'; + $result[] = 'HeatmapSessionRecording_TargetPageTestUrlMatches'; + $result[] = 'HeatmapSessionRecording_TargetPageTestUrlNotMatches'; + $result[] = 'HeatmapSessionRecording_TargetPageTestLabel'; + $result[] = 'HeatmapSessionRecording_ErrorXNotProvided'; + $result[] = 'HeatmapSessionRecording_ErrorPageRuleRequired'; + $result[] = 'HeatmapSessionRecording_CreationDate'; + $result[] = 'HeatmapSessionRecording_HeatmapCreated'; + $result[] = 'HeatmapSessionRecording_HeatmapUpdated'; + $result[] = 'HeatmapSessionRecording_FieldNamePlaceholder'; + $result[] = 'HeatmapSessionRecording_FieldNamePlaceholderV2'; + $result[] = 'HeatmapSessionRecording_FieldDescriptionPlaceholder'; + $result[] = 'HeatmapSessionRecording_HeatmapNameHelpText'; + $result[] = 'HeatmapSessionRecording_CreateNewHeatmap'; + $result[] = 'HeatmapSessionRecording_CreateNewSessionRecording'; + $result[] = 'HeatmapSessionRecording_EditSessionRecordingX'; + $result[] = 'HeatmapSessionRecording_DeleteSessionRecordingConfirm'; + $result[] = 'HeatmapSessionRecording_EndHeatmapConfirm'; + $result[] = 'HeatmapSessionRecording_EndHeatmapRepeatNotice'; + $result[] = 'HeatmapSessionRecording_EndSessionRecordingConfirm'; + $result[] = 'HeatmapSessionRecording_EndSessionRecordingRepeatNotice'; + $result[] = 'HeatmapSessionRecording_SessionRecordingCreated'; + $result[] = 'HeatmapSessionRecording_SessionRecordingUpdated'; + $result[] = 'HeatmapSessionRecording_Filter'; + $result[] = 'HeatmapSessionRecording_PlayRecordedSession'; + $result[] = 'HeatmapSessionRecording_DeleteRecordedSession'; + $result[] = 'HeatmapSessionRecording_DeleteRecordedPageview'; + $result[] = 'Live_ViewVisitorProfile'; + $result[] = 'HeatmapSessionRecording_HeatmapXRecordedSamplesSince'; + $result[] = 'HeatmapSessionRecording_PageviewsInVisit'; + $result[] = 'HeatmapSessionRecording_ColumnTime'; + $result[] = 'General_TimeOnPage'; + $result[] = 'Goals_URL'; + $result[] = 'General_Close'; + $result[] = 'HeatmapSessionRecording_HeatmapX'; + $result[] = 'HeatmapSessionRecording_NoHeatmapSamplesRecordedYet'; + $result[] = 'HeatmapSessionRecording_NoHeatmapScreenshotRecordedYet'; + $result[] = 'HeatmapSessionRecording_NoHeatmapSamplesRecordedYetWithoutSystemConfiguration'; + $result[] = 'HeatmapSessionRecording_NoHeatmapScreenshotRecordedYetWithoutSystemConfiguration'; + $result[] = 'HeatmapSessionRecording_HeatmapInfoTrackVisitsFromCountries'; + $result[] = 'HeatmapSessionRecording_SessionRecordingInfoTrackVisitsFromCountries'; + $result[] = 'HeatmapSessionRecording_AdBlockerDetected'; + $result[] = 'HeatmapSessionRecording_CaptureDomTitle'; + $result[] = 'HeatmapSessionRecording_CaptureDomInlineHelp'; + $result[] = 'HeatmapSessionRecording_AutoRepeatHeatmap'; + $result[] = 'HeatmapSessionRecording_AutoRepeatHeatmapHelp'; + $result[] = 'HeatmapSessionRecording_AutoRepeatSessionRecording'; + $result[] = 'HeatmapSessionRecording_AutoRepeatSessionRecordingHelp'; + $result[] = 'HeatmapSessionRecording_AutoRepeatHeatmapColumn'; + $result[] = 'HeatmapSessionRecording_AutoRepeatHeatmapColumnHelp'; + $result[] = 'HeatmapSessionRecording_AutoRepeatSessionRecordingColumn'; + $result[] = 'HeatmapSessionRecording_AutoRepeatSessionRecordingColumnHelp'; + $result[] = 'HeatmapSessionRecording_MatomoJSNotWritableErrorMessage'; + $result[] = 'HeatmapSessionRecording_SessionRecordings'; + $result[] = 'HeatmapSessionRecording_Heatmaps'; + $result[] = 'HeatmapSessionRecording_Clicks'; + $result[] = 'HeatmapSessionRecording_ClickRate'; + $result[] = 'HeatmapSessionRecording_Moves'; + $result[] = 'HeatmapSessionRecording_MoveRate'; + $result[] = 'HeatmapSessionRecording_HeatmapTroubleshoot'; + $result[] = 'General_Required'; + $result[] = 'HeatmapSessionRecording_ErrorHeatmapNameDuplicate'; + $result[] = 'HeatmapSessionRecording_QuotaReachedForX'; + $result[] = 'HeatmapSessionRecording_HeatmapDuplicationError'; + $result[] = 'HeatmapSessionRecording_None'; + $result[] = 'HeatmapSessionRecording_Recordings'; + $result[] = 'HeatmapSessionRecording_WaitingForFirstRecording'; + $result[] = 'HeatmapSessionRecording_TopClickedElements'; + $result[] = 'HeatmapSessionRecording_NoClickedElements'; + $result[] = 'HeatmapSessionRecording_ScrollReach'; + $result[] = 'HeatmapSessionRecording_NoScrollData'; + $result[] = 'HeatmapSessionRecording_ScrollReachTop'; + $result[] = 'HeatmapSessionRecording_ScrollReachBottom'; + $result[] = 'HeatmapSessionRecording_TopClickedElementLabel'; + $result[] = 'HeatmapSessionRecording_ElementTypeArticle'; + $result[] = 'HeatmapSessionRecording_ElementTypeButton'; + $result[] = 'HeatmapSessionRecording_ElementTypeField'; + $result[] = 'HeatmapSessionRecording_ElementTypeFooter'; + $result[] = 'HeatmapSessionRecording_ElementTypeForm'; + $result[] = 'HeatmapSessionRecording_ElementTypeHeader'; + $result[] = 'HeatmapSessionRecording_ElementTypeHeading'; + $result[] = 'HeatmapSessionRecording_ElementTypeImage'; + $result[] = 'HeatmapSessionRecording_ElementTypeLink'; + $result[] = 'HeatmapSessionRecording_ElementTypeList'; + $result[] = 'HeatmapSessionRecording_ElementTypeMainContent'; + $result[] = 'HeatmapSessionRecording_ElementTypeNavigation'; + $result[] = 'HeatmapSessionRecording_ElementTypeSection'; + $result[] = 'HeatmapSessionRecording_ElementTypeSidebar'; + $result[] = 'General_Table'; + } + + public function getJsFiles(&$jsFiles) + { + $jsFiles[] = "plugins/HeatmapSessionRecording/javascripts/rowaction.js"; + } + + public function getStylesheetFiles(&$stylesheets) + { + $stylesheets[] = "plugins/HeatmapSessionRecording/stylesheets/list-entities.less"; + $stylesheets[] = "plugins/HeatmapSessionRecording/stylesheets/edit-entities.less"; + $stylesheets[] = "plugins/HeatmapSessionRecording/vue/src/HsrTargetTest/HsrTargetTest.less"; + $stylesheets[] = "plugins/HeatmapSessionRecording/vue/src/HsrUrlTarget/HsrUrlTarget.less"; + $stylesheets[] = "plugins/HeatmapSessionRecording/stylesheets/recordings.less"; + $stylesheets[] = "plugins/HeatmapSessionRecording/vue/src/SessionRecordingVis/SessionRecordingVis.less"; + $stylesheets[] = "plugins/HeatmapSessionRecording/vue/src/HeatmapVis/HeatmapVis.less"; + $stylesheets[] = "plugins/HeatmapSessionRecording/vue/src/Tooltip/Tooltip.less"; + } + + public function activate() + { + $this->installHtAccess(); + } + + public function install() + { + $siteHsr = new SiteHsrDao(); + $siteHsr->install(); + + $hsrSite = new LogHsrSite(); + $hsrSite->install(); + + $hsr = new LogHsr($hsrSite); + $hsr->install(); + + $blobHsr = new LogHsrBlob(); + $blobHsr->install(); + + $event = new LogHsrEvent($blobHsr); + $event->install(); + + $this->installHtAccess(); + + $configuration = new Configuration(); + $configuration->install(); + } + + public function installHtAccess() + { + $htaccess = new HtAccess(); + $htaccess->install(); + } + + public function uninstall() + { + $siteHsr = new SiteHsrDao(); + $siteHsr->uninstall(); + + $hsrSite = new LogHsrSite(); + $hsrSite->uninstall(); + + $hsr = new LogHsr($hsrSite); + $hsr->uninstall(); + + $blobHsr = new LogHsrBlob(); + $blobHsr->uninstall(); + + $event = new LogHsrEvent($blobHsr); + $event->uninstall(); + + $configuration = new Configuration(); + $configuration->uninstall(); + } + + public function isTrackerPlugin() + { + return true; + } + + private function getSiteHsrDao() + { + return StaticContainer::get('Piwik\Plugins\HeatmapSessionRecording\Dao\SiteHsrDao'); + } + + public function addSiteTrackerCache(&$content, $idSite) + { + $hsr = $this->getSiteHsrDao(); + $hsrs = $hsr->getActiveRecords($idSite); + + foreach ($hsrs as $index => $hsr) { + // we make sure to keep the cache file small as this is not needed in the cache + $hsrs[$index]['page_treemirror'] = !empty($hsr['page_treemirror']) ? '1' : null; + } + + $content['hsr'] = $hsrs; + } + + public function addActionReferenceColumnsByTable(&$result) + { + $result['log_hsr'] = array('idaction_url'); + $result['log_hsr_event'] = array('idselector'); + } + + public function changeSessionLengthIfEmbedPage() + { + if ( + SettingsServer::isTrackerApiRequest() + || Common::isPhpCliMode() + ) { + return; + } + + // if there's no token_auth=... in the URL and there's no existing HSR session, then + // we don't change the session options and try to use the normal matomo session. + if ( + Common::getRequestVar('token_auth', false) === false + && empty($_COOKIE[self::EMBED_SESSION_NAME]) + ) { + return; + } + + $module = Common::getRequestVar('module', '', 'string'); + $action = Common::getRequestVar('action', '', 'string'); + if ( + $module == 'HeatmapSessionRecording' + && $action == 'embedPage' + ) { + Config::getInstance()->General['login_cookie_expire'] = self::EMBED_SESSION_TIME; + + Session::$sessionName = self::EMBED_SESSION_NAME; + Session::rememberMe(Config::getInstance()->General['login_cookie_expire']); + } + } + + public static function getTranslationKey($type) + { + $key = ''; + switch ($type) { + case 'pause': + $key = 'HeatmapSessionRecording_PauseReason'; + break; + case 'noDataSession': + $key = 'HeatmapSessionRecording_NoSessionRecordedYetWithoutSystemConfiguration'; + break; + case 'noDataHeatmap': + $key = 'HeatmapSessionRecording_NoHeatmapSamplesRecordedYetWithoutSystemConfiguration'; + break; + } + + if (!$key) { + return null; + } + + Piwik::postEvent('HeatmapSessionRecording.updateTranslationKey', [&$key]); + return $key; + } + + public static function isMatomoJsWritable($checkSpecificFile = '') + { + if (Manager::getInstance()->isPluginActivated('Cloud')) { + return true; + } + + $updater = StaticContainer::get('Piwik\Plugins\CustomJsTracker\TrackerUpdater'); + $filePath = $updater->getToFile()->getPath(); + $filesToCheck = array($filePath); + $jsCodeGenerator = new TrackerCodeGenerator(); + if (SettingsPiwik::isMatomoInstalled() && $jsCodeGenerator->shouldPreferPiwikEndpoint()) { + // if matomo is not installed yet, we definitely prefer matomo.js... check for isMatomoInstalled is needed + // cause otherwise it would perform a db query before matomo DB is configured + $filesToCheck[] = str_replace('matomo.js', 'piwik.js', $filePath); + } + + if (!empty($checkSpecificFile)) { + $filesToCheck = [$checkSpecificFile]; // mostly used for testing isMatomoJsWritable functionality + } + + if (!Manager::getInstance()->isPluginActivated('CustomJsTracker')) { + return false; + } + + foreach ($filesToCheck as $fileToCheck) { + $file = new File($fileToCheck); + + if (!$file->hasWriteAccess()) { + return false; + } + } + + return true; + } + + private function getHeatmaps($idSite) + { + return Request::processRequest('HeatmapSessionRecording.getHeatmaps', [ + 'idSite' => $idSite, 'filter_limit' => -1, + 'includePageTreeMirror' => 0 // IMPORTANT for performance and IO. If you need page tree mirror please add another method and don't remove this parameter + ], $default = []); + } + + private function getSessionRecordings($idSite) + { + return Request::processRequest('HeatmapSessionRecording.getSessionRecordings', [ + 'idSite' => $idSite, 'filter_limit' => -1 + ], $default = []); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/ImageProxy.php b/files/plugin-HeatmapSessionRecording-5.6.0/ImageProxy.php new file mode 100644 index 0000000..b83b103 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/ImageProxy.php @@ -0,0 +1,194 @@ + self::MAX_URL_LENGTH) { + throw new \Exception('URL is too long.'); + } + + $parts = parse_url($raw); + if ($parts === false || empty($parts['host']) || empty($parts['scheme'])) { + throw new \Exception('URL is missing a host or scheme.'); + } + + $scheme = strtolower($parts['scheme']); + if (!in_array($scheme, self::ALLOWED_SCHEMES, true)) { + throw new \Exception('URL scheme is not allowed.'); + } + + if (isset($parts['user']) || isset($parts['pass'])) { + throw new \Exception('URL must not contain credentials.'); + } + + if (isset($parts['port'])) { + $defaultPort = $scheme === 'https' ? 443 : 80; + if ((int) $parts['port'] !== $defaultPort) { + throw new \Exception('URL must not use a non-default port.'); + } + } + + $host = $parts['host']; + + if (strlen($host) >= 2 && $host[0] === '[' && substr($host, -1) === ']') { + throw new \Exception('IP-literal hosts are not allowed.'); + } + + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + throw new \Exception('IP-literal hosts are not allowed.'); + } + + if (function_exists('idn_to_ascii')) { + $asciiHost = idn_to_ascii($host, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46); + if ($asciiHost === false) { + throw new \Exception('URL host is malformed.'); + } + $host = $asciiHost; + } + + $cleanUrl = $scheme . '://' . $host; + if (isset($parts['port'])) { + $cleanUrl .= ':' . (int) $parts['port']; + } + $cleanUrl .= $parts['path'] ?? ''; + if (isset($parts['query'])) { + $cleanUrl .= '?' . $parts['query']; + } + + return $cleanUrl; + } + + public function assertHostNotInternal(string $host): void + { + $ips = []; + + $aRecords = gethostbynamel($host); + if (is_array($aRecords)) { + $ips = array_merge($ips, $aRecords); + } + + $aaaaRecords = @dns_get_record($host, DNS_AAAA); + if (is_array($aaaaRecords)) { + foreach ($aaaaRecords as $record) { + if (!empty($record['ipv6'])) { + $ips[] = $record['ipv6']; + } + } + } + + if (empty($ips)) { + throw new \Exception('Could not resolve host.'); + } + + foreach ($ips as $ip) { + if ($this->isInternalIp($ip)) { + throw new \Exception('Host resolves to an internal IP.'); + } + } + } + + private function isInternalIp(string $ip): bool + { + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { + return true; + } + + return IP::fromStringIP($ip)->isInRanges(['169.254.0.0/16', '100.64.0.0/10']); + } + + public function fetchToFile(string $url): array + { + $tmpPath = tempnam(sys_get_temp_dir(), 'hsrimg'); + if ($tmpPath === false) { + throw new \Exception('Could not create a temporary file.'); + } + + try { + Http::sendHttpRequest($url, self::TIMEOUT, null, $tmpPath, 0, false, [0, self::MAX_BYTES], false); + + if (filesize($tmpPath) === 0) { + throw new \Exception('Empty response body.'); + } + + $mime = $this->validateDownloadedFile($tmpPath); + } catch (\Exception $e) { + @unlink($tmpPath); + throw $e; + } + + return ['path' => $tmpPath, 'mime' => $mime]; + } + + private function validateDownloadedFile(string $path): string + { + if (filesize($path) > self::MAX_BYTES) { + throw new \Exception('Downloaded file exceeds the maximum allowed size.'); + } + + $info = @getimagesize($path); + if ($info !== false && !empty($info['mime']) && in_array($info['mime'], self::ALLOWED_MIME, true)) { + return $info['mime']; + } + + if (in_array('image/svg+xml', self::ALLOWED_MIME, true) && $this->isSvg($path)) { + return 'image/svg+xml'; + } + + throw new \Exception('Downloaded file is not an allowed image type.'); + } + + private function isSvg(string $path): bool + { + $reader = new \XMLReader(); + + if (@$reader->open($path, null, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING) !== true) { + return false; + } + + $isSvg = false; + + try { + while (@$reader->read()) { + if ($reader->nodeType === \XMLReader::ELEMENT) { + $isSvg = strtolower($reader->localName) === 'svg'; + break; + } + } + } catch (\Throwable $e) { + $isSvg = false; + } finally { + $reader->close(); + } + + return $isSvg; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Input/Breakpoint.php b/files/plugin-HeatmapSessionRecording-5.6.0/Input/Breakpoint.php new file mode 100644 index 0000000..bb0eb6a --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Input/Breakpoint.php @@ -0,0 +1,66 @@ +breakpoint = $breakpoint; + $this->name = $name; + } + + public function check() + { + $title = Piwik::translate('HeatmapSessionRecording_BreakpointX', array($this->name)); + + // zero is a valid value! + if ($this->breakpoint === false || $this->breakpoint === null || $this->breakpoint === '') { + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorXNotProvided', $title)); + } + + if (!is_numeric($this->breakpoint)) { + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorXNotANumber', array($title))); + } + + if ($this->breakpoint < 0) { + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorXTooLow', array($title, 0))); + } + + if ($this->breakpoint > self::MAX_LIMIT) { + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorXTooHigh', array($title, self::MAX_LIMIT))); + } + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Input/CaptureKeystrokes.php b/files/plugin-HeatmapSessionRecording-5.6.0/Input/CaptureKeystrokes.php new file mode 100644 index 0000000..b6004f9 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Input/CaptureKeystrokes.php @@ -0,0 +1,40 @@ +value = $value; + } + + public function check() + { + $allowedValues = array('0', '1', 0, 1, true, false); + + if (!in_array($this->value, $allowedValues, $strict = true)) { + $message = Piwik::translate('HeatmapSessionRecording_ErrorXNotWhitelisted', array('captureKeystrokes', '"1", "0"')); + throw new Exception($message); + } + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Input/Description.php b/files/plugin-HeatmapSessionRecording-5.6.0/Input/Description.php new file mode 100644 index 0000000..fddf4af --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Input/Description.php @@ -0,0 +1,50 @@ +description = $description; + } + + public function check() + { + if ($this->description === null || $this->description === false || $this->description === '') { + // description is optional + return; + } + + $title = Piwik::translate('General_Description'); + + if (Common::mb_strlen($this->description) > static::MAX_LENGTH) { + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorXTooLong', array($title, static::MAX_LENGTH))); + } + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Input/ExcludedElements.php b/files/plugin-HeatmapSessionRecording-5.6.0/Input/ExcludedElements.php new file mode 100644 index 0000000..2395013 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Input/ExcludedElements.php @@ -0,0 +1,50 @@ +selector = $name; + } + + public function check() + { + if ($this->selector === null || $this->selector === false || $this->selector === '') { + // selecto may not be set + return; + } + + $title = Piwik::translate('HeatmapSessionRecording_ExcludedElements'); + + if (Common::mb_strlen($this->selector) > static::MAX_LENGTH) { + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorXTooLong', array($title, static::MAX_LENGTH))); + } + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Input/MinSessionTime.php b/files/plugin-HeatmapSessionRecording-5.6.0/Input/MinSessionTime.php new file mode 100644 index 0000000..22dd6d4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Input/MinSessionTime.php @@ -0,0 +1,57 @@ +minSessionTime = $minSessionTime; + } + + public function check() + { + $title = 'HeatmapSessionRecording_MinSessionTime'; + + if ($this->minSessionTime === false || $this->minSessionTime === null || $this->minSessionTime === '') { + $title = Piwik::translate($title); + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorXNotProvided', $title)); + } + + if (!is_numeric($this->minSessionTime)) { + $title = Piwik::translate($title); + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorXNotANumber', array($title))); + } + + if ($this->minSessionTime < 0) { + $title = Piwik::translate($title); + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorXTooLow', array($title, 0))); + } + + if ($this->minSessionTime > self::MAX_LIMIT) { + $title = Piwik::translate($title); + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorXTooHigh', array($title, self::MAX_LIMIT))); + } + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Input/Name.php b/files/plugin-HeatmapSessionRecording-5.6.0/Input/Name.php new file mode 100644 index 0000000..002a0e9 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Input/Name.php @@ -0,0 +1,51 @@ +name = $name; + } + + public function check() + { + $title = 'General_Name'; + + if (empty($this->name)) { + $title = Piwik::translate($title); + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorXNotProvided', $title)); + } + + if (Common::mb_strlen($this->name) > static::MAX_LENGTH) { + $title = Piwik::translate($title); + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorXTooLong', array($title, static::MAX_LENGTH))); + } + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Input/PageRule.php b/files/plugin-HeatmapSessionRecording-5.6.0/Input/PageRule.php new file mode 100644 index 0000000..3c09d15 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Input/PageRule.php @@ -0,0 +1,80 @@ +target = $targets; + $this->parameterName = $parameterName; + $this->index = $index; + } + + public function check() + { + $titleSingular = 'HeatmapSessionRecording_PageRule'; + + if (!is_array($this->target)) { + $titleSingular = Piwik::translate($titleSingular); + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorInnerIsNotAnArray', array($titleSingular, $this->parameterName))); + } + + if (empty($this->target['attribute'])) { + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorArrayMissingKey', array('attribute', $this->parameterName, $this->index))); + } + + if (empty($this->target['type'])) { + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorArrayMissingKey', array('type', $this->parameterName, $this->index))); + } + + if (!array_key_exists('inverted', $this->target)) { + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorArrayMissingKey', array('inverted', $this->parameterName, $this->index))); + } + + if (empty($this->target['value']) && Tracker\PageRuleMatcher::doesTargetTypeRequireValue($this->target['type'])) { + // any is the only target type that may have an empty value + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorArrayMissingValue', array('value', $this->parameterName, $this->index))); + } + + if ($this->target['type'] === Tracker\PageRuleMatcher::TYPE_REGEXP && isset($this->target['value'])) { + $pattern = Tracker\PageRuleMatcher::completeRegexpPattern($this->target['value']); + if (@preg_match($pattern, '') === false) { + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorInvalidRegExp', array($this->target['value']))); + } + } + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Input/PageRules.php b/files/plugin-HeatmapSessionRecording-5.6.0/Input/PageRules.php new file mode 100644 index 0000000..562c871 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Input/PageRules.php @@ -0,0 +1,61 @@ +targets = $targets; + $this->parameterName = $parameterName; + $this->needsAtLeastOneEntry = $needsAtLeastOneEntry; + } + + public function check() + { + if ($this->needsAtLeastOneEntry && empty($this->targets)) { + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorXNotProvided', $this->parameterName)); + } + + if (!is_array($this->targets)) { + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorNotAnArray', $this->parameterName)); + } + + foreach ($this->targets as $index => $target) { + $target = new PageRule($target, $this->parameterName, $index); + $target->check(); + } + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Input/RequiresActivity.php b/files/plugin-HeatmapSessionRecording-5.6.0/Input/RequiresActivity.php new file mode 100644 index 0000000..61ff6a6 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Input/RequiresActivity.php @@ -0,0 +1,40 @@ +value = $value; + } + + public function check() + { + $allowedValues = array('0', '1', 0, 1, true, false); + + if (!in_array($this->value, $allowedValues, $strict = true)) { + $message = Piwik::translate('HeatmapSessionRecording_ErrorXNotWhitelisted', array('activated', '"1", "0"')); + throw new Exception($message); + } + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Input/SampleLimit.php b/files/plugin-HeatmapSessionRecording-5.6.0/Input/SampleLimit.php new file mode 100644 index 0000000..daa8686 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Input/SampleLimit.php @@ -0,0 +1,57 @@ +sampleLimit = $sampleLimit; + } + + public function check() + { + $title = 'HeatmapSessionRecording_SampleLimit'; + + if ($this->sampleLimit === false || $this->sampleLimit === null || $this->sampleLimit === '') { + $title = Piwik::translate($title); + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorXNotProvided', $title)); + } + + if (!is_numeric($this->sampleLimit)) { + $title = Piwik::translate($title); + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorXNotANumber', array($title))); + } + + if ($this->sampleLimit < 0) { + $title = Piwik::translate($title); + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorXTooLow', array($title, 0))); + } + + if ($this->sampleLimit > self::MAX_LIMIT) { + $title = Piwik::translate($title); + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorXTooHigh', array($title, self::MAX_LIMIT))); + } + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Input/SampleRate.php b/files/plugin-HeatmapSessionRecording-5.6.0/Input/SampleRate.php new file mode 100644 index 0000000..b0f6261 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Input/SampleRate.php @@ -0,0 +1,62 @@ +sampleRate = $sampleRate; + } + + public function check() + { + $title = 'HeatmapSessionRecording_SampleRate'; + + if ($this->sampleRate === false || $this->sampleRate === null || $this->sampleRate === '') { + $title = Piwik::translate($title); + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorXNotProvided', $title)); + } + + if (!is_numeric($this->sampleRate)) { + $title = Piwik::translate($title); + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorXNotANumber', array($title))); + } + + if ($this->sampleRate < 0) { + $title = Piwik::translate($title); + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorXTooLow', array($title, 0))); + } + + if ($this->sampleRate > self::MAX_RATE) { + $title = Piwik::translate($title); + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorXTooHigh', array($title, self::MAX_RATE))); + } + + if (!preg_match('/^\d{1,3}\.?\d?$/', (string) $this->sampleRate)) { + $title = Piwik::translate($title); + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorXNotANumber', array($title))); + } + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Input/ScreenshotUrl.php b/files/plugin-HeatmapSessionRecording-5.6.0/Input/ScreenshotUrl.php new file mode 100644 index 0000000..2b40332 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Input/ScreenshotUrl.php @@ -0,0 +1,58 @@ +url = $name; + } + + public function check() + { + if ($this->url === null || $this->url === false || $this->url === '') { + // url may not be set + return; + } + + $title = Piwik::translate('HeatmapSessionRecording_ScreenshotUrl'); + + if (preg_match('/\s/', $this->url)) { + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorXContainsWhitespace', $title)); + } + + if (strpos($this->url, '//') === false) { + throw new Exception(Piwik::translate('HeatmapSessionRecording_UrlXDoesNotLookLikeUrl', array($title, static::MAX_LENGTH))); + } + + if (Common::mb_strlen($this->url) > static::MAX_LENGTH) { + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorXTooLong', array($title, static::MAX_LENGTH))); + } + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Input/Validator.php b/files/plugin-HeatmapSessionRecording-5.6.0/Input/Validator.php new file mode 100644 index 0000000..c70bb1c --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Input/Validator.php @@ -0,0 +1,233 @@ +configuration = new Configuration(); + $this->systemSettings = $systemSettings; + } + + private function supportsMethod($method) + { + return method_exists('Piwik\Piwik', $method); + } + + public function checkHasSomeWritePermission() + { + Piwik::checkUserHasSomeWriteAccess(); + } + + public function checkWritePermission($idSite) + { + $this->checkSiteExists($idSite); + Piwik::checkUserIsNotAnonymous(); + Piwik::checkUserHasWriteAccess($idSite); + } + + public function checkHeatmapReportViewPermission($idSite) + { + $this->checkSiteExists($idSite); + Piwik::checkUserHasViewAccess($idSite); + $this->checkHeatmapRecordingEnabled($idSite); + } + + public function checkSessionReportViewPermission($idSite) + { + $this->checkSiteExists($idSite); + $this->checkUserIsNotAnonymousForView($idSite); + Piwik::checkUserHasViewAccess($idSite); + $this->checkSessionRecordingEnabled($idSite); + } + + public function checkSessionReportWritePermission($idSite) + { + $this->checkWritePermission($idSite); + $this->checkSessionRecordingEnabled($idSite); + } + + public function checkHeatmapReportWritePermission($idSite) + { + $this->checkWritePermission($idSite); + $this->checkHeatmapRecordingEnabled($idSite); + } + + public function checkSessionRecordingEnabled(?int $idSite = null) + { + if ($this->isSessionRecordingDisabled($idSite)) { + throw new \Exception(Piwik::translate('HeatmapSessionRecording_ErrorSessionRecordingDisabled')); + } + } + + public function checkHeatmapRecordingEnabled(?int $idSite = null) + { + if ($this->isHeatmapRecordingDisabled($idSite)) { + throw new \Exception(Piwik::translate('HeatmapSessionRecording_ErrorHeatmapRecordingDisabled')); + } + } + + /** + * Check whether the current user is allowed to duplicate to specified sites. + * + * @param int[] $idSites + * @param int $hsrType Optional type indicating the permission check is for Heatmaps (1) or SessionRecordings (2). + * The default is Heatmaps. + * @return void + * @throws \Exception If heatmaps aren't enabled, the user doesn't have write permission, or the site is a rollup + */ + public function checkSitesDuplicationPermission(array $idSites, int $hsrType = SiteHsrDao::RECORD_TYPE_HEATMAP) + { + if ($hsrType === SiteHsrDao::RECORD_TYPE_HEATMAP) { + foreach ($idSites as $idSite) { + $this->checkHeatmapRecordingEnabled($idSite); + } + } elseif ($hsrType === SiteHsrDao::RECORD_TYPE_SESSION) { + foreach ($idSites as $idSite) { + $this->checkSessionRecordingEnabled($idSite); + } + } else { + throw new \Exception('Unsupported HSR Type: ' . $hsrType); + } + + if (count($idSites) < 1) { + throw new \Exception(Piwik::translate('General_Required', ['idSites'])); + } + + Piwik::checkUserHasWriteAccess($idSites); + // Check if any of the specified sites are rollups + $rollupSiteNames = []; + foreach ($idSites as $idSite) { + if (Site::getTypeFor($idSite) === 'rollup') { + $rollupSiteNames[] = Site::getNameFor($idSite); + } + } + + if (count($rollupSiteNames) > 0) { + throw new \Exception(Piwik::translate( + 'HeatmapSessionRecording_HeatmapDuplicationSiteTypeError', + ['\'' . implode('\', \'', $rollupSiteNames) . '\''] + )); + } + } + + private function checkUserIsNotAnonymousForView($idSite) + { + if ($this->configuration->isAnonymousSessionRecordingAccessEnabled($idSite)) { + Piwik::checkUserHasViewAccess($idSite); + return; + } + + Piwik::checkUserIsNotAnonymous(); + } + + private function checkSiteExists($idSite) + { + new Site($idSite); + } + + public function canViewSessionReport(int $idSite) + { + if (empty($idSite) || $this->isSessionRecordingDisabled($idSite)) { + return false; + } + + if ( + !$this->configuration->isAnonymousSessionRecordingAccessEnabled($idSite) + && Piwik::isUserIsAnonymous() + ) { + return false; + } + + return Piwik::isUserHasViewAccess($idSite); + } + + public function canViewHeatmapReport(int $idSite) + { + if (empty($idSite) || $this->isHeatmapRecordingDisabled($idSite)) { + return false; + } + + return Piwik::isUserHasViewAccess($idSite); + } + + public function canWrite($idSite) + { + if (empty($idSite)) { + return false; + } + + return Piwik::isUserHasWriteAccess($idSite); + } + + public function isSessionRecordingDisabled(?int $idSite = null) + { + + try { + $featureFlagManager = StaticContainer::get(FeatureFlagManager::class); + if ($featureFlagManager->isFeatureActive('Piwik\Plugins\PrivacyManager\FeatureFlags\PrivacyCompliance')) { + return DisableSessionRecording::getInstance($idSite)->getValue(); + } + } catch (NotFoundException $e) { + /* + if the feature flag manager cannot be loaded, + just move on to backup/original setting + value retrieval + */ + } + return $this->systemSettings->disableSessionRecording->getValue(); + } + + public function isHeatmapRecordingDisabled(?int $idSite = null) + { + try { + $featureFlagManager = StaticContainer::get(FeatureFlagManager::class); + if ($featureFlagManager->isFeatureActive('Piwik\Plugins\PrivacyManager\FeatureFlags\PrivacyCompliance')) { + return DisableHeatmapRecording::getInstance($idSite)->getValue(); + } + } catch (NotFoundException $e) { + /* + if the feature flag manager cannot be loaded, + just move on to backup/original setting + value retrieval + */ + } + return $this->systemSettings->disableHeatmapRecording->getValue(); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Install/HtAccess.php b/files/plugin-HeatmapSessionRecording-5.6.0/Install/HtAccess.php new file mode 100644 index 0000000..dc7017d --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Install/HtAccess.php @@ -0,0 +1,66 @@ +getPluginDir() . '/.htaccess'; + } + + private function getSourcePath() + { + return $this->getPluginDir() . '/Install/htaccessTemplate'; + } + + private function exists() + { + $path = $this->getTargetPath(); + return file_exists($path); + } + + private function canCreate() + { + return is_writable($this->getPluginDir()); + } + + private function isContentDifferent() + { + $templateContent = trim(file_get_contents($this->getSourcePath())); + $fileContent = trim(file_get_contents($this->getTargetPath())); + + return $templateContent !== $fileContent; + } + + public function install() + { + if ( + $this->canCreate() && (!$this->exists() || (is_readable($this->getTargetPath()) && $this->isContentDifferent())) + ) { + Filesystem::copy($this->getSourcePath(), $this->getTargetPath()); + } + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Install/htaccessTemplate b/files/plugin-HeatmapSessionRecording-5.6.0/Install/htaccessTemplate new file mode 100644 index 0000000..c302274 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Install/htaccessTemplate @@ -0,0 +1,23 @@ +# This file is generated by InnoCraft - Piwik, do not edit directly +# Please report any issue or improvement directly to the InnoCraft team. +# Allow to serve configs.php which is safe + + + + Order Allow,Deny + Allow from All + + = 2.4> + Require all granted + + + + + Order Allow,Deny + Allow from All + + + Require all granted + + + \ No newline at end of file diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/LEGALNOTICE b/files/plugin-HeatmapSessionRecording-5.6.0/LEGALNOTICE new file mode 100644 index 0000000..b8a6bb2 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/LEGALNOTICE @@ -0,0 +1,46 @@ +COPYRIGHT + + The software package is: + + Copyright (C) 2017 InnoCraft Ltd (NZBN 6106769) + + +SOFTWARE LICENSE + + This software is licensed under the InnoCraft EULA and the license has been included in this + software package in the file LICENSE. + + +THIRD-PARTY COMPONENTS AND LIBRARIES + + The following components/libraries are redistributed in this package, + and subject to their respective licenses. + + Name: heatmap.js + Link: https://www.patrick-wied.at/static/heatmapjs/ + License: MIT + License File: ibs/heatmap.js/LICENSE + + Name: mutation-summary + Link: https://github.com/rafaelw/mutation-summary/ + License: Apache 2.0 + License File: libs/mutation-summary/COPYING + + Name: MutationObserver.js + Link: https://github.com/megawac/MutationObserver.js/tree/master + License: WTFPL, Version 2 + License File: libs/MutationObserver.js/license + + Name: svg.js + Link: http://tkyk.github.com/jquery-history-plugin/ + License: http://svgjs.com/ + License File: libs/svg.js/LICENSE.txt + + Name: Get Element CSS Selector + Link: https://gist.github.com/asfaltboy/8aea7435b888164e8563 + License: MIT + License File: included in tracker.min.js + + Name: Material icons ("repeat", "looks_one", "looks_two", "looks_four", "looks_six") in angularjs/sessionvis/sessionvis.directive.html + Link: https://design.google.com/icons/ + License: Apache License Version 2.0 diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/LICENSE b/files/plugin-HeatmapSessionRecording-5.6.0/LICENSE new file mode 100644 index 0000000..4686f35 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/LICENSE @@ -0,0 +1,49 @@ +InnoCraft License + +This InnoCraft End User License Agreement (the "InnoCraft EULA") is between you and InnoCraft Ltd (NZBN 6106769) ("InnoCraft"). If you are agreeing to this Agreement not as an individual but on behalf of your company, then "Customer" or "you" means your company, and you are binding your company to this Agreement. InnoCraft may modify this Agreement from time to time, subject to the terms in Section (xii) below. + +By clicking on the "I’ve read and accept the terms & conditions (https://shop.matomo.org/terms-conditions/)" (or similar button) that is presented to you at the time of your Order, or by using or accessing InnoCraft products, you indicate your assent to be bound by this Agreement. + + +InnoCraft EULA + +(i) InnoCraft is the licensor of the Plugin for Matomo Analytics (the "Software"). + +(ii) Subject to the terms and conditions of this Agreement, InnoCraft grants you a limited, worldwide, non-exclusive, non-transferable and non-sublicensable license to install and use the Software only on hardware systems owned, leased or controlled by you, during the applicable License Term. The term of each Software license ("License Term") will be specified in your Order. Your License Term will end upon any breach of this Agreement. + +(iii) Unless otherwise specified in your Order, for each Software license that you purchase, you may install one production instance of the Software in a Matomo Analytics instance owned or operated by you, and accessible via one URL ("Matomo instance"). Additional licenses must be purchased in order to deploy the Software in multiple Matomo instances, including when these multiple Matomo instances are hosted on a single hardware system. + +(iv) Licenses granted by InnoCraft are granted subject to the condition that you must ensure the maximum number of Authorized Users and Authorized Sites that are able to access and use the Software is equal to the number of User and Site Licenses for which the necessary fees have been paid to InnoCraft for the Subscription period. You may upgrade your license at any time on payment of the appropriate fees to InnoCraft in order to increase the maximum number of authorized users or sites. The number of User and Site Licenses granted to you is dependent on the fees paid by you. “User License” means a license granted under this EULA to you to permit an Authorized User to use the Software. “Authorized User” means a person who has an account in the Matomo instance and for which the necessary fees (“Subscription fees”) have been paid to InnoCraft for the current license term. "Site License" means a license granted under this EULA to you to permit an Authorized Site to use the Matomo Marketplace Plugin. “Authorized Sites” means a website or a measurable within Matomo instance and for which the necessary fees (“Subscription fees”) have been paid to InnoCraft for the current license term. These restrictions also apply if you install the Matomo Analytics Platform as part of your WordPress. + +(v) Piwik Analytics was renamed to Matomo Analytics in January 2018. The same terms and conditions as well as any restrictions or grants apply if you are using any version of Piwik. + +(vi) The Software requires a license key in order to operate, which will be delivered to the email addresses specified in your Order when we have received payment of the applicable fees. + +(vii) Any information that InnoCraft may collect from you or your device will be subject to InnoCraft Privacy Policy (https://www.innocraft.com/privacy). + +(viii) You are bound by the Matomo Marketplace Terms and Conditions (https://shop.matomo.org/terms-conditions/). + +(ix) You may not reverse engineer or disassemble or re-distribute the Software in whole or in part, or create any derivative works from or sublicense any rights in the Software, unless otherwise expressly authorized in writing by InnoCraft. + +(x) The Software is protected by copyright and other intellectual property laws and treaties. InnoCraft own all title, copyright and other intellectual property rights in the Software, and the Software is licensed to you directly by InnoCraft, not sold. + +(xi) The Software is provided under an "as is" basis and without any support or maintenance. Nothing in this Agreement shall require InnoCraft to provide you with support or fixes to any bug, failure, mis-performance or other defect in The Software. InnoCraft may provide you, from time to time, according to his sole discretion, with updates of the Software. You hereby warrant to keep the Software up-to-date and install all relevant updates. InnoCraft shall provide any update free of charge. + +(xii) The Software is provided "as is", and InnoCraft hereby disclaim all warranties, including but not limited to any implied warranties of title, non-infringement, merchantability or fitness for a particular purpose. InnoCraft shall not be liable or responsible in any way for any losses or damage of any kind, including lost profits or other indirect or consequential damages, relating to your use of or reliance upon the Software. + +(xiii) We may update or modify this Agreement from time to time, including the referenced Privacy Policy and the Matomo Marketplace Terms and Conditions. If a revision meaningfully reduces your rights, we will use reasonable efforts to notify you (by, for example, sending an email to the billing or technical contact you designate in the applicable Order). If we modify the Agreement during your License Term or Subscription Term, the modified version will be effective upon your next renewal of a License Term. + + +About InnoCraft Ltd + +At InnoCraft Ltd, we create innovating quality products to grow your business and to maximize your success. + +Our software products are built on top of Matomo Analytics: the leading open digital analytics platform used by more than one million websites worldwide. We are the creators and makers of the Matomo Analytics platform. + + +Contact + +Email: contact@innocraft.com +Contact form: https://www.innocraft.com/#contact +Website: https://www.innocraft.com/ +Buy our products: Premium Features for Matomo Analytics https://plugins.matomo.org/premium diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Menu.php b/files/plugin-HeatmapSessionRecording-5.6.0/Menu.php new file mode 100644 index 0000000..68799fa --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Menu.php @@ -0,0 +1,50 @@ +validator = $validator; + } + + public function configureAdminMenu(MenuAdmin $menu) + { + $idSite = Common::getRequestVar('idSite', 0, 'int'); + + if (!empty($idSite) && !Piwik::isUserIsAnonymous() && $this->validator->canWrite($idSite)) { + if (!$this->validator->isHeatmapRecordingDisabled($idSite)) { + $menu->addMeasurableItem('HeatmapSessionRecording_Heatmaps', $this->urlForAction('manageHeatmap'), $orderId = 30); + } + if (!$this->validator->isSessionRecordingDisabled($idSite)) { + $menu->addMeasurableItem('HeatmapSessionRecording_SessionRecordings', $this->urlForAction('manageSessions'), $orderId = 30); + } + } + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Model/RepeatName.php b/files/plugin-HeatmapSessionRecording-5.6.0/Model/RepeatName.php new file mode 100644 index 0000000..77543ca --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Model/RepeatName.php @@ -0,0 +1,111 @@ +getDefaultLanguage(); + } + + $shortMonths = self::getLocalizedShortMonths($translator, $language); + $monthOfYear = (int) $date->toString('n'); + + return self::buildName($baseName, $shortMonths[$monthOfYear], $date->toString('Y'), self::getStrippableShortMonths($translator, $shortMonths)); + } + + public static function buildName($baseName, $shortMonth, $year, array $allShortMonths) + { + $baseName = self::stripExistingSuffix((string) $baseName, $allShortMonths); + $suffix = $shortMonth . '-' . $year; + + return self::appendWithinLimit($baseName, $suffix); + } + + private static function getLocalizedShortMonths(Translator $translator, $language) + { + $months = array(); + + for ($month = 1; $month <= 12; $month++) { + $name = $translator->translate('Intl_Month_Short_' . $month, array(), $language); + $months[$month] = Common::mb_strtolower($name) === $name + ? Common::mb_substr(Common::mb_strtoupper($name), 0, 1) . Common::mb_substr($name, 1) + : $name; + } + + return $months; + } + + /** + * A suffix generated earlier must stay recognisable after the owner changes their language, or suffixes stack. + * Earlier suffixes were written either in the owner's language, the configured default one, or English. + */ + private static function getStrippableShortMonths(Translator $translator, array $shortMonths) + { + $months = array_values($shortMonths); + + foreach (array($translator->getDefaultLanguage(), 'en') as $language) { + $months = array_merge($months, array_values(self::getLocalizedShortMonths($translator, $language))); + } + + return array_unique($months); + } + + private static function stripExistingSuffix($baseName, array $allShortMonths) + { + $quoted = array(); + foreach ($allShortMonths as $shortMonth) { + $quoted[] = preg_quote($shortMonth, '/'); + } + + $pattern = '/\s(?:' . implode('|', $quoted) . ')-\d{4}$/u'; + + return preg_replace($pattern, '', $baseName); + } + + private static function appendWithinLimit($baseName, $suffix) + { + $full = $baseName . ' ' . $suffix; + + if (Common::mb_strlen($full) <= Name::MAX_LENGTH) { + return $full; + } + + $maxBaseLength = Name::MAX_LENGTH - Common::mb_strlen($suffix) - 1; + if ($maxBaseLength < 0) { + $maxBaseLength = 0; + } + + $truncatedBase = rtrim(Common::mb_substr($baseName, 0, $maxBaseLength)); + + if ($truncatedBase === '') { + return $suffix; + } + + return $truncatedBase . ' ' . $suffix; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Model/SiteHsrModel.php b/files/plugin-HeatmapSessionRecording-5.6.0/Model/SiteHsrModel.php new file mode 100644 index 0000000..428dec1 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Model/SiteHsrModel.php @@ -0,0 +1,828 @@ +dao = $dao; + $this->logHsrSite = $logHsrSite; + } + + public function addHeatmap($idSite, $name, $matchPageRules, $sampleLimit, $sampleRate, $excludedElements, $screenshotUrl, $breakpointMobile, $breakpointTablet, $captureDomManually, $createdDate, $description = '', $autoRepeat = false) + { + $this->checkHeatmap($name, $matchPageRules, $sampleLimit, $sampleRate, $excludedElements, $screenshotUrl, $breakpointMobile, $breakpointTablet, $description); + + $status = SiteHsrDao::STATUS_ACTIVE; + + $idSiteHsr = $this->dao->createHeatmapRecord( + $idSite, + $name, + $sampleLimit, + $sampleRate, + $matchPageRules, + $excludedElements, + $screenshotUrl, + $breakpointMobile, + $breakpointTablet, + $status, + $captureDomManually, + $createdDate, + $description, + $autoRepeat + ); + $this->clearTrackerCache($idSite); + + return (int) $idSiteHsr; + } + + /** + * @return int|null the id of the copy, or null when the source is no longer eligible to be repeated + */ + public function createRepeatCopy($idSite, $idSiteHsr) + { + $heatmap = $this->getHeatmap($idSite, $idSiteHsr); + + if (empty($heatmap)) { + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorHeatmapDoesNotExist')); + } + + $repeat = $this->prepareRepeat($idSite, $idSiteHsr, $heatmap); + + if (empty($repeat)) { + return null; + } + + $login = $repeat['login']; + $createdDate = $repeat['created_date']; + $scheduledDate = $repeat['scheduled_date']; + $name = $repeat['name']; + + $matchPageRules = $heatmap['match_page_rules']; + $sampleLimit = $heatmap['sample_limit'] ?? 1000; + $sampleRate = $heatmap['sample_rate'] ?? 5; + $excludedElements = $heatmap['excluded_elements'] ?? false; + $screenshotUrl = $heatmap['screenshot_url'] ?? false; + $breakpointMobile = $heatmap['breakpoint_mobile'] ?? false; + $breakpointTablet = $heatmap['breakpoint_tablet'] ?? false; + $captureDomManually = $heatmap['capture_manually'] ?? false; + $description = $heatmap['description'] ?? ''; + + $this->checkHeatmap($name, $matchPageRules, $sampleLimit, $sampleRate, $excludedElements, $screenshotUrl, $breakpointMobile, $breakpointTablet, $description); + + $idSiteHsrNew = $this->dao->createHeatmapRecord( + $idSite, + $name, + $sampleLimit, + $sampleRate, + $matchPageRules, + $excludedElements, + $screenshotUrl, + $breakpointMobile, + $breakpointTablet, + SiteHsrDao::STATUS_ONHOLD, + $captureDomManually, + $createdDate, + $description, + true, + $login, + $scheduledDate, + $idSiteHsr + ); + + $this->clearTrackerCache($idSite); + + return (int) $idSiteHsrNew; + } + + public function updateHeatmap( + $idSite, + $idSiteHsr, + $name, + $matchPageRules, + $sampleLimit, + $sampleRate, + $excludedElements, + $screenshotUrl, + $breakpointMobile, + $breakpointTablet, + $captureDomManually, + $updatedDate, + $description = '', + $autoRepeat = false + ) { + $this->checkHeatmap($name, $matchPageRules, $sampleLimit, $sampleRate, $excludedElements, $screenshotUrl, $breakpointMobile, $breakpointTablet, $description); + + $columns = array( + 'name' => $name, + 'description' => (string) $description, + 'sample_limit' => $sampleLimit, + 'match_page_rules' => $matchPageRules, + 'sample_rate' => $sampleRate, + 'excluded_elements' => $excludedElements, + 'screenshot_url' => $screenshotUrl, + 'breakpoint_mobile' => $breakpointMobile, + 'breakpoint_tablet' => $breakpointTablet, + 'updated_date' => $updatedDate, + ); + + if (!empty($captureDomManually)) { + $columns['capture_manually'] = 1; + $columns['page_treemirror'] = null; + } else { + $columns['capture_manually'] = 0; + } + + $columns['auto_repeat'] = !empty($autoRepeat) ? 1 : 0; + + $this->updateHsrColumns($idSite, $idSiteHsr, $columns); + $this->clearTrackerCache($idSite); + } + + private function checkHeatmap($name, $matchPageRules, $sampleLimit, $sampleRate, $excludedElements, $screenshotUrl, $breakpointMobile, $breakpointTablet, $description = '') + { + $name = new Name($name); + $name->check(); + + $description = new Description($description); + $description->check(); + + $pageRules = new PageRules($matchPageRules, 'matchPageRules', $needsOneEntry = true); + $pageRules->check(); + + $sampleLimit = new SampleLimit($sampleLimit); + $sampleLimit->check(); + + $sampleRate = new SampleRate($sampleRate); + $sampleRate->check(); + + $screenshotUrl = new ScreenshotUrl($screenshotUrl); + $screenshotUrl->check(); + + $excludedElements = new ExcludedElements($excludedElements); + $excludedElements->check(); + + $breakpointMobile = new Breakpoint($breakpointMobile, 'Mobile'); + $breakpointMobile->check(); + + $breakpointTablet = new Breakpoint($breakpointTablet, 'Tablet'); + $breakpointTablet->check(); + } + + public function addSessionRecording($idSite, $name, $matchPageRules, $sampleLimit, $sampleRate, $minSessionTime, $requiresActivity, $captureKeystrokes, $createdDate, $description = '', $autoRepeat = false) + { + $this->checkSession($name, $matchPageRules, $sampleLimit, $sampleRate, $minSessionTime, $requiresActivity, $captureKeystrokes, $description); + $status = SiteHsrDao::STATUS_ACTIVE; + + $idSiteHsr = $this->dao->createSessionRecord($idSite, $name, $sampleLimit, $sampleRate, $matchPageRules, $minSessionTime, $requiresActivity, $captureKeystrokes, $status, $createdDate, $description, $autoRepeat); + + $this->clearTrackerCache($idSite); + return (int) $idSiteHsr; + } + + /** + * @return int|null the id of the copy, or null when the source is no longer eligible to be repeated + */ + public function createSessionRepeatCopy($idSite, $idSiteHsr) + { + $session = $this->getSessionRecording($idSite, $idSiteHsr); + + if (empty($session)) { + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorSessionRecordingDoesNotExist')); + } + + $repeat = $this->prepareRepeat($idSite, $idSiteHsr, $session); + + if (empty($repeat)) { + return null; + } + + $login = $repeat['login']; + $createdDate = $repeat['created_date']; + $scheduledDate = $repeat['scheduled_date']; + $name = $repeat['name']; + + $matchPageRules = $session['match_page_rules']; + $sampleLimit = $session['sample_limit'] ?? 1000; + $sampleRate = $session['sample_rate'] ?? 10; + $minSessionTime = $session['min_session_time'] ?? 0; + $requiresActivity = $session['requires_activity'] ?? true; + $captureKeystrokes = $session['capture_keystrokes'] ?? true; + $description = $session['description'] ?? ''; + + $this->checkSession($name, $matchPageRules, $sampleLimit, $sampleRate, $minSessionTime, $requiresActivity, $captureKeystrokes, $description); + + $idSiteHsrNew = $this->dao->createSessionRecord( + $idSite, + $name, + $sampleLimit, + $sampleRate, + $matchPageRules, + $minSessionTime, + $requiresActivity, + $captureKeystrokes, + SiteHsrDao::STATUS_ONHOLD, + $createdDate, + $description, + true, + $login, + $scheduledDate, + $idSiteHsr + ); + + $this->clearTrackerCache($idSite); + + return (int) $idSiteHsrNew; + } + + public function updateSessionRecording($idSite, $idSiteHsr, $name, $matchPageRules, $sampleLimit, $sampleRate, $minSessionTime, $requiresActivity, $captureKeystrokes, $updatedDate, $description = '', $autoRepeat = false) + { + $this->checkSession($name, $matchPageRules, $sampleLimit, $sampleRate, $minSessionTime, $requiresActivity, $captureKeystrokes, $description); + + $columns = array( + 'name' => $name, + 'description' => (string) $description, + 'sample_limit' => $sampleLimit, + 'match_page_rules' => $matchPageRules, + 'sample_rate' => $sampleRate, + 'min_session_time' => $minSessionTime, + 'requires_activity' => $requiresActivity, + 'capture_keystrokes' => $captureKeystrokes, + 'updated_date' => $updatedDate, + ); + + $columns['auto_repeat'] = !empty($autoRepeat) ? 1 : 0; + + $this->updateHsrColumns($idSite, $idSiteHsr, $columns); + $this->clearTrackerCache($idSite); + } + + private function checkSession($name, $matchPageRules, $sampleLimit, $sampleRate, $minSessionTime, $requiresActivity, $captureKeystrokes, $description = '') + { + $name = new Name($name); + $name->check(); + + $description = new Description($description); + $description->check(); + + $pageRules = new PageRules($matchPageRules, 'matchPageRules', $needsOneEntry = false); + $pageRules->check(); + + $sampleLimit = new SampleLimit($sampleLimit); + $sampleLimit->check(); + + $sampleRate = new SampleRate($sampleRate); + $sampleRate->check(); + + $minSessionTime = new MinSessionTime($minSessionTime); + $minSessionTime->check(); + + $requiresActivity = new RequiresActivity($requiresActivity); + $requiresActivity->check(); + + $captureKeystrokes = new CaptureKeystrokes($captureKeystrokes); + $captureKeystrokes->check(); + } + + public function getHeatmap($idSite, $idSiteHsr) + { + $record = $this->dao->getRecord($idSite, $idSiteHsr, SiteHsrDao::RECORD_TYPE_HEATMAP); + + return $this->enrichHeatmap($record); + } + + public function getSessionRecording($idSite, $idSiteHsr) + { + $record = $this->dao->getRecord($idSite, $idSiteHsr, SiteHsrDao::RECORD_TYPE_SESSION); + return $this->enrichSessionRecording($record); + } + + public function pauseHeatmap($idSite, $idSiteHsr) + { + $this->pauseRecord($idSite, $idSiteHsr, SiteHsrDao::RECORD_TYPE_HEATMAP); + } + + public function resumeHeatmap($idSite, $idSiteHsr) + { + $this->resumeRecord($idSite, $idSiteHsr, SiteHsrDao::RECORD_TYPE_HEATMAP); + } + + private function pauseRecord($idSite, $idSiteHsr, $recordType) + { + $status = $this->dao->getRecordStatus($idSite, $idSiteHsr, $recordType); + + // only an active record can be paused. Pausing an ended or on hold one would let resumeRecord() turn it + // active, reviving a finished record or starting a scheduled one before its date. + if ($status !== SiteHsrDao::STATUS_ACTIVE) { + return; + } + + $this->updateHsrColumns($idSite, $idSiteHsr, array('status' => SiteHsrDao::STATUS_PAUSED)); + } + + private function resumeRecord($idSite, $idSiteHsr, $recordType) + { + $status = $this->dao->getRecordStatus($idSite, $idSiteHsr, $recordType); + + // only a paused record can be resumed, an on hold record needs to stay on hold until its scheduled date + if ($status === SiteHsrDao::STATUS_PAUSED) { + $this->updateHsrColumns($idSite, $idSiteHsr, array('status' => SiteHsrDao::STATUS_ACTIVE)); + } + } + + private function endRecord($idSite, $idSiteHsr, $recordType, $eventName) + { + $status = $this->dao->getRecordStatus($idSite, $idSiteHsr, $recordType); + + // only a record that has started can be ended. Ending an on hold one would finish a record that never + // recorded anything, and as it repeats the daily task would immediately queue its next copy. It can only + // be deleted. + if ($status !== SiteHsrDao::STATUS_ACTIVE && $status !== SiteHsrDao::STATUS_PAUSED) { + return; + } + + $this->updateHsrColumns($idSite, $idSiteHsr, array('status' => SiteHsrDao::STATUS_ENDED)); + + Piwik::postEvent($eventName, array($idSite, $idSiteHsr)); + } + + public function deactivateHeatmap($idSite, $idSiteHsr) + { + $heatmap = $this->getHeatmap($idSite, $idSiteHsr); + + if (!empty($heatmap)) { + $this->updateHsrColumns($idSite, $idSiteHsr, array('status' => SiteHsrDao::STATUS_DELETED)); + + // the actual recorded heatmap data will still exist but we remove the "links" which is quick. a task will later remove all entries + $this->logHsrSite->unlinkSiteRecords($idSiteHsr); + } + } + + public function heatmapExists($idSite, $idSiteHsr): bool + { + $hsr = $this->dao->getRecord($idSite, $idSiteHsr, SiteHsrDao::RECORD_TYPE_HEATMAP); + + return !empty($hsr); + } + + public function sessionRecordingExists($idSite, $idSiteHsr): bool + { + $hsr = $this->dao->getRecord($idSite, $idSiteHsr, SiteHsrDao::RECORD_TYPE_SESSION); + + return !empty($hsr); + } + + public function checkHeatmapExists($idSite, $idSiteHsr) + { + if (!$this->heatmapExists($idSite, $idSiteHsr)) { + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorHeatmapDoesNotExist')); + } + } + + public function checkSessionRecordingExists($idSite, $idSiteHsr) + { + if (!$this->sessionRecordingExists($idSite, $idSiteHsr)) { + throw new Exception(Piwik::translate('HeatmapSessionRecording_ErrorSessionRecordingDoesNotExist')); + } + } + + public function pauseSessionRecording($idSite, $idSiteHsr) + { + $this->pauseRecord($idSite, $idSiteHsr, SiteHsrDao::RECORD_TYPE_SESSION); + } + + public function resumeSessionRecording($idSite, $idSiteHsr) + { + $this->resumeRecord($idSite, $idSiteHsr, SiteHsrDao::RECORD_TYPE_SESSION); + } + + public function deactivateSessionRecording($idSite, $idSiteHsr) + { + $session = $this->getSessionRecording($idSite, $idSiteHsr); + + if (!empty($session)) { + $this->updateHsrColumns($idSite, $idSiteHsr, array('status' => SiteHsrDao::STATUS_DELETED)); + + // the actual recording will still exist but we remove the "links" which is quick. a task will later remove all entries + $this->logHsrSite->unlinkSiteRecords($idSiteHsr); + } + } + + public function deactivateRecordsForSite($idSite) + { + foreach ($this->dao->getRecords($idSite, SiteHsrDao::RECORD_TYPE_HEATMAP, false) as $heatmap) { + $this->deactivateHeatmap($idSite, $heatmap['idsitehsr']); + } + + foreach ($this->dao->getRecords($idSite, SiteHsrDao::RECORD_TYPE_SESSION, false) as $session) { + $this->deactivateSessionRecording($idSite, $session['idsitehsr']); + } + } + + public function pauseRecordsForSite($idSite) + { + foreach ($this->dao->getRecords($idSite, SiteHsrDao::RECORD_TYPE_HEATMAP, false) as $heatmap) { + $this->pauseHeatmap($idSite, $heatmap['idsitehsr']); + } + + foreach ($this->dao->getRecords($idSite, SiteHsrDao::RECORD_TYPE_SESSION, false) as $session) { + $this->pauseSessionRecording($idSite, $session['idsitehsr']); + } + } + + public function resumeRecordsForSite($idSite) + { + foreach ($this->dao->getRecords($idSite, SiteHsrDao::RECORD_TYPE_HEATMAP, false) as $heatmap) { + $this->resumeHeatmap($idSite, $heatmap['idsitehsr']); + } + + foreach ($this->dao->getRecords($idSite, SiteHsrDao::RECORD_TYPE_SESSION, false) as $session) { + $this->resumeSessionRecording($idSite, $session['idsitehsr']); + } + } + + /** + * @return bool whether the heatmap was activated. False when it is no longer on hold or no longer due. + */ + public function activateOnHoldHeatmap($idSite, $idSiteHsr) + { + return $this->activateOnHoldRecord($idSite, $idSiteHsr, SiteHsrDao::RECORD_TYPE_HEATMAP); + } + + /** + * @return bool whether the session recording was activated. False when it is no longer on hold or no longer due. + */ + public function activateOnHoldSessionRecording($idSite, $idSiteHsr) + { + return $this->activateOnHoldRecord($idSite, $idSiteHsr, SiteHsrDao::RECORD_TYPE_SESSION); + } + + private function activateOnHoldRecord($idSite, $idSiteHsr, $recordType) + { + $activated = $this->dao->activateOnHoldRecord($idSite, $idSiteHsr, $this->getCurrentDateTime(), $recordType); + + if ($activated) { + $this->clearTrackerCache($idSite); + } + + return $activated; + } + + public function endHeatmap($idSite, $idSiteHsr) + { + $this->endRecord($idSite, $idSiteHsr, SiteHsrDao::RECORD_TYPE_HEATMAP, 'HeatmapSessionRecording.endHeatmap'); + } + + public function endSessionRecording($idSite, $idSiteHsr) + { + $this->endRecord($idSite, $idSiteHsr, SiteHsrDao::RECORD_TYPE_SESSION, 'HeatmapSessionRecording.endSessionRecording'); + } + + /** + * @param $idSite + * @param bool $includePageTreeMirror performance and IO tweak has some heatmaps might have a 16MB or more treemirror and it would be loaded on every request causing a lot of IO etc. + * @param bool $skipEnrich option to skip enriching heatmaps in order to get unaltered data + * @return array + */ + public function getHeatmaps($idSite, $includePageTreeMirror, $skipEnrich = false) + { + $heatmaps = $this->dao->getRecords($idSite, SiteHsrDao::RECORD_TYPE_HEATMAP, $includePageTreeMirror); + + if ($skipEnrich) { + return $heatmaps; + } + + return $this->enrichHeatmaps($heatmaps); + } + + /** + * Only active heatmaps that have a snapshot, page_treemirror included, not enriched. + * Filters in SQL so other heatmaps' potentially large treemirror blobs are never loaded. + * + * @param int $idSite + * @return array + */ + public function getActiveHeatmapsWithPageTreemirror($idSite) + { + return $this->dao->getActiveHeatmapsWithPageTreemirror($idSite); + } + + public function getSessionRecordings($idSite) + { + $sessionRecordings = $this->dao->getRecords($idSite, SiteHsrDao::RECORD_TYPE_SESSION, $includePageTreeMirror = false); + + return $this->enrichSessionRecordings($sessionRecordings); + } + + public function hasSessionRecordings($idSite) + { + $hasSession = $this->dao->hasRecords($idSite, SiteHsrDao::RECORD_TYPE_SESSION); + + return !empty($hasSession); + } + + public function hasHeatmaps($idSite) + { + $hasHeatmap = $this->dao->hasRecords($idSite, SiteHsrDao::RECORD_TYPE_HEATMAP); + + return !empty($hasHeatmap); + } + + public function setPageTreeMirror($idSite, $idSiteHsr, $treeMirror, $screenshotUrl) + { + $heatmap = $this->getHeatmap($idSite, $idSiteHsr); + if (!empty($heatmap)) { + // only supported by heatmaps + $columns = array( + 'page_treemirror' => $treeMirror, + 'screenshot_url' => $screenshotUrl + ); + if (!empty($treeMirror)) { + // a new snapshot leaves the "snapshot deleted" empty state + $columns['screenshot_deleted_date'] = null; + + if (!empty($heatmap['capture_manually'])) { + $columns['capture_manually'] = 0; + } + } + $this->updateHsrColumns($idSite, $idSiteHsr, $columns); + } + } + + /** + * Records that the heatmap's snapshot was deleted so the report can show the matching empty state. + * Setting $captureManually stops the snapshot from being retaken automatically on the next visit. + */ + public function markScreenshotDeleted($idSite, $idSiteHsr, $captureManually = false) + { + // Always persist the requested flag so switching a deleted snapshot from manual back to + // automatic (or vice versa) actually takes effect, not just when enabling manual capture. + $this->updateHsrColumns($idSite, $idSiteHsr, array( + 'screenshot_deleted_date' => Date::now()->getDatetime(), + 'capture_manually' => $captureManually ? 1 : 0, + )); + } + + public function getPiwikRequestDate($hsr) + { + // we sub one day to make sure to include them all + $from = Date::factory($hsr['created_date'])->subDay(1)->toString(); + $to = Date::now()->addDay(1)->toString(); + + if ($from === $to) { + $dateRange = $from; + $period = 'year'; + } else { + $period = 'range'; + $dateRange = $from . ',' . $to; + } + + return array('period' => $period, 'date' => $dateRange); + } + + private function enrichHeatmaps($heatmaps) + { + if (empty($heatmaps)) { + return array(); + } + + foreach ($heatmaps as $index => $heatmap) { + $heatmaps[$index] = $this->enrichHeatmap($heatmap); + } + + return $heatmaps; + } + + private function enrichHeatmap($heatmap) + { + if (empty($heatmap)) { + return $heatmap; + } + + unset($heatmap['record_type']); + unset($heatmap['min_session_time']); + unset($heatmap['requires_activity']); + unset($heatmap['capture_keystrokes']); + $heatmap['created_date_pretty'] = Date::factory($heatmap['created_date'])->getLocalized(DateTimeFormatProvider::DATE_FORMAT_SHORT); + + if (!empty($heatmap['scheduled_date'])) { + $heatmap['scheduled_date_pretty'] = Date::factory($heatmap['scheduled_date'])->getLocalized(DateTimeFormatProvider::DATE_FORMAT_SHORT); + } else { + $heatmap['scheduled_date_pretty'] = null; + } + + if ((!method_exists(SettingsServer::class, 'isMatomoForWordPress') || !SettingsServer::isMatomoForWordPress()) && !SettingsServer::isTrackerApiRequest()) { + $heatmap['heatmapViewUrl'] = self::completeWidgetUrl('showHeatmap', 'idSiteHsr=' . (int) $heatmap['idsitehsr'] . '&useDateUrl=0', (int) $heatmap['idsite']); + } + + return $heatmap; + } + + public static function completeWidgetUrl($action, $params, $idSite, $period = null, $date = null) + { + if (!isset($date)) { + if (empty(self::$defaultDate)) { + $userPreferences = new UserPreferences(); + self::$defaultDate = $userPreferences->getDefaultDate(); + if (empty(self::$defaultDate)) { + self:: $defaultDate = 'today'; + } + } + $date = self::$defaultDate; + } + + if (!isset($period)) { + if (!isset(self::$defaultPeriod)) { + $userPreferences = new UserPreferences(); + self::$defaultPeriod = $userPreferences->getDefaultPeriod(false); + if (empty(self::$defaultPeriod)) { + self::$defaultPeriod = 'day'; + } + } + $period = self::$defaultPeriod; + } + + $token = Access::getInstance()->getTokenAuth(); + + $url = 'index.php?module=Widgetize&action=iframe&moduleToWidgetize=HeatmapSessionRecording&actionToWidgetize=' . urlencode($action) . '&' . $params . '&idSite=' . (int) $idSite . '&period=' . urlencode($period) . '&date=' . urlencode($date); + if (!empty($token)) { + $url .= '&token_auth=' . urlencode($token); + } + return $url; + } + + private function enrichSessionRecordings($sessionRecordings) + { + if (empty($sessionRecordings)) { + return array(); + } + + foreach ($sessionRecordings as $index => $sessionRecording) { + $sessionRecordings[$index] = $this->enrichSessionRecording($sessionRecording); + } + + return $sessionRecordings; + } + + private function enrichSessionRecording($session) + { + if (empty($session)) { + return $session; + } + + unset($session['record_type']); + unset($session['screenshot_url']); + unset($session['page_treemirror']); + unset($session['excluded_elements']); + unset($session['breakpoint_mobile']); + unset($session['breakpoint_tablet']); + $session['created_date_pretty'] = Date::factory($session['created_date'])->getLocalized(DateTimeFormatProvider::DATE_FORMAT_SHORT); + + if (!empty($session['scheduled_date'])) { + $session['scheduled_date_pretty'] = Date::factory($session['scheduled_date'])->getLocalized(DateTimeFormatProvider::DATE_FORMAT_SHORT); + } else { + $session['scheduled_date_pretty'] = null; + } + + return $session; + } + + protected function getCurrentDateTime() + { + return Date::now()->getDatetime(); + } + + /** + * The parts of a repeat copy that are the same whatever the record type: whether the source may still be + * repeated, who owns the copy, and when it is created, scheduled and what it is called. + * + * @param int $idSite + * @param int $idSiteHsr + * @param array $record the source record + * @return array|null the `login`, `created_date`, `scheduled_date` and `name` for the copy, or null when the + * source is no longer eligible to be repeated + */ + private function prepareRepeat($idSite, $idSiteHsr, $record) + { + // the source may have been resumed or had repetition turned off since the batch was selected + if ($record['status'] !== SiteHsrDao::STATUS_ENDED || empty($record['auto_repeat'])) { + return null; + } + + $login = $this->dao->getRecordLogin($idSite, $idSiteHsr); + + $createdDate = $this->getCurrentDateTime(); + // the delay runs from when the source finished, not from whenever the daily task happened to run + $scheduledDate = $this->getRepeatAnchorDate($record, $createdDate)->addDay(self::REPEAT_DELAY_DAYS)->getDatetime(); + + return array( + 'login' => $login, + 'created_date' => $createdDate, + 'scheduled_date' => $scheduledDate, + 'name' => RepeatName::generate($record['name'], Date::factory($scheduledDate), $this->getRecordLanguage($login)), + ); + } + + /** + * `updated_date` is set whenever the status changes, so for an ended heatmap it is the time it finished. + * + * @param array $heatmap + * @param string $fallbackDate + * @return Date + */ + private function getRepeatAnchorDate($heatmap, $fallbackDate) + { + if (!empty($heatmap['updated_date'])) { + try { + return Date::factory($heatmap['updated_date']); + } catch (Exception $e) { + // fall through to the fallback below + } + } + + return Date::factory($fallbackDate); + } + + /** + * @param string|null $login + * @return string|null the language of the record's owner, or null to use the configured default language + */ + private function getRecordLanguage($login) + { + if (empty($login)) { + return null; + } + + $language = (new LanguagesManagerModel())->getLanguageForUser($login); + + return !empty($language) ? $language : null; + } + + private function updateHsrColumns($idSite, $idSiteHsr, $columns) + { + if (!isset($columns['updated_date'])) { + $columns['updated_date'] = $this->getCurrentDateTime(); + } + + $this->dao->updateHsrColumns($idSite, $idSiteHsr, $columns); + $this->clearTrackerCache($idSite); + } + + private function clearTrackerCache($idSite) + { + Tracker\Cache::deleteCacheWebsiteAttributes($idSite); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/MutationManipulator.php b/files/plugin-HeatmapSessionRecording-5.6.0/MutationManipulator.php new file mode 100644 index 0000000..2b61c6b --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/MutationManipulator.php @@ -0,0 +1,319 @@ +configuration = $configuration; + $this->generateNonce(); + } + + public function manipulate($initialMutation, $idSiteHsr, $idLogHsr) + { + $parseAndSanitizeCssLinks = $this->updateCssLinks($initialMutation, $idSiteHsr, $idLogHsr); + + return $this->sanitizeNodeAttributes($parseAndSanitizeCssLinks); + } + + public function updateCssLinks($initialMutation, $idSiteHsr, $idLogHsr) + { + if ($this->configuration->isLoadCSSFromDBEnabled()) { + $blob = new LogHsrBlob(); + $dao = new LogHsrEvent($blob); + $cssEvents = $dao->getCssEvents($idSiteHsr, $idLogHsr); + if (!empty($cssEvents) && !empty($initialMutation)) { + $initialMutation = $this->updateInitialMutationWithInlineCss($initialMutation, $cssEvents); + } + } + + return $initialMutation; + } + + public function getNonce() + { + if (!$this->nonce) { + $this->generateNonce(); + } + + + return $this->nonce; + } + + public function generateNonce() + { + $this->nonce = $this->generateRandomString(); + } + + private function generateRandomString($length = 10) + { + $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $charactersLength = strlen($characters); + $randomString = ''; + for ($i = 0; $i < $length; $i++) { + $randomString .= $characters[rand(0, $charactersLength - 1)]; + } + return $randomString; + } + + public function sanitizeNodeAttributes($initialMutation) + { + $initialMutationArray = json_decode($initialMutation, true); + if (!empty($initialMutationArray['children'])) { + $this->parseMutationArrayRecursivelyToSanitizeNodes($initialMutationArray['children']); + $initialMutation = json_encode($initialMutationArray); + } + + return $initialMutation; + } + + public function updateInitialMutationWithInlineCss($initialMutation, $cssEvents) + { + $formattedCssEvents = $this->formatCssEvents($cssEvents); + $initialMutationArray = json_decode($initialMutation, true); + if (!empty($initialMutationArray['children']) && !empty($formattedCssEvents)) { + $this->parseMutationArrayRecursivelyForCssLinks($initialMutationArray['children'], $formattedCssEvents); + + $initialMutation = json_encode($initialMutationArray); + } + + return $initialMutation; + } + + public function formatCssEvents($cssEvents) + { + $formatted = array(); + foreach ($cssEvents as $cssEvent) { + if (!isset($formatted[md5(trim($cssEvent['url']))])) { //Only use the first one since the o/p is sorted by ID in ascending order + $formatted[md5(trim($cssEvent['url']))] = $cssEvent; + } + } + + return $formatted; + } + + private function parseMutationArrayRecursivelyForCssLinks(&$nodes, $cssEvents, &$id = 900000000) + { + foreach ($nodes as &$node) { + $parseChildNodes = true; + if (isset($node['tagName']) && $node['tagName'] == 'LINK' && !empty($node['attributes']['url']) && !empty($cssEvents) && !empty($cssEvents[md5(trim($node['attributes']['url']))]['text'])) { + $parseChildNodes = false; + $content = $cssEvents[md5(trim($node['attributes']['url']))]['text']; + if (!empty($content)) { + $node['tagName'] = 'STYLE'; + $media = $node['attributes']['media'] ?? ''; + if (isset($node['attributes'])) { + $node['attributes'] = []; + } + $node['attributes']['nonce'] = $this->getNonce(); + if ($media) { + $node['attributes']['media'] = $media; + } + $node['childNodes'] = [ + [ + 'nodeType' => 3, + 'id' => $id++, + 'textContent' => $content + ] + ]; + } + } + + if ($parseChildNodes && !empty($node['childNodes'])) { + $this->parseMutationArrayRecursivelyForCssLinks($node['childNodes'], $cssEvents, $id); + } + } + } + + private function parseMutationArrayRecursivelyToSanitizeNodes(&$nodes) + { + foreach ($nodes as &$node) { + if ($this->shouldReplaceBlockedNode($node)) { + $this->replaceBlockedNode($node); + } + + if (!empty($node['attributes'])) { + // empty all the attributes with base64 and contains javascript/script/"(" + // Eg: OR + foreach ($node['attributes'] as $nodeAttributeKey => &$nodeAttributeValue) { + // had to double encode `\x09` as `\\\\x09` in MutationManipulatorTest.php to make json_decode work, else it was giving "syntax error" via json_last_error_msg() + // Due to double encoding had to add entry for both "\\x09" and "\x09" + $nodeAttributeValue = str_replace(["\\x09", "\\x0a", "\\x0d", "\\0", "\x09", "\x0a", "\x0d", "\0"], "", $nodeAttributeValue); + $htmlDecodedAttributeValue = html_entity_decode($nodeAttributeValue, ENT_COMPAT, 'UTF-8'); + if ( + $htmlDecodedAttributeValue && + ( + stripos($htmlDecodedAttributeValue, 'ecmascript') !== false || + stripos($htmlDecodedAttributeValue, 'javascript') !== false || + stripos($htmlDecodedAttributeValue, 'script:') !== false || + stripos($htmlDecodedAttributeValue, 'jscript') !== false || + stripos($htmlDecodedAttributeValue, 'vbscript') !== false + ) + ) { + $nodeAttributeValue = ''; + } elseif (stripos($nodeAttributeValue, 'base64') !== false) { + $base64KeywordMadeLowerCase = str_ireplace('base64', 'base64', $nodeAttributeValue); + //For values like data:text/javascript;base64,YWxlcnQoMSk= we split the value into 2 parts + // part1: data:text/javascript;base64 + // part2: ,YWxlcnQoMSk= we split the value into 2 parts + // we determine the position of first comma from second part and try to decode the base64 string and check fo possible XSS + // cannot assume the position of firstComma to be `0` since there can be string with spaces in beginning + $attributeExploded = explode('base64', $base64KeywordMadeLowerCase); + array_shift($attributeExploded); + if (!empty($attributeExploded)) { + foreach ($attributeExploded as $attributeExplodedValue) { + $htmlDecodedAttributeString = html_entity_decode($attributeExplodedValue, ENT_COMPAT, 'UTF-8'); + $base64DecodedString = $this->decodeBase64Payload($attributeExplodedValue); + $base64UrlDecodedString = $this->decodeBase64Payload(urldecode($attributeExplodedValue)); + if ( + $this->isXssString($base64DecodedString) || + $this->isXssString($base64UrlDecodedString) || + $this->isXssString($htmlDecodedAttributeString) || + $this->isXssString(urldecode($htmlDecodedAttributeString)) + ) { + $nodeAttributeValue = ''; + break; + } + } + } + } elseif ($nodeAttributeValue) { + $htmlDecodedString = html_entity_decode($nodeAttributeValue, ENT_COMPAT, 'UTF-8'); + if ( + $this->isXssString($htmlDecodedString) || + $this->isXssString(urldecode($htmlDecodedString)) + ) { + $nodeAttributeValue = ''; + } + } + } + } + + if (!empty($node['childNodes'])) { + $this->parseMutationArrayRecursivelyToSanitizeNodes($node['childNodes']); + } + } + } + + private function isXssString($value) + { + $value = $this->normalizeDecodedText($value); + + if ( + !empty($value) && + ( + stripos($value, 'script:') !== false || + stripos($value, 'javascript') !== false || + stripos($value, 'ecmascript') !== false || + stripos($value, '') !== false || + stripos($value, ' + ) + ) { + return true; + } + + return false; + } + + private function shouldReplaceBlockedNode($node) + { + if (empty($node['tagName']) || !is_string($node['tagName'])) { + return false; + } + + return in_array(strtoupper($node['tagName']), self::BLOCKED_TAG_NAMES, true); + } + + private function replaceBlockedNode(&$node) + { + $node['tagName'] = 'NO_SCRIPT'; + $node['attributes'] = array('blocked' => 1); + $node['childNodes'] = array(); + } + + private function decodeBase64Payload($value) + { + if (!is_string($value) || '' === $value) { + return $value; + } + + $parts = explode(',', $value, 2); + $payload = count($parts) === 2 ? $parts[1] : $parts[0]; + $payload = trim($payload); + + if ($payload === '') { + return ''; + } + + $decoded = base64_decode($payload, true); + if ($decoded === false) { + return ''; + } + + return $this->normalizeDecodedText($decoded); + } + + private function normalizeDecodedText($value) + { + if (!is_string($value) || $value === '') { + return $value; + } + + if (strpos($value, "\xFF\xFE") === 0) { + return $this->convertEncoding($value, 'UTF-16LE'); + } + + if (strpos($value, "\xFE\xFF") === 0) { + return $this->convertEncoding($value, 'UTF-16BE'); + } + + if ($this->seemsUtf16WithNullBytes($value)) { + return $this->convertEncoding($value, strpos($value, "\x00") === 1 ? 'UTF-16LE' : 'UTF-16BE'); + } + + return $value; + } + + private function seemsUtf16WithNullBytes($value) + { + if (!is_string($value) || strlen($value) < 4) { + return false; + } + + return (bool) preg_match('/^(?:[\x20-\x7E]\x00){2,}|^(?:\x00[\x20-\x7E]){2,}/', $value); + } + + private function convertEncoding($value, $fromEncoding) + { + if (!function_exists('mb_convert_encoding')) { + return $value; + } + + $converted = @mb_convert_encoding($value, 'UTF-8', $fromEncoding); + + return is_string($converted) && $converted !== '' ? $converted : $value; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/README.md b/files/plugin-HeatmapSessionRecording-5.6.0/README.md new file mode 100644 index 0000000..e35a968 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/README.md @@ -0,0 +1,117 @@ +# Matomo HeatmapSessionRecording Plugin + +## Description + +Discover how visitors interact with your website, from the elements they engage with to the journeys they abandon. + +Traditional analytics can tell you what happened. [Heatmaps](https://matomo.org/guide/reports/heatmaps/) and [Session Recordings](https://matomo.org/guide/reports/session-recordings/) reveal how visitors navigate your website, where they hesitate, what attracts attention, and where they encounter obstacles. + +Replace assumptions with direct observation of visitor behaviour on desktop and mobile devices. + +### How Heatmaps & Session Recordings work + +#### Uncover visitor behaviour with Heatmaps + +
+
+

Identify the areas of your pages that attract attention and the elements that are ignored. A heatmap shows where visitors click, move their cursor, and how far they scroll. Use these insights to evaluate content placement, navigation, forms, and calls to action across different devices and screen sizes.

+

Create heatmaps in minutes and start evaluating page performance without building complex reports.

+
+
+Uncover visitor behaviour with Heatmaps +
+
+ +#### Support UX and design decisions with evidence + +
+
+

Website changes often involve competing opinions and assumptions. Heatmaps provide a visual record of visitor behaviour, helping teams evaluate content based on how people actually interact with the page.

+

Compare the impact of design updates over time, identify overlooked content, and assess whether important elements receive the attention they were designed to attract.

+
+
+Support UX and design decisions with evidence +
+
+ +#### Replay real visitor journeys + +
+
+

Watch anonymised session recordings to see exactly how visitors move through your website. Follow mouse movements, clicks, scrolling behaviour, and navigation patterns to understand where visitors abandon tasks or encounter usability issues. Small usability issues can have a significant impact on the user experience. Let Session Recordings detect barriers that affect form completion, checkout flows, content engagement, and conversion paths.

+
+
+Replay real visitor journeys +
+
+ +#### Connect behaviour with analytics data + +
+
+

Combine your heatmap or session recording with Matomo analytics data to understand the pages a visitor explored, the events they triggered, the campaigns they arrived from, and whether they completed a goal or conversion. Additional context brings together quantitative and behavioural data for converting and non-converting visitors to uncover what affects engagement and conversion rates.

+
+
+Connect behaviour with analytics data +
+
+ +### Try Heatmaps & Session Recordings Today + +Stop losing conversions you can't explain. Heatmaps and session recordings show you exactly what users do on your site. Record actual sessions. See where people click, pause, and drop off and understand why visitors leave without converting. + +Free for 30 days. No credit card required. + +### Heatmap visualisation features +* View click, mouse move (hover) and scroll heatmaps. +* View the heatmaps for desktop, [tablet and mobile devices](https://matomo.org/faq/heatmap-session-recording/faq_24232/). +* See how much of the content is visible on average when users open the website (above the fold). +* See how far down your visitors scroll. +* Delete an already taken [heatmap screenshot](https://matomo.org/faq/heatmap-session-recording/faq_32488/). +* Choose between different heatmap widths. +* Apply [segments](https://matomo.org/docs/segmentation/) to drill down your visitors and gain insights into specific target groups. + +### Recording features +* Visitor summary shows used browser, operating system, location, viewport resolution, time spent on the page, and more. +* Video controls like play, pause, replay and seek. +* Video timeline shows you when a certain event like a click, mouse move, or scroll will happen. +* Replays all clicks, mouse movements, scrolls, window resizes, form interactions, and page changes (e.g when a popup appears). +* Replay all recorded page views of a visitor within a session one after another. +* Delete individual [recordings](https://matomo.org/faq/heatmap-session-recording/faq_24226/). +* Enable autoplay to replay all page views within a visit automatically. +* Change the replay speed. +* Skip long pauses in a recording automatically. +* Use shortcuts when replaying a recorded session. +* View the [Visitor Profile](https://matomo.org/docs/user-profile/) to get all information about a visitor. +* Replay a recorded session directly from the [Visitor Log](https://matomo.org/docs/real-time/#visitor-log). +* Apply [segments](https://matomo.org/docs/segmentation/) to find the recordings you are interested in. + +### Manage Heatmap +* Create [unlimited heatmaps](https://matomo.org/faq/reports/create-and-manage-heatmaps/). +* Select how many page views you want to record. +* Define on which page a heatmap should be recorded by applying patterns like "starts with", "contains", "regular expressions" to URL, URL path and URL parameter. +* Choose a sample rate. +* [Hide certain elements](https://matomo.org/faq/heatmap-session-recording/faq_24266/) in the heatmap (for example a pop-up). +* Define on which URL a screenshot should be taken. +* Set custom mobile and tablet breakpoints. + +### Manage Session Recordings +* Record unlimited sessions. +* Select how many page views you want to record. +* Restrict on [which pages](https://matomo.org/faq/heatmap-session-recording/faq_24263/) a visitor should be recorded by using patterns like "starts with" based on URL, URL path and URL parameter. +* Choose a sample rate. +* Only record activities when a visitor spends at least a specific time on a page. +* Only record activities when a user has clicked and scrolled at least once. +* Define if keystrokes on text form fields should be captured or not. +* It only takes a few seconds to create a new session recording. + +### Privacy features +* [Anonymising / Masking](https://matomo.org/faq/heatmap-session-recording/data-masking-in-heatmaps-and-session-recordings/) of personal or sensitive information that a user enters into a form field (keystrokes) to not record personal data. +* Possibility to record [form fields](https://matomo.org/faq/form-analytics/faq_23737/) in plain text except for form fields that may contain personal information. +* Supports Matomo's [privacy](https://matomo.org/faq/general/eprivacy-directive-national-implementations-and-website-analytics/) and [GDPR features](https://matomo.org/faq/new-to-piwik/is-matomo-analytics-gdpr-compliant/) like the right to erase data or the right to export data. GDPR stands for General Data Protection Regulation and is for example also known as RGPD in French, DS-GVO in German. +* The session recording feature can be [disabled](https://matomo.org/faq/heatmap-session-recording/how-do-i-disable-only-the-heatmaps-feature/) while keeping the heatmap feature enabled and vice versa. + +### Export and API features +* HTTP API to manage your heatmaps and session recordings. +* HTTP API to fetch and export all [Heatmap & Session Recording reports](https://developer.matomo.org/api-reference/reporting-api#HeatmapSessionRecording). +* Get access to all the raw data via MySQL for 100% data ownership. diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Reports/GetRecordedSessions.php b/files/plugin-HeatmapSessionRecording-5.6.0/Reports/GetRecordedSessions.php new file mode 100644 index 0000000..c48e50f --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Reports/GetRecordedSessions.php @@ -0,0 +1,353 @@ +categoryId = 'HeatmapSessionRecording_SessionRecordings'; + $this->name = Piwik::translate('HeatmapSessionRecording_SessionRecordings'); + $this->dimension = null; + $this->documentation = Piwik::translate('HeatmapSessionRecording_ReportRecordedSessionsDocumentation'); + + $this->metrics = array( + 'server_time', + 'idloghsr', + 'time_on_site', + 'nb_pageviews', + 'config_os', + 'config_device_type', + 'config_device_model', + 'config_browser_name', + ); + $this->processedMetrics = array(); + $this->actionToLoadSubTables = $this->action; + + $this->order = 1; + } + + private function getValidator() + { + return StaticContainer::get('Piwik\Plugins\HeatmapSessionRecording\Input\Validator'); + } + + + public function configureView(ViewDataTable $view) + { + $view->requestConfig->filter_limit = 100; + + if ($view->isViewDataTableId(HtmlTable::ID)) { + $view->config->disable_row_evolution = true; + } + + $view->config->documentation = $this->documentation; + + if (!empty($_GET['filter_sort_column'])) { + // because we show html in reporting ui we cannot use processed metrics and we need to map it therefore + // manually + + $paramsToFixSort = array( + 'os' => 'config_os', + 'device' => 'config_device_type', + 'browser' => 'config_browser_name', + 'location' => 'country', + ); + $sort = $_GET['filter_sort_column']; + + if (isset($paramsToFixSort[$sort])) { + $_GET['filter_sort_column'] = $paramsToFixSort[$sort]; + } + $view->config->filters[] = function () use ($view, $sort, $paramsToFixSort) { + if (isset($paramsToFixSort[$sort])) { + // we make sure correct column will be selected (sort icon) + $_GET['filter_sort_column'] = $sort; + return; + } + $key = array_search($sort, $paramsToFixSort); + if ($key !== false) { + $_GET['filter_sort_column'] = $key; + } + }; + } + + if (property_exists($view->config, 'show_totals_row')) { + // since Matomo 3.7 for htmltables + $view->config->show_totals_row = false; + } + + $view->config->show_all_views_icons = false; + $view->config->show_exclude_low_population = false; + $view->config->show_table_all_columns = false; + $view->config->show_table = false; + $view->config->show_flatten_table = false; + $view->config->show_search = false; + $view->config->metrics_documentation['resolution'] = Piwik::translate('HeatmapSessionRecording_ColumnResolutionDocumentation'); + $view->config->addTranslation('resolution', Piwik::translate('Resolution_ColumnResolution')); + $view->config->no_data_message = Piwik::translate(HeatmapSessionRecording::getTranslationKey('noDataSession')); + + $idSubtable = Common::getRequestVar('idSubtable', 0, 'int'); + + $idSite = Common::getRequestVar('idSite', 0, 'int'); + $idSiteHsr = Common::getRequestVar('idSiteHsr', 0, 'int'); + if (!empty($idSite) && !empty($idSiteHsr)) { + $model = StaticContainer::get('Piwik\Plugins\HeatmapSessionRecording\Model\SiteHsrModel'); + $recording = Request::processRequest('HeatmapSessionRecording.getSessionRecording', [ + 'idSite' => $idSite, + 'idSiteHsr' => $idSiteHsr, + ], $default = []); + + if (!empty($recording)) { + $requestDate = $model->getPiwikRequestDate($recording); + + if (!PeriodFactory::isPeriodEnabledForAPI($requestDate['period'])) { + $requestDate['period'] = Common::getRequestVar('period', $requestDate['period'], 'string'); + $requestDate['date'] = Common::getRequestVar('date', $requestDate['date'], 'string'); + } + + // we want to fetch all recordings + $view->requestConfig->request_parameters_to_modify['period'] = $requestDate['period']; + $view->requestConfig->request_parameters_to_modify['date'] = $requestDate['date']; + $view->requestConfig->request_parameters_to_modify['idSiteHsr'] = (int)$idSiteHsr; + + if (empty($idSubtable)) { + if ($this->getValidator()->canWrite($idSite)) { + $view->config->title_edit_entity_url = 'index.php' . Url::getCurrentQueryStringWithParametersModified(array( + 'module' => 'HeatmapSessionRecording', + 'action' => 'manageSessions' + )) . '#?idSiteHsr=' . (int)$idSiteHsr; + } + + $view->config->title = Piwik::translate('HeatmapSessionRecording_SessionRecordingX', '"' . $recording['name'] . '"'); + + if ($recording['status'] == SiteHsrDao::STATUS_ACTIVE) { + $view->config->show_footer_message = Piwik::translate('HeatmapSessionRecording_RecordedSessionsDocStatusActive', array($recording['sample_limit'], $recording['sample_rate'] . '%')); + } elseif ($recording['status'] == SiteHsrDao::STATUS_ENDED) { + $view->config->show_footer_message = Piwik::translate('HeatmapSessionRecording_RecordedSessionsDocStatusEnded'); + } elseif ($recording['status'] == SiteHsrDao::STATUS_ONHOLD) { + if (!empty($recording['scheduled_date_pretty'])) { + $view->config->show_footer_message = Piwik::translate( + 'HeatmapSessionRecording_RecordedSessionsDocStatusOnHoldWithDate', + $recording['scheduled_date_pretty'] + ); + } else { + $view->config->show_footer_message = Piwik::translate('HeatmapSessionRecording_RecordedSessionsDocStatusOnHold'); + } + } + } + } + } + + $report = $this; + + if (empty($idSubtable)) { + $view->config->addTranslation('label', Piwik::translate('HeatmapSessionRecording_ColumnLabelRecordedSessions')); + } else { + $view->config->addTranslation('label', 'URL'); + $view->config->enable_sort = false; + $view->requestConfig->filter_sort_column = 'server_time'; + $view->requestConfig->filter_sort_order = 'asc'; + $_GET['filter_sort_column'] = 'server_time'; + $_GET['filter_sort_order'] = 'asc'; + } + + $view->config->metrics_documentation['nb_pageviews'] = Piwik::translate('HeatmapSessionRecording_ColumnPageviewsDocumentation'); + + $validator = StaticContainer::get('Piwik\Plugins\HeatmapSessionRecording\Input\Validator'); + $view->config->custom_parameters['writeAccess'] = $validator->canWrite($idSite); + + $view->config->filters[] = function (DataTable $table) use ($report, $view, $idSiteHsr, $idSubtable) { + // we need to handle the metrics manually and cannot use processed metrics! + // this is because we "merge" columns in those metrics using arrays (compute() returns sometimes arrays) + // returning arrays in compute() wouldn't work in UI tests so we do it here manually and in API always return + // raw response. This way the API response is also more usable as the HTML those methods return is only + // interesting for us when visualizing it + + if (empty($idSubtable)) { + $metrics = array(new TotalEvents(), new TimeOnSite(), new Location(), new Browser(), + new Device(), new OperatingSystem(), new SessionTime(DateTimeFormatProvider::DATETIME_FORMAT_SHORT)); + } else { + $metrics = array(new TimeOnPage(), new SessionTime(DateTimeFormatProvider::TIME_FORMAT)); + } + + $formatter = new Formatter(); + + foreach ($table->getRowsWithoutSummaryRow() as $row) { + $row->setMetadata('idvisitor', $row->getColumn('idvisitor')); + $row->setMetadata('idsitehsr', $idSiteHsr); + $row->setMetadata('idloghsr', $row->getColumn('idloghsr')); + + if (empty($idSubtable)) { + $row->setMetadata('idvisit', $row->getColumn('idvisit')); + } + + if (!empty($idSubtable)) { + $label = $row->getColumn('label'); + $row->setMetadata('url', $label); + $row->setColumn('label', EnrichRecordedSessions::shortUrl($label)); + } elseif ($row->getColumn('nb_pageviews') == 1) { + $firstUrl = $row->getColumn('first_url'); + $row->setColumn('label', EnrichRecordedSessions::shortUrl($firstUrl)); + $row->setMetadata('url', $firstUrl); + $row->setNonLoadedSubtableId(null); // no need to have it expandable I reckon + } else { + $firstUrl = EnrichRecordedSessions::shortUrl($row->getColumn('first_url')); + $lastUrl = EnrichRecordedSessions::shortUrl($row->getColumn('last_url')); + $row->setColumn('label', $firstUrl . ' → ' . $lastUrl); + } + } + + foreach ($metrics as $processedMetric) { + /** @var BaseMetric $processedMetric */ + $name = $processedMetric->getName(); + $view->config->addTranslation($name, $processedMetric->getTranslatedName()); + + $documentation = $processedMetric->getDocumentation(); + if (!empty($documentation)) { + $view->config->metrics_documentation[$name] = $documentation; + } + + if (!$processedMetric->beforeCompute($report, $table)) { + continue; + } + + $doFormat = $processedMetric->beforeFormat($report, $table); + + foreach ($table->getRowsWithoutSummaryRow() as $row) { + $value = $row->getColumn($name); + if ($value === false) { + // only compute the metric if it has not been computed already + $value = $processedMetric->compute($row); + } + if ($doFormat) { + $value = $processedMetric->format($value, $formatter); + } + + if ($processedMetric->showsHtml() && $value !== false) { + $row->setColumn($name, ' '); + $row->setMetadata('html_column_' . $name . '_prefix', $value); + } else { + $row->setColumn($name, $value); + } + } + } + }; + + if (!empty($idSubtable)) { + $view->config->columns_to_display = array( + 'label', + 'server_time', + 'time_on_page', + 'resolution' + ); + } else { + $systemSettings = StaticContainer::get(SystemSettings::class); + $includedCountries = $systemSettings->getIncludedCountries(); + $headerMessage = ''; + if (!HeatmapSessionRecording::isMatomoJsWritable()) { + $headerMessage .= '
' . + Piwik::translate('HeatmapSessionRecording_MatomoJSNotWritableErrorMessage', [ + Piwik::translate('HeatmapSessionRecording_SessionRecordings'), + '', + '' + ]) . '
'; + } + if (!empty($includedCountries)) { + $headerMessage .= '
' . Piwik::translate('HeatmapSessionRecording_SessionRecordingInfoTrackVisitsFromCountries', [implode(', ', $includedCountries)]) . '
'; + } + $detectAdBlockerView = new View('@HeatmapSessionRecording/_detectAdBlocker'); + $detectAdBlockerView->type = 'Session recordings'; + $headerMessage .= $detectAdBlockerView->render(); + if ($headerMessage) { + $view->config->show_header_message = $headerMessage; + } + + $view->config->columns_to_display = array( + 'label', + 'nb_pageviews', + 'total_events', + 'server_time', + 'time_on_site', + 'location', + 'device', + 'os', + 'browser', + ); + } + } + + public function alwaysUseDefaultViewDataTable() + { + return true; + } + + public function configureWidgets(WidgetsList $widgetsList, ReportWidgetFactory $factory) + { + $idSite = Common::getRequestVar('idSite', $default = 0, 'int'); + + if ($this->getValidator()->canViewSessionReport($idSite)) { + $recordings = Request::processRequest('HeatmapSessionRecording.getSessionRecordings', [ + 'idSite' => $idSite, 'filter_limit' => -1 + ], $default = []); + + foreach ($recordings as $recording) { + $widget = $factory->createWidget(); + $widget->setName(sprintf('Session recording "%s"', $recording['name'])); + $widget->setSubcategoryId($recording['idsitehsr']); + $widget->setIsNotWidgetizable(); + $widget->setParameters(array('idSiteHsr' => $recording['idsitehsr'])); + $widgetsList->addWidgetConfig($widget); + } + } + } + + public function configureReportMetadata(&$availableReports, $infos) + { + // disabled for now + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Settings/DisableHeatmapRecording.php b/files/plugin-HeatmapSessionRecording-5.6.0/Settings/DisableHeatmapRecording.php new file mode 100644 index 0000000..be41196 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Settings/DisableHeatmapRecording.php @@ -0,0 +1,119 @@ + + */ + use PolicyComparisonTrait; + + /** + * @use SystemGetterTrait + */ + use SystemGetterTrait; + + /** + * @var bool + */ + private $value; + + private function __construct(bool $value) + { + $this->value = $value; + } + + public function getValue() + { + return $this->value; + } + + protected static function getSystemDefaultValue() + { + return false; + } + + protected static function getSystemName(): string + { + return 'disableHeatmapRecording'; + } + + protected static function getSystemType(): string + { + return FieldConfig::TYPE_BOOL; + } + + public static function getComplianceRequirementNote(?int $idSite = null): string + { + return Piwik::translate('HeatmapSessionRecording_PolicyHeatmapRequirementNote'); + } + + public static function getInstance(?int $idSite = null) + { + $values = self::getPolicyRequiredValues($idSite); + $values['system'] = self::getSystemValue(); + + $strictest = self::getStrictestValueFromArray($values); + return new self($strictest); + } + + public static function getTitle(): string + { + return Piwik::translate('HeatmapSessionRecording_PolicyHeatmapTitle'); + } + + public static function getInlineHelp(): string + { + return ''; + } + + protected static function compareStrictness($value1, $value2) + { + return $value1 || $value2; + } + + public static function getPolicyRequirements(): array + { + return [ + CnilPolicy::class => true + ]; + } + + public static function isCompliant(string $policy, ?int $idSite = null): bool + { + $policyValues = self::getPolicyRequirements(); + + if (!array_key_exists($policy, $policyValues)) { + return true; + } + + $currentValue = self::getInstance($idSite)->getValue(); + + return $currentValue === $policyValues[$policy]; + } + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Settings/DisableHeatmapRecordingNoInterfaces.php b/files/plugin-HeatmapSessionRecording-5.6.0/Settings/DisableHeatmapRecordingNoInterfaces.php new file mode 100644 index 0000000..86c29c2 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Settings/DisableHeatmapRecordingNoInterfaces.php @@ -0,0 +1,34 @@ +value = $value; + } + + public static function getInstance(?int $idSite = null): self + { + $systemSetting = new SystemSetting( + 'disableHeatmapRecording', + false, + FieldConfig::TYPE_BOOL, + 'HeatmapSessionRecording' + ); + return new self($systemSetting->getValue()); + } + + public function getValue(): bool + { + return $this->value; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Settings/DisableSessionRecording.php b/files/plugin-HeatmapSessionRecording-5.6.0/Settings/DisableSessionRecording.php new file mode 100644 index 0000000..02139b8 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Settings/DisableSessionRecording.php @@ -0,0 +1,119 @@ + + */ + use PolicyComparisonTrait; + + /** + * @use SystemGetterTrait + */ + use SystemGetterTrait; + + /** + * @var bool + */ + private $value; + + private function __construct(bool $value) + { + $this->value = $value; + } + + public function getValue() + { + return $this->value; + } + + protected static function getSystemDefaultValue() + { + return false; + } + + protected static function getSystemName(): string + { + return 'disableSessionRecording'; + } + + protected static function getSystemType(): string + { + return FieldConfig::TYPE_BOOL; + } + + public static function getComplianceRequirementNote(?int $idSite = null): string + { + return Piwik::translate('HeatmapSessionRecording_PolicySessionRequirementNote'); + } + + public static function getInstance(?int $idSite = null) + { + $values = self::getPolicyRequiredValues($idSite); + $values['system'] = self::getSystemValue(); + + $strictest = self::getStrictestValueFromArray($values); + return new self($strictest); + } + + public static function getTitle(): string + { + return Piwik::translate('HeatmapSessionRecording_PolicySessionTitle'); + } + + public static function getInlineHelp(): string + { + return ''; + } + + protected static function compareStrictness($value1, $value2) + { + return $value1 || $value2; + } + + public static function getPolicyRequirements(): array + { + return [ + CnilPolicy::class => true + ]; + } + + public static function isCompliant(string $policy, ?int $idSite = null): bool + { + $policyValues = self::getPolicyRequirements(); + + if (!array_key_exists($policy, $policyValues)) { + return true; + } + + $currentValue = self::getInstance($idSite)->getValue(); + + return $currentValue === $policyValues[$policy]; + } + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Settings/DisableSessionRecordingNoInterfaces.php b/files/plugin-HeatmapSessionRecording-5.6.0/Settings/DisableSessionRecordingNoInterfaces.php new file mode 100644 index 0000000..8ab71c5 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Settings/DisableSessionRecordingNoInterfaces.php @@ -0,0 +1,34 @@ +value = $value; + } + + public static function getInstance(?int $idSite = null): self + { + $systemSetting = new SystemSetting( + 'disableSessionRecording', + false, + FieldConfig::TYPE_BOOL, + 'HeatmapSessionRecording' + ); + return new self($systemSetting->getValue()); + } + + public function getValue(): bool + { + return $this->value; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Settings/TrackingDisableDefault.php b/files/plugin-HeatmapSessionRecording-5.6.0/Settings/TrackingDisableDefault.php new file mode 100644 index 0000000..716a5ed --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Settings/TrackingDisableDefault.php @@ -0,0 +1,36 @@ +oldValue; + } + + public function setValue($value) + { + $this->oldValue = $this->getValue(); + + parent::setValue($value); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/SystemSettings.php b/files/plugin-HeatmapSessionRecording-5.6.0/SystemSettings.php new file mode 100644 index 0000000..bde0d56 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/SystemSettings.php @@ -0,0 +1,326 @@ +breakpointMobile = $this->createBreakpointMobileSetting(); + $this->breakpointTablet = $this->createBreakpointTabletSetting(); + $this->disableTrackingByDefault = $this->createDisableTrackingByDefaultSetting(); + $this->disableSessionRecording = $this->createDisableSessionRecordingSetting(); + $this->disableHeatmapRecording = $this->createDisableHeatmapRecordingSetting(); + $this->includeCountries = $this->createIncludeCountriesSetting(); + + if (Plugin\Manager::getInstance()->isPluginActivated('CustomJsTracker')) { + $trackerUpdater = StaticContainer::get(TrackerUpdater::class); + if (!$trackerUpdater || !$trackerUpdater->getToFile() || !$trackerUpdater->getToFile()->hasWriteAccess()) { + // only works if matomo file can be updated + $this->disableTrackingByDefault->setIsWritableByCurrentUser(false); + } + } else { + $this->disableTrackingByDefault->setIsWritableByCurrentUser(false); + } + } + + private function createDisableTrackingByDefaultSetting() + { + $setting = new TrackingDisableDefault('trackingDisabledDefault', false, FieldConfig::TYPE_BOOL, $this->pluginName); + $setting->setConfigureCallback(function (FieldConfig $field) { + $field->title = Piwik::translate('HeatmapSessionRecording_TrackingDisabledDefaultSettingTitle'); + $field->uiControl = FieldConfig::UI_CONTROL_CHECKBOX; + $field->description = Piwik::translate('HeatmapSessionRecording_TrackingDisabledDefaultSettingDescription'); + }); + $this->addSetting($setting); + return $setting; + } + + private function createBreakpointMobileSetting() + { + return $this->makeSetting('breakpointMobile', Breakpoint::DEFAULT_MOBILE, FieldConfig::TYPE_INT, function (FieldConfig $field) { + $field->title = Piwik::translate('HeatmapSessionRecording_BreakpointX', Piwik::translate('General_Mobile')); + $field->uiControl = FieldConfig::UI_CONTROL_TEXT; + $field->description = Piwik::translate('HeatmapSessionRecording_BreakpointGeneralHelp'); + $field->validate = function ($value) { + $breakpoint = new Breakpoint($value, Piwik::translate('General_Mobile')); + $breakpoint->check(); + }; + }); + } + + private function createBreakpointTabletSetting() + { + return $this->makeSetting('breakpointTablet', Breakpoint::DEFAULT_TABLET, FieldConfig::TYPE_INT, function (FieldConfig $field) { + $field->title = Piwik::translate('HeatmapSessionRecording_BreakpointX', Piwik::translate('DevicesDetection_Tablet')); + $field->uiControl = FieldConfig::UI_CONTROL_TEXT; + $field->description = Piwik::translate('HeatmapSessionRecording_BreakpointGeneralHelp'); + $field->validate = function ($value) { + $breakpoint = new Breakpoint($value, Piwik::translate('DevicesDetection_Tablet')); + $breakpoint->check(); + }; + }); + } + + private function createDisableSessionRecordingSetting() + { + $setting = new TrackingDisableDefault('disableSessionRecording', false, FieldConfig::TYPE_BOOL, $this->pluginName); + $setting->setConfigureCallback(function (FieldConfig $field) { + $field->title = Piwik::translate('HeatmapSessionRecording_DisableSessionRecordingTitle', Piwik::translate('General_Mobile')); + $field->description = Piwik::translate('HeatmapSessionRecording_DisableSessionRecordingDescription'); + $field->inlineHelp = Piwik::translate('HeatmapSessionRecording_DisableSessionRecordingInlineHelp', array('','')); + }); + $this->addSetting($setting); + + return $setting; + } + + private function createDisableHeatmapRecordingSetting() + { + $setting = new TrackingDisableDefault('disableHeatmapRecording', false, FieldConfig::TYPE_BOOL, $this->pluginName); + $setting->setConfigureCallback(function (FieldConfig $field) { + $field->title = Piwik::translate('HeatmapSessionRecording_DisableHeatmapRecordingTitle', Piwik::translate('General_Mobile')); + $field->description = Piwik::translate('HeatmapSessionRecording_DisableHeatmapRecordingDescription'); + $field->inlineHelp = Piwik::translate('HeatmapSessionRecording_DisableHeatmapRecordingInlineHelp', array('','')); + }); + $this->addSetting($setting); + + return $setting; + } + + public function createIncludeCountriesSetting() + { + return $this->makeSetting('included_countries', [], FieldConfig::TYPE_ARRAY, function (FieldConfig $field) { + $field->title = Piwik::translate('HeatmapSessionRecording_EnableIncludeCountriesTitle'); + $field->description = Piwik::translate('HeatmapSessionRecording_EnableIncludeCountriesDescription'); + $field->uiControl = FieldConfig::UI_CONTROL_MULTI_TUPLE; + $field1 = new FieldConfig\MultiPair(Piwik::translate('HeatmapSessionRecording_Country'), 'country', FieldConfig::UI_CONTROL_SINGLE_SELECT); + $field1->availableValues = $this->getAvailableCountries(); + $field->uiControlAttributes['field1'] = $field1->toArray(); + + $self = $this; + $field->transform = function ($value) use ($self) { + return $self->transformCountryList($value); + }; + $field->validate = function ($value) use ($field1) { + foreach ($value as $country) { + if (empty($country['country'])) { + continue; + } + if ($country['country'] === 'xx') { + continue; // valid, country not detected + } + if (!isset($field1->availableValues[$country['country']])) { + throw new \Exception('Invalid country code'); + } + } + }; + }); + } + + public function transformCountryList($value) + { + if (!empty($value) && is_array($value)) { + $newVal = []; + foreach ($value as $index => $val) { + if (empty($val['country'])) { + continue; + } + $newVal[] = ['country' => $val['country']]; + } + return $newVal; + } + return $value; + } + + public function save() + { + $this->endSessionRecordings(); + $this->endHeatmapRecordings(); + parent::save(); + + if (!empty($this->disableTrackingByDefault)) { + $oldValue = $this->disableTrackingByDefault->getOldValue(); + $newValue = $this->disableTrackingByDefault->getValue(); + if ($oldValue != $newValue) { + $plugin = Plugin\Manager::getInstance()->getLoadedPlugin($this->pluginName); + if (!empty($plugin) && $plugin instanceof HeatmapSessionRecording) { + $plugin->updatePiwikTracker(); + } + } + } + } + + public function getIncludedCountries($applyTransformation = true) + { + $includedCountries = $this->includeCountries->getValue(); + $transformedList = []; + + if (!empty($includedCountries)) { + foreach ($includedCountries as $value) { + $transformedList[] = $value['country']; + } + } + + if (!empty($transformedList) && $applyTransformation) { + $transformedList = $this->getAvailableCountries($transformedList); + } + + return $transformedList; + } + + private function getAvailableCountries($countryCodesToFilter = []) + { + $regionDataProvider = StaticContainer::get(RegionDataProvider::class); + $countryList = $regionDataProvider->getCountryList(); + array_walk($countryList, function (&$item, $key) { + $item = Piwik::translate('Intl_Country_' . strtoupper($key)); + }); + asort($countryList); //order by localized name + + if (!empty($countryCodesToFilter)) { + $filteredList = []; + foreach ($countryList as $countryCode => $countryName) { + if (in_array($countryCode, $countryCodesToFilter)) { + $filteredList[$countryCode] = $countryName; + } + } + + return $filteredList; + } + + return $countryList; + } + + private function endSessionRecordings() + { + $settingValue = $this->disableSessionRecording->getValue(); + try { + $featureFlagManager = StaticContainer::get(FeatureFlagManager::class); + if ($featureFlagManager->isFeatureActive('Piwik\Plugins\PrivacyManager\FeatureFlags\PrivacyCompliance')) { + $settingValue = DisableSessionRecording::getInstance()->getValue(); + } + } catch (NotFoundException $e) { + /* + if the feature flag manager cannot be loaded, + just move on to backup/original setting + value retrieval + */ + } + if ( + !empty($settingValue) && + $this->disableSessionRecording->getOldValue() != $settingValue + ) { + $sites = SitesManagerAPI::getInstance()->getAllSitesId(); + $this->disableSessionRecording->setValue(false); //added this to fetch results, else it throws an exception + foreach ($sites as $idSite) { + if (Site::getTypeFor($idSite) === 'proxysite') { + continue; //do not delete session recording for proxySite as it will throw an exception due to read only behaviour + } + $recordings = Request::processRequest('HeatmapSessionRecording.getSessionRecordings', [ + 'idSite' => $idSite, 'filter_limit' => -1 + ]); + foreach ($recordings as $recording) { + Request::processRequest('HeatmapSessionRecording.deleteSessionRecording', [ + 'idSite' => $idSite, + 'idSiteHsr' => $recording['idsitehsr'] + ]); + } + } + + $this->disableSessionRecording->setValue(true); + Cache::deleteTrackerCache(); + } + } + + private function endHeatmapRecordings() + { + $settingValue = $this->disableHeatmapRecording->getValue(); + try { + $featureFlagManager = StaticContainer::get(FeatureFlagManager::class); + if ($featureFlagManager->isFeatureActive('Piwik\Plugins\PrivacyManager\FeatureFlags\PrivacyCompliance')) { + $settingValue = DisableHeatmapRecording::getInstance()->getValue(); + } + } catch (NotFoundException $e) { + /* + if the feature flag manager cannot be loaded, + just move on to backup/original setting + value retrieval + */ + } + if ( + !empty($settingValue) && + $this->disableHeatmapRecording->getOldValue() != $settingValue + ) { + $sites = SitesManagerAPI::getInstance()->getAllSitesId(); + $this->disableHeatmapRecording->setValue(false); //added this to fetch results, else it throws an exception + foreach ($sites as $idSite) { + if (Site::getTypeFor($idSite) === 'proxysite') { + continue; //do not delete heatmap for proxySite as it will throw an exception due to read only behaviour + } + $recordings = Request::processRequest('HeatmapSessionRecording.getHeatmaps', [ + 'idSite' => $idSite, 'filter_limit' => -1 + ]); + foreach ($recordings as $recording) { + Request::processRequest('HeatmapSessionRecording.deleteHeatmap', [ + 'idSite' => $idSite, + 'idSiteHsr' => $recording['idsitehsr'] + ]); + } + } + + $this->disableHeatmapRecording->setValue(true); + Cache::deleteTrackerCache(); + } + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Tasks.php b/files/plugin-HeatmapSessionRecording-5.6.0/Tasks.php new file mode 100644 index 0000000..107a90e --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Tasks.php @@ -0,0 +1,370 @@ +daily('removeDeletedRecordings'); + + // we are doing this rarely to avoid removing actions that might be used eg a day later or so again + $this->monthly('removeUnusedHsrBlobs'); + + $this->hourly('notifyFinishedRecordings'); + + $this->daily('createRepeatHeatmaps'); + + $this->hourly('activateOnHoldHeatmaps'); + + $this->daily('createRepeatSessionRecordings'); + + $this->hourly('activateOnHoldSessionRecordings'); + } + + public function __construct(LogHsr $logHsr, LogHsrSite $logHsrSite, LogHsrBlob $logHsrBlob, SiteHsrDao $siteHsrDao, SiteHsrModel $siteHsrModel) + { + $this->logHsr = $logHsr; + $this->logHsrSite = $logHsrSite; + $this->logHsrBlob = $logHsrBlob; + $this->siteHsrDao = $siteHsrDao; + $this->siteHsrModel = $siteHsrModel; + } + + /** + * To test execute the following command: + * `./console core:run-scheduled-tasks "Piwik\Plugins\HeatmapSessionRecording\Tasks.removeDeletedRecordings"` + * + * @throws \Exception + */ + public function removeDeletedRecordings() + { + $this->logHsrSite->deleteNoLongerNeededRecords(); + + $idLogHsrsToDelete = $this->logHsr->findDeletedLogHsrIds(); + $this->logHsr->deleteIdLogHsrsFromAllTables($idLogHsrsToDelete); + } + + /** + * To test execute the following command: + * `./console core:run-scheduled-tasks "Piwik\Plugins\HeatmapSessionRecording\Tasks.removeDeletedHsrBlobs"` + */ + public function removeUnusedHsrBlobs() + { + $this->logHsrBlob->deleteUnusedBlobEntries(); + } + + /** + * To test execute the following command: + * `./console core:run-scheduled-tasks "Piwik\Plugins\HeatmapSessionRecording\Tasks.notifyFinishedRecordings"` + */ + public function notifyFinishedRecordings() + { + Access::doAsSuperUser(function () { + $records = $this->siteHsrDao->getFinishedRecordsPendingNotification(); + + foreach ($records as $record) { + try { + $this->notifyFinishedRecord($record); + } catch (\Throwable $e) { + StaticContainer::get(\Piwik\Log\LoggerInterface::class)->error( + 'Failed to send finished recording notification for idsitehsr {idsitehsr}: {message}', + array( + 'idsitehsr' => isset($record['idsitehsr']) ? $record['idsitehsr'] : null, + 'message' => $e->getMessage(), + 'exception' => $e, + ) + ); + } + } + }); + } + + /** + * To test execute the following command: + * `./console core:run-scheduled-tasks "Piwik\Plugins\HeatmapSessionRecording\Tasks.createRepeatHeatmaps"` + */ + public function createRepeatHeatmaps() + { + $this->createRepeatRecords(SiteHsrDao::RECORD_TYPE_HEATMAP, 'heatmap', 'HeatmapSessionRecording.createRepeatHeatmap', array($this->siteHsrModel, 'createRepeatCopy')); + } + + /** + * To test execute the following command: + * `./console core:run-scheduled-tasks "Piwik\Plugins\HeatmapSessionRecording\Tasks.activateOnHoldHeatmaps"` + */ + public function activateOnHoldHeatmaps() + { + $this->activateOnHoldRecords(SiteHsrDao::RECORD_TYPE_HEATMAP, 'heatmap', 'HeatmapSessionRecording.activateRepeatHeatmap', array($this->siteHsrModel, 'activateOnHoldHeatmap')); + } + + /** + * To test execute the following command: + * `./console core:run-scheduled-tasks "Piwik\Plugins\HeatmapSessionRecording\Tasks.createRepeatSessionRecordings"` + */ + public function createRepeatSessionRecordings() + { + $this->createRepeatRecords(SiteHsrDao::RECORD_TYPE_SESSION, 'session recording', 'HeatmapSessionRecording.createRepeatSessionRecording', array($this->siteHsrModel, 'createSessionRepeatCopy')); + } + + /** + * To test execute the following command: + * `./console core:run-scheduled-tasks "Piwik\Plugins\HeatmapSessionRecording\Tasks.activateOnHoldSessionRecordings"` + */ + public function activateOnHoldSessionRecordings() + { + $this->activateOnHoldRecords(SiteHsrDao::RECORD_TYPE_SESSION, 'session recording', 'HeatmapSessionRecording.activateRepeatSessionRecording', array($this->siteHsrModel, 'activateOnHoldSessionRecording')); + } + + /** + * @param int $recordType + * @param string $label Entity wording used in the log messages, eg `heatmap` + * @param string $eventName + * @param callable $createCopy Receives `$idSite` and `$idSiteHsr` and returns the new idsitehsr, or null + */ + private function createRepeatRecords($recordType, $label, $eventName, $createCopy) + { + Access::doAsSuperUser(function () use ($recordType, $label, $eventName, $createCopy) { + $records = $this->siteHsrDao->getRecordsPendingRepeat($recordType); + + foreach ($records as $record) { + $idSite = (int) $record['idsite']; + $idSiteHsr = (int) $record['idsitehsr']; + $name = isset($record['name']) ? $record['name'] : ''; + + try { + $this->checkRepeatAllowed($idSite, $idSiteHsr, $recordType, $eventName); + + if (null === $createCopy($idSite, $idSiteHsr)) { + StaticContainer::get(\Piwik\Log\LoggerInterface::class)->info( + 'Skipped creating repeat ' . $label . ' copy for idsitehsr {idsitehsr} "{name}" (idsite {idsite}) as it is no longer ended or no longer set to repeat', + array( + 'idsitehsr' => $idSiteHsr, + 'idsite' => $idSite, + 'name' => $name, + ) + ); + } + } catch (\Throwable $e) { + if ($e instanceof \Exception && Db::get()->isErrNo($e, MigrationDb::ERROR_CODE_DUPLICATE_ENTRY)) { + // another run already created the copy for this record + continue; + } + + StaticContainer::get(\Piwik\Log\LoggerInterface::class)->error( + 'Failed to create repeat ' . $label . ' copy for idsitehsr {idsitehsr} "{name}" (idsite {idsite}). It will be retried on the next run: {message}', + array( + 'idsitehsr' => $idSiteHsr, + 'idsite' => $idSite, + 'name' => $name, + 'message' => $e->getMessage(), + 'exception' => $e, + ) + ); + } + } + }); + } + + /** + * @param int $recordType + * @param string $label Entity wording used in the log messages, eg `heatmap` + * @param string $eventName + * @param callable $activate Receives `$idSite` and `$idSiteHsr` and returns whether the record was activated + */ + private function activateOnHoldRecords($recordType, $label, $eventName, $activate) + { + Access::doAsSuperUser(function () use ($recordType, $label, $eventName, $activate) { + $records = $this->siteHsrDao->getOnHoldRecordsToActivate($recordType); + + foreach ($records as $record) { + $idSite = (int) $record['idsite']; + $idSiteHsr = (int) $record['idsitehsr']; + + try { + $this->checkRepeatAllowed($idSite, $idSiteHsr, $recordType, $eventName); + + if (!$activate($idSite, $idSiteHsr)) { + StaticContainer::get(\Piwik\Log\LoggerInterface::class)->info( + 'Skipped activating on-hold ' . $label . ' for idsitehsr {idsitehsr} (idsite {idsite}) as it is no longer on hold or no longer due', + array( + 'idsitehsr' => $idSiteHsr, + 'idsite' => $idSite, + ) + ); + } + } catch (\Throwable $e) { + StaticContainer::get(\Piwik\Log\LoggerInterface::class)->error( + 'Failed to activate on-hold ' . $label . ' for idsitehsr {idsitehsr} (idsite {idsite}): {message}', + array( + 'idsitehsr' => $idSiteHsr, + 'idsite' => $idSite, + 'message' => $e->getMessage(), + 'exception' => $e, + ) + ); + } + } + }); + } + + /** + * Throws when a repeat record must not be created or activated for the given site. + * + * The event lets other plugins apply the feature and quota checks they apply to + * `API.HeatmapSessionRecording.addHeatmap`, which these scheduled tasks do not go through. + * Heatmaps and session recordings post distinct events so each is checked against its own quota. + * + * @param int $idSite + * @param int $idSiteHsr + * @param int $recordType + * @param string $eventName + * @throws \Exception + */ + private function checkRepeatAllowed($idSite, $idSiteHsr, $recordType, $eventName) + { + if ($recordType === SiteHsrDao::RECORD_TYPE_HEATMAP) { + StaticContainer::get(Validator::class)->checkHeatmapRecordingEnabled($idSite); + } else { + StaticContainer::get(Validator::class)->checkSessionRecordingEnabled($idSite); + } + + /** + * Triggered before a scheduled task creates or activates the repeat copy of a heatmap or a session + * recording that has automatic repeating enabled. + * + * Depending on which task is running, `$eventName` is one of + * `HeatmapSessionRecording.createRepeatHeatmap`, `HeatmapSessionRecording.activateRepeatHeatmap`, + * `HeatmapSessionRecording.createRepeatSessionRecording` or + * `HeatmapSessionRecording.activateRepeatSessionRecording`. Heatmaps and session recordings post + * distinct events so each can be checked against its own quota. + * + * These tasks do not go through `API.HeatmapSessionRecording.addHeatmap` or + * `API.HeatmapSessionRecording.addSessionRecording`, so throwing an exception in an observer is the + * way to apply the same feature and quota checks. When an observer throws, the copy is not created + * or activated and the source is retried on the next run. + * + * **Example** + * + * Piwik::addAction('HeatmapSessionRecording.createRepeatSessionRecording', function ($idSite, $idSiteHsr) { + * if (myQuotaIsReached($idSite)) { + * throw new \Exception('Session recording quota reached'); + * } + * }); + * + * @param int $idSite The ID of the site the repeating heatmap or session recording belongs to. + * @param int $idSiteHsr The ID of the source heatmap or session recording that is repeating. + */ + Piwik::postEvent($eventName, array($idSite, $idSiteHsr)); + } + + private function notifyFinishedRecord(array $record) + { + $login = $record['login']; + $idSite = (int) $record['idsite']; + $idSiteHsr = (int) $record['idsitehsr']; + $recordType = (int) $record['record_type']; + $name = $record['name']; + + // Claim immediately so the record is never reprocessed, even if we end up skipping or failing below. + $this->siteHsrDao->markNotified($idSiteHsr); + + $userModel = new Model(); + $user = $userModel->getUser($login); + + if (empty($user) || empty($user['email'])) { + return; + } + + $hasAccess = !empty($user['superuser_access']); + if (!$hasAccess) { + $access = $userModel->getUsersAccessFromSite($idSite); + $hasAccess = array_key_exists($login, $access); + } + + if (!$hasAccess) { + return; + } + + $setting = new UserSetting('notifyFinishedRecordings', true, FieldConfig::TYPE_BOOL, 'HeatmapSessionRecording', $login); + if (!$setting->getValue()) { + return; + } + + $language = (new LanguagesManagerModel())->getLanguageForUser($login); + + /** @var Translator $translator */ + $translator = StaticContainer::get('Piwik\Translation\Translator'); + + if (empty($language)) { + $language = $translator->getDefaultLanguage(); + } + + $translator->setCurrentLanguage($language); + + if ($recordType === SiteHsrDao::RECORD_TYPE_HEATMAP) { + $mail = new HeatmapFinishedEmail($user['email'], $name, $idSite, $idSiteHsr, $login); + } else { + $mail = new SessionRecordingFinishedEmail($user['email'], $name, $idSite, $idSiteHsr, $login); + } + $mail->safeSend(); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Tracker/Configs.php b/files/plugin-HeatmapSessionRecording-5.6.0/Tracker/Configs.php new file mode 100644 index 0000000..d1cc86c --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Tracker/Configs.php @@ -0,0 +1,160 @@ + $validator + )); + try { + $environment->init(); + } catch (NotYetInstalledException $e) { + http_response_code(403); + exit; + } catch (Exception $e) { + if (!\Piwik\SettingsPiwik::isMatomoInstalled()) { + // the validator is disabled for performance so we would not detect a not installed matomo + http_response_code(403); + exit; + } + throw $e; + } + } + + public function sendResponse($response, $originalIdSite, $trackerId) + { + $response['idsite'] = $originalIdSite; + $response['trackerid'] = $trackerId; + header('Content-Type: application/javascript'); + echo 'Piwik.HeatmapSessionRecording.configuration.assign(' . json_encode($response) . ');'; + } + + public function loadTrackerEnvironment() + { + if (!$this->hasLoadedTrackerEnvironment) { + $this->hasLoadedTrackerEnvironment = true; + try { + Tracker::loadTrackerEnvironment(); + $GLOBALS['PIWIK_TRACKER_DEBUG'] = false; + } catch (Exception $e) { + $GLOBALS['PIWIK_TRACKER_DEBUG'] = false; + if (!\Piwik\SettingsPiwik::isMatomoInstalled()) { + // the validator is disabled for performance so we would not detect a not installed matomo + http_response_code(403); + exit; + } + throw $e; + } + } + } + + private function isNumber($value) + { + return preg_match('/^\d+$/', (string) $value); + } + + public function getIdSite($idSite) + { + if (is_string($idSite) && !$this->isNumber($idSite)) { + $this->loadTrackerEnvironment(); + + /** + * Triggered when obtaining the ID of the site we are tracking a visit for. + * + * This event can be used to change the site ID so data is tracked for a different + * website. + * + * @param int &$idSite Initialized to the value of the **idsite** query parameter. If a + * subscriber sets this variable, the value it uses must be greater + * than 0. + * @param array $params The entire array of request parameters in the current tracking + * request. + */ + \Piwik\Piwik::postEvent('Tracker.Request.getIdSite', array(&$idSite, array_merge($_GET, $_POST))); + + if (!$this->isNumber($idSite)) { + // the string idsite was not converted to an integer / number... + // we do not cast to int as it could result in using the wrong idsite when eg string is '5Tk3k' => could result + // in idsite=5 which may not be the correct idsite + http_response_code(400); + exit; + } + } + + $idSite = (int) abs($idSite); + return $idSite; + } + + public function getCachedWebsiteAttributes($idSite) + { + try { + $cache = Tracker\Cache::getCacheWebsiteAttributes($idSite); + } catch (\Piwik\Exception\UnexpectedWebsiteFoundException $exception) { + http_response_code(400); + exit; + } catch (Exception $e) { + if (!\Piwik\SettingsPiwik::isMatomoInstalled()) { + // the validator is disabled for performance so we would not detect a not installed matomo + http_response_code(403); + exit; + } + throw $e; + } + + if (empty($cache)) { + ob_start(); + // this is a performance improvement to bootstrap the plugins only if the cache does not exist right now + $this->loadTrackerEnvironment(); + + try { + $cache = Tracker\Cache::getCacheWebsiteAttributes($idSite); + } catch (\Piwik\Exception\UnexpectedWebsiteFoundException $exception) { + http_response_code(400); + exit; + } catch (Exception $e) { + if (!\Piwik\SettingsPiwik::isMatomoInstalled()) { + // the validator is disabled for performance so we would not detect a not installed matomo + http_response_code(403); + exit; + } + throw $e; + } + ob_end_clean(); + } + + return $cache; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Tracker/HsrMatcher.php b/files/plugin-HeatmapSessionRecording-5.6.0/Tracker/HsrMatcher.php new file mode 100644 index 0000000..ac70179 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Tracker/HsrMatcher.php @@ -0,0 +1,224 @@ +fetch($samplesKey); + + if ($hsr['record_type'] == SiteHsrDao::RECORD_TYPE_HEATMAP) { + if (empty($numSamples)) { + $numSamples = $logHsrSite->getNumPageViews($idSiteHsr); + $lazyCache->save($samplesKey, $numSamples, $ttlInSec = 300); + } + + if ($numSamples >= $hsr['sample_limit']) { + $siteHsr = \Piwik\Container\StaticContainer::get('Piwik\Plugins\HeatmapSessionRecording\Model\SiteHsrModel'); + $siteHsr->endHeatmap($idSite, $idSiteHsr); + // we make sure in the future the completed HSR won't receive any recordings anymore afterwards + Cache::deleteCacheWebsiteAttributes($idSite); + + return false; + } + } elseif ($hsr['record_type'] == SiteHsrDao::RECORD_TYPE_SESSION) { + if (empty($numSamples)) { + $numSamples = $logHsrSite->getNumSessions($idSiteHsr); + $lazyCache->save($samplesKey, $numSamples, $ttlInSec = 300); + } + + if ($numSamples >= $hsr['sample_limit']) { + $siteHsr = \Piwik\Container\StaticContainer::get('Piwik\Plugins\HeatmapSessionRecording\Model\SiteHsrModel'); + + $siteHsr->endSessionRecording($idSite, $idSiteHsr); + // we make sure in the future the completed HSR won't receive any recordings anymore afterwards + Cache::deleteCacheWebsiteAttributes($idSite); + + return false; + } + } + + return true; + } + + public static function makeCookieSampleKey($idSiteHsr) + { + return self::QUERY_NAMESPACE . (int) $idSiteHsr; + } + + public static function isUserPartOfSampleGroup($recordType, $idSiteHsr) + { + if ($recordType == SiteHsrDao::RECORD_TYPE_SESSION) { + $key = self::makeCookieSampleKey($idSiteHsr); + if (isset($_GET[$key]) && $_GET[$key] == '1') { + return self::SAMPLE_GROUP_IS_PART; + } elseif (isset($_GET[$key]) && $_GET[$key] == '0') { + return self::SAMPLE_GROUP_NOT_PART; + } + } + + return self::SAMPLE_GROUP_TO_BE_DETECTED; + } + + /** + * @param $matchPageRules + * @param $url + * @return bool + */ + public static function matchesAllPageRules($matchPageRules, $url) + { + if (empty($matchPageRules)) { + // no restrictions, we track any page + return true; + } + + if (is_array($matchPageRules)) { + // ALL RULES NEED TO MATCH! + + foreach ($matchPageRules as $pageRule) { + $pageRule = new PageRuleMatcher($pageRule); + if (!$pageRule->matches($url)) { + return false; + } + } + + return true; + } + + return false; + } + + public static function isIncludedCountry($includedCountries) + { + if (empty($includedCountries)) { + return true; + } + $ipAddress = IP::getIpFromHeader(); + $browserLanguage = Common::getBrowserLanguage(); + + if (defined('PIWIK_TEST_MODE') && PIWIK_TEST_MODE) { + //Need to use MockLocationProvider else it will throw an error, need to do it here instead of test as `configs.php` is called via HTTP call + $visitorLocator = new MockLocationProvider(); + } else { + $visitorLocator = new VisitorGeolocator(); + } + + $info = array('lang' => $browserLanguage, 'ip' => $ipAddress); + + $provider = $visitorLocator->getProvider(); + if ($provider === null) { + // Try even without a provider because something might be cached + try { + $visitorLocator = $visitorLocator->getLocation($info, $useClassCache = true); + } catch (\Throwable $throwable) { + return false; + } + } else { + $visitorLocator = $visitorLocator->getLocation($info, $useClassCache = true); + } + + if (empty($visitorLocator[LocationProvider::COUNTRY_CODE_KEY])) { + return false; + } + + $countryCode = strtolower($visitorLocator[LocationProvider::COUNTRY_CODE_KEY]); + + if (in_array($countryCode, $includedCountries, true)) { + return true; + } + + return false; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Tracker/LogTable/LogHsr.php b/files/plugin-HeatmapSessionRecording-5.6.0/Tracker/LogTable/LogHsr.php new file mode 100644 index 0000000..eb8a31c --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Tracker/LogTable/LogHsr.php @@ -0,0 +1,42 @@ + 'idhsrblob'); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Tracker/LogTable/LogHsrEvent.php b/files/plugin-HeatmapSessionRecording-5.6.0/Tracker/LogTable/LogHsrEvent.php new file mode 100644 index 0000000..b011f0d --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Tracker/LogTable/LogHsrEvent.php @@ -0,0 +1,37 @@ + 'idloghsr'); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Tracker/PageRuleMatcher.php b/files/plugin-HeatmapSessionRecording-5.6.0/Tracker/PageRuleMatcher.php new file mode 100644 index 0000000..427d870 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Tracker/PageRuleMatcher.php @@ -0,0 +1,305 @@ +pageRule = $pageRule; + } + + /** + * Check if the experiment matches the given target. + * + * @param string $url + * @return bool + * @throws \Exception + */ + public function matches($url) + { + if (empty($this->pageRule['type']) || empty($this->pageRule['attribute'])) { + return true; + } + + $attributeValue = $this->getValueForAttribute($url); + + switch (strtolower($this->pageRule['attribute'])) { + case self::ATTRIBUTE_URL: + case self::ATTRIBUTE_PATH: + return $this->matchesTargetValue($attributeValue, $this->pageRule['type'], $this->pageRule['inverted'], $this->pageRule['value']); + case self::ATTRIBUTE_URLPARAM: + $value2 = null; + if (isset($this->pageRule['value2'])) { + $value2 = $this->pageRule['value2']; + } + + if (is_array($attributeValue)) { + // eg when url param is `&foo=bar&foo=baz` and trying to match foo url param + if ($this->pageRule['inverted']) { + $matchesOne = true; + foreach ($attributeValue as $attrVal) { + $matchesOne = $matchesOne && $this->matchesTargetValue($attrVal, $this->pageRule['type'], $this->pageRule['inverted'], $value2); + } + return $matchesOne; + } else { + $matchesOne = false; + foreach ($attributeValue as $attrVal) { + $matchesOne = $matchesOne || $this->matchesTargetValue($attrVal, $this->pageRule['type'], $this->pageRule['inverted'], $value2); + } + return $matchesOne; + } + } + return $this->matchesTargetValue($attributeValue, $this->pageRule['type'], $this->pageRule['inverted'], $value2); + } + + return false; + } + + protected function getValueForAttribute($url) + { + if (!empty($url) && is_string($url)) { + $enabledForceSample = HeatmapSessionRecording::ULR_PARAM_FORCE_SAMPLE . '=1'; + $disabledForceSample = HeatmapSessionRecording::ULR_PARAM_FORCE_SAMPLE . '=0'; + $enabledForceScreen = HeatmapSessionRecording::ULR_PARAM_FORCE_CAPTURE_SCREEN . '=1'; + $disabledForceScreen = HeatmapSessionRecording::ULR_PARAM_FORCE_CAPTURE_SCREEN . '=0'; + + $url = str_replace(array( + '?' . $enabledForceSample, + '?' . $disabledForceSample, + '?' . $enabledForceScreen, + '?' . $disabledForceScreen + ), '?', $url); + $url = rtrim($url, '?'); // eg when https://www.example.com?pk_hsr_forcesample=1 + $url = str_replace(array( + '&' . $enabledForceSample, + '&' . $disabledForceSample, + '&' . $enabledForceScreen, + '&' . $disabledForceScreen + ), '', $url); + } + + switch (strtolower($this->pageRule['attribute'])) { + case self::ATTRIBUTE_URL: + return $url; + case self::ATTRIBUTE_PATH: + $urlParsed = parse_url($url); + if (isset($urlParsed['path'])) { + return $urlParsed['path']; + } + return ''; + case self::ATTRIBUTE_URLPARAM: + $urlParsed = parse_url($url); + $targetValue = null; + if (!empty($urlParsed['query']) && !empty($this->pageRule['value'])) { + $paramName = $this->pageRule['value']; + $params = UrlHelper::getArrayFromQueryString($urlParsed['query']); + if (isset($params[$paramName])) { + $targetValue = $params[$paramName]; + } + } + return $targetValue; + } + } + + private function removeWwwSubdomain($host) + { + return str_replace('www.', '', $host); + } + + protected function matchesTargetValue($attributeValue, $type, $invert, $valueToMatch) + { + $matches = false; + + if (is_string($attributeValue)) { + $attributeValue = strtolower($attributeValue); + } + + if (is_string($valueToMatch) && $type !== 'regexp') { + $valueToMatch = strtolower($valueToMatch); + } + + switch ($type) { + case self::TYPE_ANY: + $matches = true; + break; + case self::TYPE_EXISTS: + if ($attributeValue !== null) { + $matches = true; + } + break; + case self::TYPE_EQUALS_SIMPLE: + $parsedActual = parse_url($attributeValue); + $parsedMatch = parse_url($valueToMatch); + + if (isset($parsedActual['host'])) { + $parsedActual['host'] = $this->removeWwwSubdomain($parsedActual['host']); + } + if (isset($parsedMatch['host'])) { + $parsedMatch['host'] = $this->removeWwwSubdomain($parsedMatch['host']); + } + + if (!isset($parsedMatch['host']) || !isset($parsedActual['host']) || $parsedActual['host'] == $parsedMatch['host']) { + if (!isset($parsedActual['path']) && !isset($parsedMatch['path'])) { + $matches = true; + } elseif (isset($parsedActual['path']) && isset($parsedMatch['path'])) { + if ( + $parsedActual['path'] == $parsedMatch['path'] || + $parsedActual['path'] == $parsedMatch['path'] . '/' || + $parsedActual['path'] == '/' . $parsedMatch['path'] || + $parsedActual['path'] == '/' . $parsedMatch['path'] . '/' || + $parsedActual['path'] . '/' == $parsedMatch['path'] + ) { + $matches = true; + } + } elseif (isset($parsedActual['path']) && $parsedActual['path'] === '/' && !isset($parsedMatch['path'])) { + $matches = true; + } elseif (isset($parsedMatch['path']) && $parsedMatch['path'] === '/' && !isset($parsedActual['path'])) { + $matches = true; + } + } + + break; + case self::TYPE_EQUALS_EXACTLY: + if ($attributeValue && $attributeValue === $valueToMatch) { + $matches = true; + } + + if ($valueToMatch && @parse_url($valueToMatch, PHP_URL_PATH) === '/' && $valueToMatch === ($attributeValue . '/')) { + $matches = true; + } + + if ($attributeValue && @parse_url($attributeValue, PHP_URL_PATH) === '/' && $attributeValue === ($valueToMatch . '/')) { + $matches = true; + } + + break; + case self::TYPE_CONTAINS: + if (isset($valueToMatch) && $attributeValue && !is_array($valueToMatch) && !is_object($valueToMatch) && strlen($valueToMatch) && strpos($attributeValue, (string) $valueToMatch) !== false) { + $matches = true; + } + break; + case self::TYPE_STARTS_WITH: + if ($attributeValue && !is_array($valueToMatch) && !is_object($valueToMatch) && strlen($valueToMatch) && strpos($attributeValue, (string) $valueToMatch) === 0) { + $matches = true; + } + break; + case self::TYPE_REGEXP: + $pattern = self::completeRegexpPattern($valueToMatch); + if ($pattern && $attributeValue && preg_match($pattern, $attributeValue)) { + $matches = true; + } + break; + } + + if ($invert) { + return !$matches; + } + + return $matches; + } + + public static function completeRegexpPattern($pattern) + { + return $pattern ? ('/' . str_replace('/', '\/', stripslashes($pattern)) . '/i') : $pattern; + } + + public static function doesTargetTypeRequireValue($type) + { + return $type !== self::TYPE_ANY; + } + + public static function getAvailableTargetTypes() + { + $targetTypes = array(); + + $urlOptions = array( + self::TYPE_EQUALS_EXACTLY => Piwik::translate('HeatmapSessionRecording_TargetTypeEqualsExactly'), + self::TYPE_EQUALS_SIMPLE => Piwik::translate('HeatmapSessionRecording_TargetTypeEqualsSimple'), + self::TYPE_CONTAINS => Piwik::translate('HeatmapSessionRecording_TargetTypeContains'), + self::TYPE_STARTS_WITH => Piwik::translate('HeatmapSessionRecording_TargetTypeStartsWith'), + self::TYPE_REGEXP => Piwik::translate('HeatmapSessionRecording_TargetTypeRegExp'), + ); + + $urlAttribute = array( + 'value' => self::ATTRIBUTE_URL, + 'name' => Piwik::translate('HeatmapSessionRecording_TargetAttributeUrl'), + 'types' => array(), + 'example' => 'http://www.example.com/' . Piwik::translate('HeatmapSessionRecording_FilesystemDirectory') + ); + foreach ($urlOptions as $key => $value) { + $urlAttribute['types'][] = array('value' => $key, 'name' => $value); + } + $targetTypes[] = $urlAttribute; + + + $urlAttribute = array( + 'value' => self::ATTRIBUTE_PATH, + 'name' => Piwik::translate('HeatmapSessionRecording_TargetAttributePath'), + 'types' => array(), + 'example' => '/' . Piwik::translate('HeatmapSessionRecording_FilesystemDirectory') + ); + foreach ($urlOptions as $key => $value) { + $urlAttribute['types'][] = array('value' => $key, 'name' => $value); + } + $targetTypes[] = $urlAttribute; + + + $urlAttribute = array( + 'value' => self::ATTRIBUTE_URLPARAM, + 'name' => Piwik::translate('HeatmapSessionRecording_TargetAttributeUrlParameter'), + 'types' => array(), + 'example' => Piwik::translate('HeatmapSessionRecording_TargetAttributeUrlParameterExample') + ); + + $parameterOptions = array( + self::TYPE_EXISTS => Piwik::translate('HeatmapSessionRecording_TargetTypeExists'), + self::TYPE_EQUALS_EXACTLY => Piwik::translate('HeatmapSessionRecording_TargetTypeEqualsExactly'), + self::TYPE_CONTAINS => Piwik::translate('HeatmapSessionRecording_TargetTypeContains'), + self::TYPE_REGEXP => Piwik::translate('HeatmapSessionRecording_TargetTypeRegExp'), + ); + + foreach ($parameterOptions as $key => $value) { + $urlAttribute['types'][] = array('value' => $key, 'name' => $value); + } + + $targetTypes[] = $urlAttribute; + + return $targetTypes; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Tracker/RequestProcessor.php b/files/plugin-HeatmapSessionRecording-5.6.0/Tracker/RequestProcessor.php new file mode 100644 index 0000000..ba94e54 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Tracker/RequestProcessor.php @@ -0,0 +1,312 @@ +logHsr = $logHsr; + $this->logEvent = $logEvent; + $this->logMutation = $logMutation; + $this->siteHsr = $siteHsr; + $this->logHsrSite = $logHsrSite; + } + + public function afterRequestProcessed(VisitProperties $visitProperties, Request $request) + { + $params = $request->getParams(); + + if (!empty($params[self::TRACKING_PARAM_HSR_ID_VIEW])) { + // we need to make sure no action will be recorded! especially since we might go into record logs! + $request->setMetadata('Actions', 'action', null); + // make sure no goals will be recorded! + $request->setMetadata('Goals', 'goalsConverted', array()); + + // for all tracking requests further down we need at least one hsrid + if (empty($params[self::TRACKING_PARAM_HSR_IDS]) || !is_array($params[self::TRACKING_PARAM_HSR_IDS])) { + // nothing to record, abort request + return true; + } + + $idVisit = $visitProperties->getProperty('idvisit'); + + if (empty($idVisit)) { + // this is a NEW VISIT request, this should usually not happen as HSR requests are usually sent after + // pageviews. We need to handle the visit during recordLogs() + // For faster performance and to reduce load on visit table etc we want to record those requests + // ideally always here in afterRequestProcessed and terminate as early as possible + return false; + } + + $this->handleHsrRequest($idVisit, $request); + + // abort anything else + return true; + } + } + + public function recordLogs(VisitProperties $visitProperties, Request $request) + { + // we try to insert the request again if it is a new visit, as we now have the idVisit (should have) + return $this->afterRequestProcessed($visitProperties, $request); + } + + private function handleHsrRequest($idVisit, Request $request) + { + $params = $request->getParams(); + $url = $request->getParam('url'); + $idPageview = $request->getParam('pv_id'); + $idSite = $request->getIdSite(); + + $serverTime = Date::getDatetimeFromTimestamp($request->getCurrentTimestamp()); + $timeOnPage = $params[self::TRACKING_PARAM_TOTAL_TIME]; + $viewportW = $params[self::TRACKING_PARAM_VIEWPORT_WIDTH]; + $viewportH = $params[self::TRACKING_PARAM_VIEWPORT_HEIGHT]; + $scrollYMaxPercent = $params[self::TRACKING_PARAM_SCROLL_MAX_PERCENTAGE]; + $foldYpercent = $params[self::TRACKING_PARAM_FOLD_Y_PERCENT]; + $hsrIds = $params[self::TRACKING_PARAM_HSR_IDS]; + $idHsrView = $params[self::TRACKING_PARAM_HSR_ID_VIEW]; + + $maxAllowedTimeOnPage = self::MYSQL_MAX_MEDIUM_UINT; + $configuration = new Configuration(); + $maxAllowedTimeOnPageConfigValue = $configuration->getMaximumAllowedPageTime(); + if (!empty($maxAllowedTimeOnPageConfigValue)) { + $maxAllowedTimeOnPage = $maxAllowedTimeOnPageConfigValue; + } + + if ($timeOnPage > $maxAllowedTimeOnPage) { + return; + } + + $hsrIds = $this->getValidHsrIds($idSite, $hsrIds); + + if (empty($hsrIds)) { + Common::printDebug('Warning! no active hsrIds provided, will ignore tracking request'); + throw new InvalidRequestParameterException('No active hsrIds provided'); + } + + $action = $request->getMetadata('Actions', 'action'); + if (!empty($action)) { + /** @var Tracker\Action $action */ + $action->loadIdsFromLogActionTable(); + $url = $action->getActionUrl(); + } + $userAgent = $request->getUserAgent(); + $resolution = $request->getParam('res'); + + $idLogHsr = $this->logHsr->record($hsrIds, $idSite, $idVisit, $idHsrView, $idPageview, $url, $serverTime, $userAgent, $resolution, $timeOnPage, $viewportW, $viewportH, $scrollYMaxPercent, $foldYpercent); + + $events = $params[self::TRACKING_PARAM_EVENTS]; + + $selectors = array(); + foreach ($events as $index => $event) { + // DO NOT USE VARIABLE REFERENCE HERE! + if (isset($event['s'])) { + $key = md5($event['s']); + $events[$index]['skey'] = $key; + $selectors[$key] = array($event['s'], self::ACTION_TYPE_HSR_SELECTOR, null); + } + } + + $selectorIds = array(); + if (!empty($selectors)) { + $selectorIds = Tracker\TableLogAction::loadIdsAction($selectors); + } + + foreach ($events as $event) { + $this->recordEvent($idLogHsr, $idSite, $event, $url, $selectorIds); + } + } + + private function recordEvent($idLogHsr, $idSite, $event, $url, $selectorIds) + { + $event['ty'] = (int) $event['ty']; + + switch ($event['ty']) { + case self::EVENT_TYPE_STOP_RECORDING: + // we don't record any event for this, only there to track accurate recording time (time when we stopped recording) + break; + case self::EVENT_TYPE_PAGE_TREEMIRROR: + if (!empty($event['dom'])) { + // we need to save the initial DOM for heatmap + $clearCache = false; + $cachedHsrs = $this->getCachedHsrs($idSite); + // we cannot overwrite simply all matching idsitehsr because someone might define a screenshot url and this + // url might be different per hsr. + foreach ($cachedHsrs as $hsr) { + if (empty($hsr['page_treemirror']) && $hsr['idsitehsr'] == $event['id'] && $hsr['record_type'] == SiteHsrDao::RECORD_TYPE_HEATMAP) { + $screenshotUrl = $hsr['screenshot_url']; + if (empty($hsr['screenshot_url'])) { + $screenshotUrl = $url; + } + + if (!empty($screenshotUrl)) { + // we need to make sure a url is set + $this->siteHsr->setPageTreeMirror($idSite, $hsr['idsitehsr'], $event['dom'], $screenshotUrl); + $clearCache = true; + } + } + } + if ($clearCache) { + // prevent tracking initial dom again + Tracker\Cache::deleteCacheWebsiteAttributes($idSite); + } + } + + break; + + case self::EVENT_TYPE_LINK_HSR: + if (isset($event['id'])) { + // we need to link a new idsitehsr to an existing recording + $idSiteHsr = (int) $event['id']; + + if (!empty($idSiteHsr)) { + $this->logHsrSite->linkRecord($idLogHsr, $idSiteHsr); + } + } + + break; + + default: + $idSelector = null; + if (isset($event['skey']) && isset($selectorIds[$event['skey']])) { + $idSelector = $selectorIds[$event['skey']]; + } + $x = null; + if (isset($event['x'])) { + $x = $event['x']; + } + $y = null; + if (isset($event['y'])) { + $y = $event['y']; + } + $text = null; + if (isset($event['te'])) { + $text = $event['te']; + } + + if ($event['ty'] == RequestProcessor::EVENT_TYPE_INITIAL_DOM) { + $event['ti'] = 0;// we make sure to force 0 time since load + } + + $this->logEvent->record($idLogHsr, $event['ti'], $event['ty'], $idSelector, $x, $y, $text); + } + } + + // we need to make sure it is only possible to track data into currently active heatmaps, and currently + // active or ended session recordings. Otherwise other users could track in advance data into any heatmap or recording + // it is still possible to track data into completed sessions because we want to make sure to include all tracking requests + // of a session + private function getValidHsrIds($idSite, $hsrIds) + { + $hsrs = $this->getCachedHsrs($idSite); + + $existingIds = array(); + foreach ($hsrs as $hsr) { + $existingIds[] = $hsr['idsitehsr']; + } + + $ids = array(); + foreach ($hsrIds as $hsrId) { + if (in_array($hsrId, $existingIds)) { + $ids[] = $hsrId; + } else { + Common::printDebug(sprintf("Notice! hsrId %d is not active anymore and will be ignored", (int) $hsrId)); + } + } + + $ids = array_unique($ids); + return $ids; + } + + private function getCachedHsrs($idSite) + { + $cache = Tracker\Cache::getCacheWebsiteAttributes($idSite); + if (!empty($cache['hsr'])) { + return $cache['hsr']; + } + return array(); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Unsubscribe.php b/files/plugin-HeatmapSessionRecording-5.6.0/Unsubscribe.php new file mode 100644 index 0000000..bab9a1c --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Unsubscribe.php @@ -0,0 +1,46 @@ +migration = $factory; + } + + public function getMigrations(Updater $updater) + { + $migration = $this->migration->db->addIndex('log_hsr_site', 'idloghsr'); + + return array( + $migration + ); + } + + public function doUpdate(Updater $updater) + { + $updater->executeMigrations(__FILE__, $this->getMigrations($updater)); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Updates/3.0.11.php b/files/plugin-HeatmapSessionRecording-5.6.0/Updates/3.0.11.php new file mode 100644 index 0000000..f200234 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Updates/3.0.11.php @@ -0,0 +1,51 @@ +migration = $factory; + } + + public function getMigrations(Updater $updater) + { + $migration = $this->migration->db->addIndex('log_hsr_event', 'idhsrblob'); + + return array( + $migration + ); + } + + public function doUpdate(Updater $updater) + { + $updater->executeMigrations(__FILE__, $this->getMigrations($updater)); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Updates/3.0.3.php b/files/plugin-HeatmapSessionRecording-5.6.0/Updates/3.0.3.php new file mode 100644 index 0000000..c79e8f5 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Updates/3.0.3.php @@ -0,0 +1,30 @@ +install(); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Updates/4.0.0.php b/files/plugin-HeatmapSessionRecording-5.6.0/Updates/4.0.0.php new file mode 100644 index 0000000..2a66759 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Updates/4.0.0.php @@ -0,0 +1,55 @@ +migration = $factory; + } + + public function getMigrations(Updater $updater) + { + $eventTable = Common::prefixTable('log_hsr_event'); + $columns = DbHelper::getTableColumns($eventTable); + + $migrations = array(); + if (!array_key_exists('idhsrevent', $columns)) { + // if installed < 3.2.21 where column wasn't added yet + $migrations[] = $this->migration->db->addColumn('log_hsr_event', 'idhsrevent', 'BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY'); + } + + return $migrations; + } + + public function doUpdate(Updater $updater) + { + $updater->executeMigrations(__FILE__, $this->getMigrations($updater)); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Updates/5.1.0.php b/files/plugin-HeatmapSessionRecording-5.6.0/Updates/5.1.0.php new file mode 100644 index 0000000..5cd40d1 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Updates/5.1.0.php @@ -0,0 +1,54 @@ +migration = $factory; + } + + public function getMigrations(Updater $updater) + { + $siteHsrTable = Common::prefixTable('site_hsr'); + $columns = DbHelper::getTableColumns($siteHsrTable); + + $migrations = array(); + if (!array_key_exists('capture_manually', $columns)) { + $migrations[] = $this->migration->db->addColumn('site_hsr', 'capture_manually', 'TINYINT(1) UNSIGNED NOT NULL DEFAULT 0'); + } + + return $migrations; + } + + public function doUpdate(Updater $updater) + { + $updater->executeMigrations(__FILE__, $this->getMigrations($updater)); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Updates/5.4.0.php b/files/plugin-HeatmapSessionRecording-5.6.0/Updates/5.4.0.php new file mode 100644 index 0000000..c12aa6f --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Updates/5.4.0.php @@ -0,0 +1,55 @@ +migration = $factory; + } + + public function getMigrations(Updater $updater) + { + $siteHsrTable = Common::prefixTable('site_hsr'); + $columns = DbHelper::getTableColumns($siteHsrTable); + + $migrations = array(); + if (!array_key_exists('description', $columns)) { + $migrations[] = $this->migration->db->addColumn('site_hsr', 'description', "VARCHAR(" . Description::MAX_LENGTH . ") NOT NULL DEFAULT ''"); + } + + return $migrations; + } + + public function doUpdate(Updater $updater) + { + $updater->executeMigrations(__FILE__, $this->getMigrations($updater)); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Updates/5.5.0.php b/files/plugin-HeatmapSessionRecording-5.6.0/Updates/5.5.0.php new file mode 100644 index 0000000..7f81a13 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Updates/5.5.0.php @@ -0,0 +1,63 @@ +migration = $factory; + } + + public function getMigrations(Updater $updater) + { + $siteHsrTable = Common::prefixTable('site_hsr'); + $columns = DbHelper::getTableColumns($siteHsrTable); + + $migrations = array(); + if (!array_key_exists('login', $columns)) { + $migrations[] = $this->migration->db->addColumn('site_hsr', 'login', 'VARCHAR(100) NULL DEFAULT NULL'); + } + + if (!array_key_exists('finished_notified', $columns)) { + $migrations[] = $this->migration->db->addColumn('site_hsr', 'finished_notified', 'TINYINT(1) UNSIGNED NOT NULL DEFAULT 0'); + + // defensively flag existing finished records so the upgrade does not trigger a backlog of notification emails + $migrations[] = $this->migration->db->sql( + sprintf("UPDATE %s SET finished_notified = 1 WHERE status = 'ended'", $siteHsrTable) + ); + } + + return $migrations; + } + + public function doUpdate(Updater $updater) + { + $updater->executeMigrations(__FILE__, $this->getMigrations($updater)); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Updates/5.5.5.php b/files/plugin-HeatmapSessionRecording-5.6.0/Updates/5.5.5.php new file mode 100644 index 0000000..70d030d --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Updates/5.5.5.php @@ -0,0 +1,75 @@ +migration = $factory; + } + + public function getMigrations(Updater $updater) + { + $siteHsrTable = Common::prefixTable('site_hsr'); + $columns = DbHelper::getTableColumns($siteHsrTable); + + $migrations = array(); + if (!array_key_exists('auto_repeat', $columns)) { + $migrations[] = $this->migration->db->addColumn('site_hsr', 'auto_repeat', 'TINYINT(1) UNSIGNED NOT NULL DEFAULT 0'); + } + + if (!array_key_exists('repeat_source_idsitehsr', $columns)) { + $migrations[] = $this->migration->db->addColumn('site_hsr', 'repeat_source_idsitehsr', 'INT(10) UNSIGNED NULL DEFAULT NULL'); + } + + if (!DbHelper::tableHasIndex($siteHsrTable, 'unique_repeat_source')) { + $migrations[] = $this->migration->db->addUniqueKey('site_hsr', 'repeat_source_idsitehsr', 'unique_repeat_source'); + } + + // repeat_processed was only ever added by an earlier revision of this migration, it is now derived + // from the copies themselves + if (array_key_exists('repeat_processed', $columns)) { + $migrations[] = $this->migration->db->dropColumn('site_hsr', 'repeat_processed'); + } + + if (!array_key_exists('scheduled_date', $columns)) { + $migrations[] = $this->migration->db->addColumn('site_hsr', 'scheduled_date', 'DATETIME NULL'); + } + + $migrations[] = $this->migration->db->changeColumnType('site_hsr', 'name', 'VARCHAR(' . Name::MAX_LENGTH . ') NOT NULL'); + + return $migrations; + } + + public function doUpdate(Updater $updater) + { + $updater->executeMigrations(__FILE__, $this->getMigrations($updater)); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Updates/5.6.0.php b/files/plugin-HeatmapSessionRecording-5.6.0/Updates/5.6.0.php new file mode 100644 index 0000000..ebf062a --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Updates/5.6.0.php @@ -0,0 +1,54 @@ +migration = $factory; + } + + public function getMigrations(Updater $updater) + { + $siteHsrTable = Common::prefixTable('site_hsr'); + $columns = DbHelper::getTableColumns($siteHsrTable); + + $migrations = array(); + if (!array_key_exists('screenshot_deleted_date', $columns)) { + $migrations[] = $this->migration->db->addColumn('site_hsr', 'screenshot_deleted_date', 'DATETIME NULL DEFAULT NULL'); + } + + return $migrations; + } + + public function doUpdate(Updater $updater) + { + $updater->executeMigrations(__FILE__, $this->getMigrations($updater)); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/UserSettings.php b/files/plugin-HeatmapSessionRecording-5.6.0/UserSettings.php new file mode 100644 index 0000000..990063c --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/UserSettings.php @@ -0,0 +1,35 @@ +notifyFinishedRecordings = $this->makeSetting('notifyFinishedRecordings', $default = true, FieldConfig::TYPE_BOOL, function (FieldConfig $field) { + $field->title = Piwik::translate('HeatmapSessionRecording_NotifyFinishedSetting'); + $field->uiControl = FieldConfig::UI_CONTROL_CHECKBOX; + $field->inlineHelp = Piwik::translate('HeatmapSessionRecording_NotifyFinishedSettingHelp'); + }); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/VisitorDetails.php b/files/plugin-HeatmapSessionRecording-5.6.0/VisitorDetails.php new file mode 100644 index 0000000..a1bf919 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/VisitorDetails.php @@ -0,0 +1,125 @@ +recordings = array_fill_keys($visitIds, null); + + $aggregator = new Aggregator(); + $recordings = $aggregator->findRecordings($visitIds); + + foreach ($recordings as $recording) { + $this->recordings[$recording['idvisit']] = $recording; + } + } + + public function extendVisitorDetails(&$visitor) + { + $visitor['sessionReplayUrl'] = null; + + $idVisit = $visitor['idVisit']; + $idSite = $visitor['idSite']; + + if (empty($idSite) || empty($idVisit) || !$this->getValidator()->canViewSessionReport($idSite)) { + return; + } + + $recording = $this->getRecoding($idVisit); + + if (!empty($recording) && !empty($recording['idsitehsr'])) { + $visitor['sessionReplayUrl'] = '?module=HeatmapSessionRecording&action=replayRecording&idSite=' . (int)$idSite . '&idLogHsr=' . (int)$recording['idloghsr'] . '&idSiteHsr=' . (int)$recording['idsitehsr']; + + $token_auth = Common::getRequestVar('token_auth', '', 'string'); + $force_api_session = Common::getRequestVar('force_api_session', 0, 'int'); + if ( + !empty($token_auth) + && ctype_xdigit($token_auth) && strlen($token_auth) > 30 && strlen($token_auth) < 81 + && !HeatmapSessionRecording::isMatomoForWordPress() + && !$force_api_session + ) { + $visitor['sessionReplayUrl'] .= '&token_auth=' . rawurlencode($token_auth); + } + } + } + + protected function getRecoding($idVisit) + { + if (empty($this->recordings) || !array_key_exists($idVisit, $this->recordings)) { + // if not prefetched + $aggregator = new Aggregator(); + return $aggregator->findRecording($idVisit); + } + + return $this->recordings[$idVisit]; + } + + /** + * @return \Piwik\Plugins\HeatmapSessionRecording\Input\Validator + */ + protected function getValidator() + { + return StaticContainer::get('Piwik\Plugins\HeatmapSessionRecording\Input\Validator'); + } + + public function renderIcons($visitorDetails) + { + if (!empty($visitorDetails['sessionReplayUrl'])) { + $title = htmlentities( + Piwik::translate('HeatmapSessionRecording_ReplayRecordedSession'), + ENT_COMPAT | ENT_HTML401, + 'UTF-8' + ); + return ''; + } + + return ''; + } + + public function renderVisitorDetails($visitorDetails) + { + if (!empty($visitorDetails['sessionReplayUrl'])) { + $title = htmlentities( + Piwik::translate('HeatmapSessionRecording_ReplayRecordedSession'), + ENT_COMPAT | ENT_HTML401, + 'UTF-8' + ); + return [ + [ + 100, + ' ' . $title . '
' + ] + ]; + } + + return []; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Widgets/GetManageHeatmaps.php b/files/plugin-HeatmapSessionRecording-5.6.0/Widgets/GetManageHeatmaps.php new file mode 100644 index 0000000..f1215e5 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Widgets/GetManageHeatmaps.php @@ -0,0 +1,77 @@ +settings = $settings; + } + + public static function configure(WidgetConfig $config) + { + $config->setCategoryId('HeatmapSessionRecording_Heatmaps'); + $config->setSubcategoryId('HeatmapSessionRecording_ManageHeatmaps'); + $config->setName('HeatmapSessionRecording_ManageHeatmaps'); + $config->setParameters(array('showtitle' => 0)); + $config->setOrder(99); + $config->setIsNotWidgetizable(); + + $idSite = Common::getRequestVar('idSite', 0, 'int'); + if (self::getAccessValidator()->canWrite($idSite) && !self::getAccessValidator()->isHeatmapRecordingDisabled()) { + $config->enable(); + } else { + $config->disable(); + } + } + + private static function getAccessValidator() + { + return StaticContainer::get('Piwik\Plugins\HeatmapSessionRecording\Input\Validator'); + } + + public function render() + { + $idSite = Common::getRequestVar('idSite', null, 'int'); + self::getAccessValidator()->checkWritePermission($idSite); + + return sprintf( + '
', + (int)$this->settings->breakpointMobile->getValue(), + (int)$this->settings->breakpointTablet->getValue(), + Piwik::translate( + HeatmapSessionRecording::getTranslationKey('pause'), + [Piwik::translate('HeatmapSessionRecording_Heatmap')] + ), + HeatmapSessionRecording::isMatomoJsWritable() + ); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Widgets/GetManageSessionRecordings.php b/files/plugin-HeatmapSessionRecording-5.6.0/Widgets/GetManageSessionRecordings.php new file mode 100644 index 0000000..7a9908f --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Widgets/GetManageSessionRecordings.php @@ -0,0 +1,66 @@ +setCategoryId('HeatmapSessionRecording_SessionRecordings'); + $config->setSubcategoryId('HeatmapSessionRecording_ManageSessionRecordings'); + $config->setName('HeatmapSessionRecording_ManageSessionRecordings'); + $config->setParameters(array('showtitle' => 0)); + $config->setOrder(99); + $config->setIsNotWidgetizable(); + + $idSite = Common::getRequestVar('idSite', 0, 'int'); + if (self::getAccessValidator()->canWrite($idSite) && !self::getAccessValidator()->isSessionRecordingDisabled()) { + $config->enable(); + } else { + $config->disable(); + } + } + + private static function getAccessValidator() + { + return StaticContainer::get('Piwik\Plugins\HeatmapSessionRecording\Input\Validator'); + } + + public function render() + { + $idSite = Common::getRequestVar('idSite', null, 'int'); + self::getAccessValidator()->checkWritePermission($idSite); + + return sprintf( + '
', + Piwik::translate( + HeatmapSessionRecording::getTranslationKey('pause'), + [ + Piwik::translate('HeatmapSessionRecording_Heatmap') + ] + ), + HeatmapSessionRecording::isMatomoJsWritable() + ); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Widgets/GettingStartedHeatmap.php b/files/plugin-HeatmapSessionRecording-5.6.0/Widgets/GettingStartedHeatmap.php new file mode 100644 index 0000000..82b053b --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Widgets/GettingStartedHeatmap.php @@ -0,0 +1,75 @@ +setIsNotWidgetizable(); + $config->setCategoryId('HeatmapSessionRecording_Heatmaps'); + $config->setName('HeatmapSessionRecording_GettingStarted'); + $config->setOrder(5); + + $idSite = Common::getRequestVar('idSite', 0, 'int'); + + if (self::shouldEnable($idSite) && !self::getAccessValidator()->isHeatmapRecordingDisabled()) { + $config->enable(); + $hsrs = StaticContainer::get('Piwik\Plugins\HeatmapSessionRecording\Model\SiteHsrModel'); + $hsrs = $hsrs->hasSessionRecordings($idSite); + if (empty($hsrs)) { + // we only make it visible in the UI when there are no hsrs. We cannot disable/enable it + // as we otherwise would show an error message "not allowed to view widget" when suddenly + // hsrs are configured + $config->setSubcategoryId('HeatmapSessionRecording_GettingStarted'); + } + } else { + $config->disable(); + } + } + + private static function getAccessValidator() + { + return StaticContainer::get('Piwik\Plugins\HeatmapSessionRecording\Input\Validator'); + } + + private static function shouldEnable($idSite) + { + $validator = self::getAccessValidator(); + return !empty($idSite) && $validator->canViewHeatmapReport($idSite) && !$validator->canWrite($idSite); + } + + public function render() + { + $idSite = Common::getRequestVar('idSite', null, 'int'); + + self::getAccessValidator()->checkHeatmapReportViewPermission($idSite); + + if (self::shouldEnable($idSite)) { + return $this->renderTemplate('gettingStartedHeatmaps'); + } + + return ''; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/Widgets/GettingStartedSessions.php b/files/plugin-HeatmapSessionRecording-5.6.0/Widgets/GettingStartedSessions.php new file mode 100644 index 0000000..aa15cf7 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/Widgets/GettingStartedSessions.php @@ -0,0 +1,75 @@ +setIsNotWidgetizable(); + $config->setCategoryId('HeatmapSessionRecording_SessionRecordings'); + $config->setName('HeatmapSessionRecording_GettingStarted'); + $config->setOrder(5); + + $idSite = Common::getRequestVar('idSite', 0, 'int'); + + if (self::shouldEnable($idSite) && !self::getAccessValidator()->isSessionRecordingDisabled()) { + $config->enable(); + $hsrs = StaticContainer::get('Piwik\Plugins\HeatmapSessionRecording\Model\SiteHsrModel'); + $hsrs = $hsrs->hasSessionRecordings($idSite); + if (empty($hsrs)) { + // we only make it visible in the UI when there are no hsrs. We cannot disable/enable it + // as we otherwise would show an error message "not allowed to view widget" when suddenly + // hsrs are configured + $config->setSubcategoryId('HeatmapSessionRecording_GettingStarted'); + } + } else { + $config->disable(); + } + } + + private static function getAccessValidator() + { + return StaticContainer::get('Piwik\Plugins\HeatmapSessionRecording\Input\Validator'); + } + + private static function shouldEnable($idSite) + { + $validator = self::getAccessValidator(); + // only for VIEW users, not for anonymous and not for admin users + return !empty($idSite) && $validator->canViewSessionReport($idSite) && !$validator->canWrite($idSite); + } + + public function render() + { + $idSite = Common::getRequestVar('idSite', null, 'int'); + self::getAccessValidator()->checkSessionReportViewPermission($idSite); + + if (self::shouldEnable($idSite)) { + return $this->renderTemplate('gettingStartedSessions'); + } + + return ''; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/config/config.php b/files/plugin-HeatmapSessionRecording-5.6.0/config/config.php new file mode 100644 index 0000000..2c33444 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/config/config.php @@ -0,0 +1,21 @@ + Piwik\DI::add(array( + Piwik\DI::get('Piwik\Plugins\HeatmapSessionRecording\Diagnostic\ConfigsPhpCheck'), + )), +); diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/configs.php b/files/plugin-HeatmapSessionRecording-5.6.0/configs.php new file mode 100644 index 0000000..4607e8c --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/configs.php @@ -0,0 +1,164 @@ +init(); + +$originalIdSite = $_GET['idsite']; +$trackerId = (string) $_GET['trackerid']; +$url = (string) $_GET['url']; + +$idSite = $configs->getIdSite($originalIdSite); + +if ($idSite <= 0) { + http_response_code(400); + exit; +} + +$trackerConfig = array( + 'heatmaps' => array(), // for getdom we later need to check if a screenhot url is configured array('enabled' => false, 'getdom' => []) + 'sessions' => array(), // array('enabled' => false) +); + +$cache = $configs->getCachedWebsiteAttributes($idSite); + +if (empty($cache['hsr'])) { + $configs->sendResponse($trackerConfig, $originalIdSite, $trackerId); + exit; +} + +$systemSettings = StaticContainer::get(SystemSettings::class); +$includedCountries = $systemSettings->getIncludedCountries(false); +$isIncludedCountry = true; +if (!empty($includedCountries)) { + $configs->loadTrackerEnvironment(); + $isIncludedCountry = HsrMatcher::isIncludedCountry($includedCountries); +} + +foreach ($cache['hsr'] as $hsr) { + $sessionGroup = HsrMatcher::isUserPartOfSampleGroup($hsr['record_type'], $hsr['idsitehsr']); + + if ($sessionGroup === HsrMatcher::SAMPLE_GROUP_NOT_PART) { + // user is not part of sample group + continue; + } + + $shouldForceBeIncluded = $sessionGroup === HsrMatcher::SAMPLE_GROUP_IS_PART; + // For sessions we need to record all pages, even if page rules does not match as soon as the session is being recorded. That is + // as soon as the visitor has visited at least one page that matches the page rules + + if ( + ( + $shouldForceBeIncluded || + ( + HsrMatcher::matchesAllPageRules($hsr['match_page_rules'], $url) && + HsrMatcher::checkIsNotEnded($hsr) + ) + ) && + $isIncludedCountry + ) { + // by returning the hsrid IDs here we can make sure to record all updates for this user + // also it makes request processor faster since we won't have to match the records there again + // it also prevents starting to track only partial data after a heatmap or session was created while a different + // session or heatmap is already being recorded + // it will also make the JS tracker easier since user will be able to configure manually which ids to track + // also because the sample rate is based on random number generation we cannot check sample rate and whether a user + // is in the group or not on each tracking request. We would need to send a cookie + + if ($hsr['record_type'] == \Piwik\Plugins\HeatmapSessionRecording\Dao\SiteHsrDao::RECORD_TYPE_SESSION) { + $trackerConfig['sessions'][] = array( + 'id' => $hsr['idsitehsr'], + 'sample_rate' => $hsr['sample_rate'], + 'min_time' => $hsr['min_session_time'], + 'activity' => $hsr['requires_activity'], + 'keystrokes' => $hsr['capture_keystrokes'], + ); + } elseif ($hsr['record_type'] == \Piwik\Plugins\HeatmapSessionRecording\Dao\SiteHsrDao::RECORD_TYPE_HEATMAP) { + $heatmap = array('id' => $hsr['idsitehsr'], 'getdom' => false, 'sample_rate' => $hsr['sample_rate'], 'capture_manually' => $hsr['capture_manually']); + + if ( + empty($hsr['page_treemirror']) && + empty($hsr['capture_manually']) && + \Piwik\Plugins\HeatmapSessionRecording\Tracker\HsrMatcher::doesScreenshotUrlMatch($hsr['screenshot_url'], $url) + ) { + $heatmap['getdom'] = true; + } + + $trackerConfig['heatmaps'][] = $heatmap; + } + } +} + +$configs->sendResponse($trackerConfig, $originalIdSite, $trackerId); diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/docs/index.md b/files/plugin-HeatmapSessionRecording-5.6.0/docs/index.md new file mode 100644 index 0000000..aad3729 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/docs/index.md @@ -0,0 +1,3 @@ +## Documentation + +The [Heatmap User Guide](https://matomo.org/docs/heatmaps/), [Session Recording User Guide](https://matomo.org/docs/session-recording/) and the [Heatmap & Session Recording User FAQ](https://matomo.org/faq/heatmap-session-recording/) cover how to get the most out of this plugin. The [Heatmap & Session Recording developer guides](https://developer.matomo.org/guides/heatmap-session-recording) help you customizing the tracking of your heatmaps and session recordings. \ No newline at end of file diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/images/no-interactions.svg b/files/plugin-HeatmapSessionRecording-5.6.0/images/no-interactions.svg new file mode 100644 index 0000000..595fd5d --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/images/no-interactions.svg @@ -0,0 +1,3 @@ + + + diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/javascripts/recording.js b/files/plugin-HeatmapSessionRecording-5.6.0/javascripts/recording.js new file mode 100644 index 0000000..3e7d3ee --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/javascripts/recording.js @@ -0,0 +1,732 @@ +/** + * Copyright (C) InnoCraft Ltd - All rights reserved. + * + * NOTICE: All information contained herein is, and remains the property of InnoCraft Ltd. + * The intellectual and technical concepts contained herein are protected by trade secret or copyright law. + * Redistribution of this information or reproduction of this material is strictly forbidden + * unless prior written permission is obtained from InnoCraft Ltd. + * + * You shall use this code only in accordance with the license agreement obtained from InnoCraft Ltd. + * + * @link https://www.innocraft.com/ + * @license For license details see https://www.innocraft.com/license + */ + +function HsrRecordingIframe (url) { + url = String(url); + if (url.indexOf('?') >= 0) { + url = url.substr(0, url.indexOf('?')); + } + + if (url.indexOf('#') >= 0) { + url = url.substr(0, url.indexOf('#')); + } + + if (url.indexOf('http') === -1) { + // we need to load http or at least same protocol as current piwik install + url = String(location.protocol) + '//' + url; + } + + var requireSecureProtocol = String(location.protocol).toLowerCase() === 'https:'; + + function convertUrlToSecureProtocolIfNeeded(url) + { + // otherwise we get problems re insecure content + if (requireSecureProtocol && String(url).toLowerCase().indexOf('http:') === 0) { + url = 'https' + url.substr('http'.length); + } + + return url; + } + + url = convertUrlToSecureProtocolIfNeeded(url); + + var baseUrl = url; + + this.enableDebugMode = false; + + function addRemoveSupport(window) + { + if (window && !('remove' in window.Element.prototype)) { + window.Element.prototype.remove = function() { + if (this.parentNode && this.parentNode.removeChild) { + this.parentNode.removeChild(this); + } + }; + } + } + + addRemoveSupport(window); + + this.isSupportedBrowser = function () { + if (typeof WebKitMutationObserver !== 'undefined') { + return true; + } else if (typeof MutationObserver !== 'undefined') { + return true; + } + + return false; + }; + + this.scrollTo = function (x,y) { + window.scrollTo(x,y); + }; + + this.getIframeWindow = function () { + return window; + }; + + this.findElement = function (selector) { + selector = this.sanitizeSelector(selector); + return $(selector); + }; + + this.sanitizeSelector = function (selector) { + if (typeof selector !== 'string') { + return null; + } + + selector = selector.trim(); + + if (!selector) { + return null; + } + + if (selector.indexOf('<') !== -1) { + return null; + } + + return selector; + }; + + this.makeSvg = function (width, height) { + var canvas = this.findElement('#mouseMoveCanvas'); + if (canvas.length) { + canvas.empty(); + } else { + this.appendContent('
'); + } + + this.draw = SVG('mouseMoveCanvas').size(width, height); + }; + + this.drawLine = function (x1, y1, x2, y2, color) { + if (this.draw) { + var line = this.draw.line(x1, y1, x2, y2); + line.stroke({ width: 1,color:color }); + } + }; + + this.drawCircle = function (x, y, color) { + if (this.draw) { + if (x > 4) { + x = x - 4; // because of radius of 8 we need to center it + } + if (y > 4) { + y = y - 4; // because of radius of 8 we need to center it + } + var circle = this.draw.circle(8); + circle.fill(color); + circle.move(x,y); + } + }; + + this.appendContent = function (html) { + return $('body').append(html); + }; + + this.getIframeSandboxValue = function (nodeOrUrl) { + var youtubeRegex = /^((?:https?:)\/\/)((?:www|m)\.)?((?:youtube\.com|youtube-nocookie\.com|youtu\.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)([a-zA-Z_=&]*)?$/; + var source = ''; + + if (nodeOrUrl) { + if (typeof nodeOrUrl === 'string') { + source = nodeOrUrl; + } else if (nodeOrUrl.src) { + source = nodeOrUrl.src; + } else if (nodeOrUrl.getAttribute) { + source = nodeOrUrl.getAttribute('src') || ''; + } + } + + if (source && youtubeRegex.test(String(source).toLowerCase())) { + return 'allow-scripts allow-same-origin'; + } + + return 'allow-scripts'; + }; + + this.restoreIframeSandboxPolicy = function (node) { + if (!node || node.nodeType !== Node.ELEMENT_NODE) { + return; + } + + if (node.tagName === 'IFRAME' && !node.getAttribute('sandbox')) { + node.setAttribute('sandbox', this.getIframeSandboxValue(node)); + } + + if (node.querySelectorAll) { + var frames = node.querySelectorAll('iframe:not([sandbox])'); + for (var i = 0; i < frames.length; i++) { + frames[i].setAttribute('sandbox', this.getIframeSandboxValue(frames[i])); + } + } + }; + + this.isBlockedMetaAttributeMutation = function (node, name, value) { + if (!node || node.tagName !== 'META' || !name) { + return false; + } + + var nameLower = String(name).trim().toLowerCase(); + + if (nameLower === 'http-equiv' && value) { + var httpEquiv = String(value).trim().toLowerCase(); + return httpEquiv === 'content-security-policy' || httpEquiv === 'refresh'; + } + + if (nameLower === 'name' && value) { + var metaName = String(value).trim().toLowerCase(); + return metaName === 'csrf-token' || metaName === 'referrer'; + } + + return false; + }; + + this.initialMutation = function(event) { + var self = this; + if (document.documentElement) { + document.documentElement.remove(); + } + + if (document && document.doctype) { + if (document.doctype && document.doctype.remove) { + document.doctype.remove(); + } else if (document.doctype) { + // fix remove is not available on IE + document.removeChild(document.doctype); + } + } + + addRemoveSupport(window); + + this.mirror = new TreeMirror(document, { + createElement: function(tagName, data) { + if (!tagName) { + return; + } + + tagName = tagName.toLowerCase().trim(); + + if (tagName === 'script') { + // prevent execution of this element! we still need to have it in the dom eg for nth-child selector etc. + var element = document.createElement('NO_SCRIPT'); + element.style.display = 'none'; + return element; + + } else if (tagName === 'form') { + var element = document.createElement('FORM'); + element.addEventListener('submit', function (event) { + event.preventDefault(); + event.stopPropagation(); + return false; + }); + return element; + + } else if (tagName === 'link' || tagName === 'img' || tagName === 'iframe') { + var element; + var isLinkHrefAttr = (tagName === 'link' && data && typeof data.attributes === 'object'); + function shouldUnresolve(href) { + var posHref = href.toLowerCase().indexOf('.scr.kaspersky-labs.com'); + if (posHref > 5 && posHref < 20) { + return true; + } + return false; + } + if (tagName === 'iframe' && data && 'src' in data.attributes && data.attributes.src.indexOf('google.com/recaptcha') !== -1) { + var element = document.createElement('NO_SCRIPT'); + element.style.display = 'none'; + return element; + } + if (isLinkHrefAttr && data && 'href' in data.attributes && data.attributes['href']) { + if (shouldUnresolve(String(data.attributes['href']))) { + data.attributes['href'] = '#not_possible_to_resolve'; + // this URL cannot be resolved and is injected dynamically + element = document.createElement('NO_LINK'); + element.style.display = 'none'; + } + } + if (isLinkHrefAttr && data && 'data-matomo-href' in data.attributes && data.attributes['data-matomo-href']) { + if (shouldUnresolve(String(data.attributes['data-matomo-href']))) { + data.attributes['href'] = '#not_possible_to_resolve'; + // this URL cannot be resolved and is injected dynamically + element = document.createElement('NO_LINK'); + element.style.display = 'none'; + } else { + data.attributes['href'] = data.attributes['data-matomo-href']; + } + } + + if (!element) { + element = document.createElement(tagName.toUpperCase()); + } + + if (element.tagName === 'IFRAME') { + element.setAttribute('sandbox', self.getIframeSandboxValue(data && data.attributes ? data.attributes.src : '')); + } + + element.setAttribute('referrerpolicy', 'no-referrer'); + return element; + } else if (tagName === 'head') { + var element = document.createElement('HEAD'); + + // we need to add ours first, because multiple base elements may exist and their base might only + // appear after few resources are already loaded + element.appendChild(document.createElement('BASE')); + element.firstChild.href = baseUrl; + + var style = document.createElement('style'); + style.setAttribute('type', 'text/css') + style.appendChild(document.createTextNode('[data-matomo-mask] {background-image: none !important; }' + + ' img[data-matomo-mask][src^="http"] {visibility:hidden !important;opacity: 0; }' + + ' img[data-matomo-mask][src^="/"] {visibility:hidden !important;opacity: 0; }' + + ' img[data-matomo-mask][src*=".png"] {visibility:hidden !important;opacity: 0; }' + + ' img[data-matomo-mask][src*=".jpg"] {visibility:hidden !important;opacity: 0; }' + + ' img[data-matomo-mask][src*=".webp"] {visibility:hidden !important;opacity: 0; }' + + ' img[data-matomo-mask][src*=".jepg"] {visibility:hidden !important;opacity: 0; }' + + ' img[data-matomo-mask][src*=".gif"] {visibility:hidden !important;opacity: 0; }' + + ' input:not([type="submit"]):not([type="button"]):not([value=""]){ background: unset !important; }' + )); + element.appendChild(style); + element.appendChild(document.createElement('BASE')); + + var metaElement = document.createElement('META'); + metaElement.name = 'referrer'; + metaElement.content = 'no-referrer'; + element.appendChild(metaElement); + + if (typeof data === 'object' && 'childNodes' in data && data.childNodes && data.childNodes.length) { + for (var k = 0; k < data.childNodes.length; k++) { + if (k in data.childNodes && 'object' === typeof data.childNodes[k] && 'tagName' in data.childNodes[k] && data.childNodes[k].tagName && data.childNodes[k].tagName === 'BASE') { + if ('attributes' in data.childNodes[k] && data.childNodes[k].attributes && data.childNodes[k].attributes.href) { + // no need to add a BASE ourselves, we prefer to use existing base set by user + + var thisBaseUrl = data.childNodes[k].attributes.href; + + var lowerThisBaseUrl = ('' + thisBaseUrl).toLowerCase(); + if (lowerThisBaseUrl.indexOf('http') === 0 || lowerThisBaseUrl.indexOf('//') === 0) { + // absolute base URL is set, we can simply use that URL + continue; // there might be multiple base URLs so need to continue + } + + // it has to be a relative URL, trying to resolve it + if ('function' === typeof URL) { + var theUrl = new URL(thisBaseUrl, baseUrl); + if (theUrl && theUrl.href) { + baseUrl = theUrl.href; + } else if (theUrl) { + baseUrl = '' + theUrl; + } + } else { + // browser does not support URL api... won't work in IE11 or lower + if ('undefined' !== typeof console && 'undefined' !== typeof console.log){ + console.log('browser does not support URL api, cannot resolve relative base URL'); + } + } + + // make sure to use this absolute base url + data.childNodes[k].attributes.href = baseUrl; + continue; // there might be multiple base URLs so need to continue + } + } + } + } + + return element; + + } else if (tagName === 'a') { + var element = document.createElement('A'); + element.addEventListener('click', function (event) { + event.preventDefault(); + event.stopPropagation(); + return false; + }); + return element; + + } else if (['svg', 'path', 'g', 'polygon', 'polyline', 'rect', 'text', 'circle', 'line'].indexOf(tagName) !== -1) { + return document.createElementNS('http://www.w3.org/2000/svg', tagName) + } else if (tagName === 'meta') { + if (data && typeof data.attributes === 'object') { + if ('http-equiv' in data.attributes && data.attributes['http-equiv']) { + var httpEquiv = String(data.attributes['http-equiv']).trim().toLowerCase(); + + if (httpEquiv === 'content-security-policy' || httpEquiv === 'refresh') { + return document.createElement('NO_META'); + } + } + if ('name' in data.attributes && data.attributes['name']) { + var metaName = String(data.attributes['name']).trim().toLowerCase(); + if (metaName === 'csrf-token') { + return document.createElement('NO_META'); + } + if (metaName === 'referrer') { + // we want to apply our own policy + return document.createElement('NO_META'); + } + } + } + } + }, + removeAttribute: function (node, name) { + if (!name) { + return node; + } + + var nameLower = String(name).trim().toLowerCase(); + + if (nameLower === 'sandbox' || nameLower === 'referrerpolicy') { + // do not remove our policy + return node; + } + }, + setAttribute: function(node, name, value) { + if (!name) { + return node; + } + + var nameLower = String(name).trim().toLowerCase(); + + if (nameLower === 'src' && value && (String(value).indexOf('/piwik.js') >= 0 || String(value).indexOf('/matomo.js') >= 0)) { + // we do not want to set piwik.js + return node; + } + + if (nameLower === 'srcdoc') { + // we ignore srcdoc + return node; + } + + if (nameLower === 'referrerpolicy') { + // we always set our value + node.setAttribute(nameLower, 'no-referrer'); + return node; + } + + if (nameLower === 'src' && value && String(value).indexOf('/HeatmapSessionRecording/') > 0) { + // we do not want to set configs.php etc + return node; + } + + if (value + && (String(value).toLowerCase().replace(/\x09|\x0a|\x0d/g, '').indexOf('javascript') >= 0 + || String(value).toLowerCase().replace(/\x09|\x0a|\x0d/g, '').indexOf('ecmascript') >= 0 + || String(value).toLowerCase().replace(/\x09|\x0a|\x0d/g, '').indexOf('vbscript') >= 0 + || String(value).toLowerCase().replace(/\x09|\x0a|\x0d/g, '').indexOf('jscript') >= 0)) { + // we do not want to set any javascript URL, eg href and src and attribute + return node; + } + if (value && String(value).toLowerCase().indexOf('xmlhttprequest') >= 0) { + // prevent simple input of xmlhttprequest + return node; + } + if (value && /fetch\s*\(/.test(String(value).toLowerCase())) { + // prevent simple input of fetch( + return node; + } + + var blockedAttributes = ['onchange', 'onload', 'onshow', 'onhashchange', 'onstorage', 'onchecking', 'ondownloading', 'onnoupdate', 'onupdateready', 'onabort', 'oncopy','ondrop','onwheel', 'onpaste', 'oncut', 'onbeforeunload', 'onreset','onsubmit', 'onunload', 'onerror', 'onclose','onopen','onpagehide','onpageshow','onpopstate','onmessage', 'onclick', 'ondblclick', 'oncontextmenu', 'onauxclick', 'onfocus', 'onfocusin', 'onfocusout', 'onblur', 'onselect', 'onplay', 'onpause', 'onended', 'onsuspend', 'onwaiting', 'onprogress', 'ontimeout', 'onchange', 'ontimeupdate', 'onstalled', 'onseeking', 'onplaying', 'onloadeddata', 'onended', 'onemptied', 'ondurationchange', 'oncanplay', 'oncomplete', 'onaudioprocess'] + // we block any on... per regex but adding few other checks just in case the regex fails + if (/^on([a-zA-Z])+$/.test(nameLower) + || blockedAttributes.indexOf(nameLower) > -1 + || nameLower.indexOf('onmouse') === 0 + || nameLower.indexOf('onkey') === 0 + || nameLower.indexOf('onanimation') === 0 + || nameLower.indexOf('ondrag') === 0 + || nameLower.indexOf('onload') === 0 + || nameLower.indexOf('ontransition') === 0 + || nameLower.indexOf('oncomposition') === 0 + || nameLower.indexOf('ontouch') === 0) { + // do not execute any onload method or when we set form element values + return node; + } + + if (node.tagName === 'LINK') { + if (nameLower === 'crossorigin') { + // cross origin relevant for images only, not for scripts as we rename them anyway + return node + } + + if (nameLower === 'integrity') { + // hash of a file should be ignored as file fetched later might have different hash etc + return node + } + + if (nameLower === 'referrerpolicy') { + // do not overwrite our policy + return node + } + + if (requireSecureProtocol) { + if (nameLower === 'href' && value && String(value).indexOf('http:') === 0) { + value = convertUrlToSecureProtocolIfNeeded(value); + node.setAttribute(name, value); + return node; + } + } + } + + if (node.tagName === 'IMG') { + var isHeatmap = window.location.search.indexOf('idLogHsr') === -1; + //To support images rendered using lazy load, we check if allowed dataset attributes are set and no src attributes are present replace it with available data attributes + if (isHeatmap && (typeof node.attributes.src === "undefined" || node.getAttribute('src') === '') && Object.keys(node.dataset).length) { + var allowedDatasetsToReplaceImageSrc = ['src', 'original', 'lazy']; + var newSrcValue = ''; + for (var i = 0; i < allowedDatasetsToReplaceImageSrc.length; i++) { + if (typeof node.dataset[allowedDatasetsToReplaceImageSrc[i]] !== "undefined" && node.dataset[allowedDatasetsToReplaceImageSrc[i]]) { + newSrcValue = node.dataset[allowedDatasetsToReplaceImageSrc[i]]; + break; + } + } + + //srcset is also used to lazy load with responsive images + //the value of srcset is "{imagePath} screenSize1,{imagePath} screenSize2" for responsive lazy load + //Eg data-scrset="images/400.jpg 400w, images/400.webp 400w, images/600.jpg 600w" + //we pick the last size and to determine it by checking the last character has w + // if we cannot find last character as 'w' we just set the src else we replace with the last size determined + if (newSrcValue === '' && typeof node.dataset.srcset !== "undefined" && node.dataset.srcset) { + var srcSetValue = node.dataset.srcset; + var srcSetLength = srcSetValue.length; + if (srcSetValue[srcSetLength-1] === 'w') { + var splitSrcSetSizes = srcSetValue.split('w,'); + var lastSizeValue = splitSrcSetSizes[splitSrcSetSizes.length-1]; + newSrcValue = lastSizeValue.replace( / \d+w/g,'').trim(); + } else { + newSrcValue = srcSetValue; + } + } + if (newSrcValue) { + node.setAttribute('src', convertUrlToSecureProtocolIfNeeded(newSrcValue)); + } + } + + if (requireSecureProtocol) { + if (nameLower === 'src' && value && String(value).indexOf('http:') === 0) { + value = convertUrlToSecureProtocolIfNeeded(value); + node.setAttribute(name, value); + return node; + } + } + + if (nameLower === 'referrerpolicy') { + // do not overwrite our policy + return node + } + } + + if (node.tagName === 'META') { + if (self.isBlockedMetaAttributeMutation(node, name, value)) { + // the createElement delegate only sees the attributes an element is created with, + // so we also need to prevent a later mutation from turning a harmless meta element + // into a dangerous one + return node; + } + } + + if (node.tagName === 'FORM') { + if (requireSecureProtocol) { + if (nameLower === 'action' && value && String(value).indexOf('http:') === 0) { + value = convertUrlToSecureProtocolIfNeeded(value); + node.setAttribute(name, value); + return node; + } + } + } + + if (node.tagName === 'IFRAME') { + node.setAttribute('sandbox', self.getIframeSandboxValue(value && nameLower === 'src' ? value : node)); + + if (requireSecureProtocol) { + if (nameLower === 'src' && value && String(value).indexOf('http:') === 0) { + value = convertUrlToSecureProtocolIfNeeded(value); + node.setAttribute(name, value); + return node; + } + } + + if (nameLower === 'referrerpolicy') { + // do not overwrite our policy + return node + } + if (nameLower === 'sandbox') { + // do not overwrite our policy + return node + } + } + + if (node.tagName === 'BASE') { + if (requireSecureProtocol) { + if (nameLower === 'href' && value && String(value).indexOf('http:') === 0) { + value = convertUrlToSecureProtocolIfNeeded(value); + node.setAttribute(name, value); + return node; + } + } + } + } + }); + + if (event) { + this.mirror.initialize(event.rootId, event.children); + + this.addClass('html', 'piwikHsr'); + this.addClass('html', 'matomoHsr'); + } + }; + + this.addClass = function (selector, className) { + $(selector).addClass(className); + }; + + this.addWorkaroundForSharepointHeatmaps = function () { + // only needed for heatmaps... instead of having a scrollable element we show all content + var doc = document.getElementById('s4-workspace'); + if (doc && doc.tagName === 'DIV' && doc.className && String(doc.className).indexOf('ms-core-overlay') >= 0) { + doc.style += ';height: auto !important;width: auto !important'; + } + }; + + this.applyMutation = function (event) { + if (event) { + this.mirror.applyChanged(event.rem || [], event.adOrMo || [], event.att || [], event.text || []); + + // the tree mirror does not consult our delegate when it moves nodes, and re-inserting + // an iframe reloads it, so we restore the sandbox only on the moved subtree + var addedOrMoved = event.adOrMo || []; + for (var i = 0; i < addedOrMoved.length; i++) { + var movedNode = this.mirror.getNode ? this.mirror.getNode(addedOrMoved[i].id) : null; + this.restoreIframeSandboxPolicy(movedNode); + } + } + }; + this.trim = function(text) + { + if (text && String(text) === text) { + return text.replace(/^\s+|\s+$/g, ''); + } + + return text; + }; + + this.parseExcludedElementSelectors = function (excludedElements) { + if (!excludedElements) { + return []; + } + + excludedElements = String(excludedElements); + excludedElements = this.trim(excludedElements); + + if (!excludedElements) { + return []; + } + + excludedElements = excludedElements.split(','); + + if (!excludedElements || !excludedElements.length) { + return []; + } + + var selectors = []; + for (var i = 0; i < excludedElements.length; i++) { + var selector = this.trim(excludedElements[i]); + selectors.push(selector); + } + return selectors; + }; + + this.excludeElements = function (excludedElements) { + excludedElements = this.parseExcludedElementSelectors(excludedElements); + if (!excludedElements || !excludedElements.length) { + return; + } + + var self = this; + + var style = (function() { + var style = document.createElement('style'); + style.appendChild(document.createTextNode('')); + document.head.appendChild(style); + + return style; + })(); + + for (var i = 0; i < excludedElements.length; i++) { + var selector = this.decodeHTMLEntities(excludedElements[i]); // decodeHTMLEntities fixes cases like p>a[href="test.html"] or a[href="test.html"] as the quotes are escaped and results in error + if (selector && style && style.sheet) { + if('insertRule' in style.sheet) { + style.sheet.insertRule(selector + "{ visibility: hidden; }", i); + } else if('addRule' in sheet) { + style.sheet.addRule(selector, 'visibility: hidden; ', i); + } + + } + } + }; + + this.decodeHTMLEntities = function (text) { + var textArea = document.createElement('textarea'); + textArea.innerHTML = text; + return textArea.value; + }; + + this.getCoordinatesInFrame = function (selector, offsetx, offsety, offsetAccuracy, ignoreHiddenElement) { + selector = this.sanitizeSelector(selector); + var $node = $(selector); + + if (!$node.length) { + if (this.enableDebugMode) { + if ('undefined' !== typeof console && 'undefined' !== typeof console.log){ + console.log(selector, 'selector not found'); + } + } + return; + } + + var width = $node.outerWidth(); + var height = $node.outerHeight(); + + if (ignoreHiddenElement && ignoreHiddenElement === true && width === 0 || height === 0 || !$node.is(':visible')) { + // not visible + return; + } + + var width = width / offsetAccuracy; + var height = height / offsetAccuracy; + var coordinates = $node.offset(); + + var dataPoint = { + x: parseInt(coordinates.left, 10) + parseInt(offsetx * width, 10), + y: parseInt(coordinates.top, 10) + parseInt(offsety * height, 10), + } + + return dataPoint; + }; + + this.getScrollTop = function () { + return $(window).scrollTop(); + }; + + this.getScrollLeft = function () { + return $(window).scrollLeft(); + }; + + this.getIframeHeight = function () { + var documentHeight = Math.max(document.body ? document.body.offsetHeight : 0, document.body ? document.body.scrollHeight : 0, document.documentElement ? document.documentElement.offsetHeight : 0, document.documentElement ? document.documentElement.clientHeight : 0, document.documentElement ? document.documentElement.scrollHeight : 0); + return documentHeight; + }; + + this.getIframeWidth = function () { + var documentHeight = Math.max(document.body ? document.body.offsetWidth : 0, document.body ? document.body.scrollWidth : 0, document.documentElement ? document.documentElement.offsetWidth : 0, document.documentElement ? document.documentElement.clientWidth : 0, document.documentElement ? document.documentElement.scrollWidth : 0); + return documentHeight; + }; + +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/javascripts/rowaction.js b/files/plugin-HeatmapSessionRecording-5.6.0/javascripts/rowaction.js new file mode 100644 index 0000000..a9149f0 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/javascripts/rowaction.js @@ -0,0 +1,211 @@ +/*! + * Copyright (C) InnoCraft Ltd - All rights reserved. + * + * NOTICE: All information contained herein is, and remains the property of InnoCraft Ltd. + * The intellectual and technical concepts contained herein are protected by trade secret or copyright law. + * Redistribution of this information or reproduction of this material is strictly forbidden + * unless prior written permission is obtained from InnoCraft Ltd. + * + * You shall use this code only in accordance with the license agreement obtained from InnoCraft Ltd. + * + * @link https://www.innocraft.com/ + * @license For license details see https://www.innocraft.com/license + */ + +/** + * This file registers the Overlay row action on the pages report. + */ + +(function () { + + var actionNameProfile = 'HsrVisitorProfile'; + var actionNamePlay = 'HsrPlayRecording'; + var actionNameDelete = 'HsrDeleteRecording'; + + function getMetadataFromRow(tr) + { + if (tr) { + return JSON.parse($(tr).attr('data-row-metadata') || '{}'); + } + } + + function DataTable_RowActions_HsrVisitorProfile(dataTable) { + this.dataTable = dataTable; + this.actionName = actionNameProfile; + } + + DataTable_RowActions_HsrVisitorProfile.prototype = new DataTable_RowAction(); + + DataTable_RowActions_HsrVisitorProfile.prototype.trigger = function (tr, e, subTableLabel) { + var metadata = getMetadataFromRow(tr); + broadcast.propagateNewPopoverParameter('visitorProfile', metadata.idvisitor); + }; + + function DataTable_RowActions_HsrPlayRecording(dataTable) { + this.dataTable = dataTable; + this.actionName = actionNamePlay; + } + + DataTable_RowActions_HsrPlayRecording.prototype = new DataTable_RowAction(); + + DataTable_RowActions_HsrPlayRecording.prototype.trigger = function (tr, e, subTableLabel) { + var metadata = getMetadataFromRow(tr); + var idsite = parseInt(this.dataTable.param.idSite, 10); + var idLogHsr = parseInt(metadata.idloghsr, 10); + var idSiteHsr = parseInt(metadata.idsitehsr, 10); + window.open('?module=HeatmapSessionRecording&action=replayRecording&idSite=' + idsite + '&idLogHsr=' + idLogHsr + '&idSiteHsr=' + idSiteHsr); + }; + + function DataTable_RowActions_HsrDeleteRecording(dataTable) { + this.dataTable = dataTable; + this.actionName = actionNameDelete; + } + + DataTable_RowActions_HsrDeleteRecording.prototype = new DataTable_RowAction(); + + DataTable_RowActions_HsrDeleteRecording.prototype.trigger = function (tr, e, subTableLabel) { + var metadata = getMetadataFromRow(tr); + + var idloghsr = metadata.idloghsr; + var idsitehsr = metadata.idsitehsr; + + var params = { + module: 'API', + method: 'HeatmapSessionRecording.deleteRecordedPageview', + idLogHsr: idloghsr, + idSiteHsr: idsitehsr, + format: 'json' + }; + + if (metadata.idvisit) { + params.method = 'HeatmapSessionRecording.deleteRecordedSession'; + params.idVisit = metadata.idvisit; + } + + var ajaxRequest = new ajaxHelper(); + ajaxRequest.withTokenInUrl(); + ajaxRequest.addParams(params, 'get'); + ajaxRequest.setCallback(function (id) { }); + ajaxRequest.send(); + + // we directly remove the node for better UX and assume the removal works + $(tr).remove(); + }; + + DataTable_RowActions_Registry.register({ + name: actionNameProfile, + + dataTableIcon: 'icon-visitor-profile', + + order: 53, + + dataTableIconTooltip: [ + _pk_translate('Live_ViewVisitorProfile'), + '' + ], + + isAvailableOnReport: function (dataTableParams, undefined) { + return dataTableParams && dataTableParams.module === 'HeatmapSessionRecording' && dataTableParams.action === 'getRecordedSessions' && piwik.visitorProfileEnabled; + }, + + isAvailableOnRow: function (dataTableParams, tr) { + return true; + }, + + createInstance: function (dataTable, param) { + if (dataTable !== null && typeof dataTable.hsrVisitorProfileInstance !== 'undefined') { + return dataTable.hsrVisitorProfileInstance; + } + + var instance = new DataTable_RowActions_HsrVisitorProfile(dataTable); + if (dataTable !== null) { + dataTable.hsrVisitorProfileInstance = instance; + } + + return instance; + } + }); + + + DataTable_RowActions_Registry.register({ + name: actionNamePlay, + + dataTableIcon: 'icon-play', + + order: 51, + + dataTableIconTooltip: [ + _pk_translate('HeatmapSessionRecording_PlayRecordedSession'), + '' + ], + + isAvailableOnReport: function (dataTableParams, undefined) { + return dataTableParams && dataTableParams.module === 'HeatmapSessionRecording' && dataTableParams.action === 'getRecordedSessions'; + }, + + isAvailableOnRow: function (dataTableParams, tr) { + return true; + }, + + createInstance: function (dataTable, param) { + if (dataTable !== null && typeof dataTable.hsrPlayRecordingInstance !== 'undefined') { + return dataTable.hsrPlayRecordingInstance; + } + + var instance = new DataTable_RowActions_HsrPlayRecording(dataTable); + if (dataTable !== null) { + dataTable.hsrPlayRecordingInstance = instance; + } + + return instance; + } + }); + + + DataTable_RowActions_Registry.register({ + name: actionNameDelete, + + dataTableIcon: 'icon-delete', + + order: 55, + + dataTableIconTooltip: [ + _pk_translate('HeatmapSessionRecording_DeleteRecordedSession'), + '' + ], + + isAvailableOnReport: function (dataTableParams, undefined) { + if (!dataTableParams || !dataTableParams.writeAccess) { + return false; + } + + return dataTableParams && dataTableParams.module === 'HeatmapSessionRecording' && dataTableParams.action === 'getRecordedSessions'; + }, + + isAvailableOnRow: function (dataTableParams, tr) { + var metadata = getMetadataFromRow(tr); + + if (metadata.idvisit) { + this.dataTableIconTooltip[0] = _pk_translate('HeatmapSessionRecording_DeleteRecordedSession'); + } else { + this.dataTableIconTooltip[0] = _pk_translate('HeatmapSessionRecording_DeleteRecordedPageview'); + } + + return true; + }, + + createInstance: function (dataTable, param) { + if (dataTable !== null && typeof dataTable.hsrDeleteRecordingInstance !== 'undefined') { + return dataTable.hsrDeleteRecordingInstance; + } + + var instance = new DataTable_RowActions_HsrDeleteRecording(dataTable); + if (dataTable !== null) { + dataTable.hsrDeleteRecordingInstance = instance; + } + + return instance; + } + }); + +})(); diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/am.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/am.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/am.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/ar.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/ar.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/ar.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/az.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/az.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/az.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/be.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/be.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/be.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/bg.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/bg.json new file mode 100644 index 0000000..71d6990 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/bg.json @@ -0,0 +1,7 @@ +{ + "HeatmapSessionRecording": { + "Heatmap": "Топлинна карта", + "Heatmaps": "Топлинни карти", + "Recordings": "Записи" + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/bn.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/bn.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/bn.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/bs.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/bs.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/bs.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/ca.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/ca.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/ca.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/cs.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/cs.json new file mode 100644 index 0000000..5d9e0c0 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/cs.json @@ -0,0 +1,190 @@ +{ + "HeatmapSessionRecording": { + "ActivityClick": "Kliknutí", + "ActivityFormChange": "Změna formuláře", + "ActivityFormText": "Změna textu formuláře", + "ActivityFormValue": "Změna hodnoty formuláře", + "ActivityInitialDom": "Počáteční stránka", + "ActivityMove": "Pohyb", + "ActivityPageChange": "Změnit na stránce", + "ActivityResize": "Změna velikosti", + "ActivityScroll": "Posouování", + "ActivityScrollElement": "Prvek posouvání", + "AdvancedOptions": "Pokročilé možnosti", + "AutoPlayNextPageview": "Klepnutím na %1$s se automaticky přehraje další zobrazení stránky (zástupce %2$s)", + "AvgAboveFoldDescription": "Průměrně návštěvníci vidí obsah nad tímto řádkem bez posouvání", + "AvgAboveFoldTitle": "Prům. nad hranou %spx", + "BreakpointGeneralHelp": "Typ zařízení je automaticky detekován, když si návštěvník prohlíží váš web. Někdy však nemusí být možné zjistit typ zařízení. Pokud vaše webové stránky reagují a vaše rozvržení se v určitém okamžiku rozpadlo, umístíme uživatele stolních počítačů s malým rozlišením do tohoto typu zařízení pro přesnější heatmapy. Do tohoto typu zařízení bude vložena jakákoli šířka menší než tato hodnota. Pokud váš web nereaguje, nastavte jej na nulu.", + "BreakpointGeneralHelpManage": "Uživatel s přístupem Super User může změnit výchozí hodnoty pro tyto body zlomu v Administration => General Settings.", + "BreakpointX": "Bod zlomu %s", + "CaptureKeystrokes": "Zachyťte stisknutí kláves", + "CaptureKeystrokesHelp": "Pokud je povoleno, zaznamená se jakýkoli text, který je zadán do textových polí formuláře. Během záznamu textu je jakýkoli znak, který uživatel zadá, nahrazen hvězdičkou ('*'). Určitá pole, která mají být zaznamenána jako prostý text, můžete povolit zadáním atributu 'data-matomo-unmask' do pole formuláře. Pole hesel a mnoho dalších polí, která by případně mohla obsahovat osobní údaje (adresa, e-mail, informace o kreditní kartě, uživatelské jméno, telefonní číslo, …), však budou automaticky maskována, pokud nás jako takové detekuje (%1$slearn%2$s) . Upozorňujeme, že když tuto funkci povolíte, můžete ukládat osobní údaje, které mohou mít vliv na GDPR.", + "ChangeReplaySpeed": "Změna rychlosti přehrávání (zástupce %s)", + "ClickToSkipPauses": "Kliknutím na %1$s přeskočíte pauzy (zástupce %2$s)", + "ColumnActionsDocumentation": "Přehrajte nahranou relaci nebo natrvalo odeberte záznam.", + "ColumnBrowserDocumentation": "Prohlížeč, který návštěvník používal při záznamu této relace.", + "ColumnDeviceDocumentation": "Zařízení, které návštěvník používal při záznamu této relace.", + "ColumnLabelRecordedSessions": "Vstupní URL → Výstupní URL", + "ColumnLocationDocumentation": "Lokalita návštěvníka, kdy byla tato relace zaznamenána.", + "ColumnOperatingSystem": "OS", + "ColumnOperatingSystemDocumentation": "Operační systém, který návštěvník používal při záznamu této relace.", + "ColumnPageviewsDocumentation": "Počet zobrazení stránek zaznamenaných během této relace.", + "ColumnResolutionDocumentation": "Počet pixelů viditelného výřezu v okně prohlížeče, když uživatel otevřel tuto stránku.", + "ColumnTime": "Čas", + "ColumnTimeDocumentation": "Čas zahájení nahrávání v časovém pásmu webové stránky.", + "ColumnTimeOnPageDocumentation": "Kolik času strávil návštěvník na této stránce během záznamu relace", + "ColumnTimeOnSiteDocumentation": "Kolik času strávil návštěvník celkem během záznamu relace", + "ConfigsInternetDisabled": "Nemohli jsme zkontrolovat, zda je '%s' přístupné, protože je zakázáno připojení k internetu.", + "ConfigsPhpErrorResult": "Výsledkem je, že sledování heatmap a záznamů relací nemusí fungovat. Možná budete muset změnit konfiguraci webového serveru, abyste umožnili přístup k tomuto souboru přes internet nebo intranet.", + "ConfigsPhpManualCheck": "Otevřete prosím adresu URL ručně v prohlížeči a zjistěte, zda odpověď obsahuje 'Piwik.HeatmapSessionRecording'. Pokud tomu tak není, možná budete muset upravit konfiguraci serveru, protože tento soubor musí být přístupný prostřednictvím prohlížeče z Internetu nebo intranetu.", + "ConfigsPhpNotAccessible": "Zdá se, že adresa URL '%s' není přístupná z internetu nebo intranetu.", + "ConfigsPhpSelfSignedError": "Vyžádání '%s' způsobilo chybu SSL. Možná používáte certifikát s vlastním podpisem?", + "ConfigsPhpSuccess": "'%s' je přístupný", + "ConfigsPhpUnknown": "Nemohli jsme zkontrolovat, zda je '%s' přístupný přes internet nebo intranet.", + "ConfigsPhpUnknownError": "Vyžádání '%1$s' způsobilo chybu: %2$s.", + "CreateNewHeatmap": "Vytvořte novou heatmapu", + "CreateNewSessionRecording": "Vytvořte nový záznam relací", + "CreationDate": "Datum vytvoření", + "DeleteHeatmapConfirm": "Opravdu chcete tuto heatmapu odstranit? Zprávy dříve generované heatmapy již nebudou k dispozici v uživatelském rozhraní pro vykazování.", + "DeleteHeatmapScreenshotConfirm": "Opravdu chcete odstranit tento snímek obrazovky? Může chvíli trvat, než bude snímek obrazovky znovu pořízen.", + "DeleteRecordedPageview": "Smazat toto zaznamenané zobrazení stránky (není vyžadováno potvrzení)", + "DeleteRecordedSession": "Smazat tuto zaznamenanou relaci (není vyžadováno potvrzení)", + "DeleteScreenshot": "Odstranit snímek obrazovky", + "DeleteSessionRecordingConfirm": "Opravdu chcete smazat tento záznam relace? Záznamy, které byly dříve vytvořeny, již nebudou v uživatelském rozhraní hlášení k dispozici.", + "DeleteX": "Vymazat %s", + "EditHeatmapX": "Upravit heatmapu %s", + "EditSessionRecordingX": "Upravit záznam relací %s", + "EditX": "Upravit %s", + "EndHeatmapConfirm": "Opravdu chcete přestat zaznamenávat aktivity pro tuto heatmapu?", + "EndSessionRecordingConfirm": "Opravdu chcete zastavit nahrávání nových relací?", + "ErrorArrayMissingKey": "Chybí klíč pole \"%1$s\" v \"%2$s\" na pozici \"%3$s\".", + "ErrorArrayMissingValue": "Chybí hodnota klíče pole \"%1$s\" v \"%2$s\" na pozici \"%3$s\".", + "ErrorHeatmapDoesNotExist": "Požadovaná heatmapa neexistuje", + "ErrorHeatmapNameDuplicate": "Název heatmapy je již používán jinou heatmapou.", + "ErrorInnerIsNotAnArray": "Každé \"%1$s\" v \"%2$s\" musí být pole.", + "ErrorInvalidRegExp": "Regulární výraz \"%1$s\" nemá platný formát.", + "ErrorNotAnArray": "\"%1$s\" musí být pole.", + "ErrorPageRuleRequired": "Musí být nastaveno alespoň jedno pravidlo stránky.", + "ErrorSessionRecordingDoesNotExist": "Požadovaný záznam relace neexistuje", + "ErrorXContainsWhitespace": "\"%1$s\" nesmí obsahovat žádné prázdné místo.", + "ErrorXNotANumber": "\"%1$s\" musí být číslo.", + "ErrorXNotProvided": "Zadejte prosím hodnotu pro \"%1$s\".", + "ErrorXNotWhitelisted": "Hodnota pro \"%1$s\" není povolena, použijte jednu z: %2$s.", + "ErrorXTooHigh": "\"%1$s\"je příliš vysoká, maximální povolená hodnota je %2$s.", + "ErrorXTooLong": "\"%1$s\" je příliš dlouhý, povoleno je maximálně %2$s znaků.", + "ErrorXTooLow": "\"%1$s\"je příliš nízká, minimální povolená hodnota je %2$s.", + "ExcludedElements": "Vyloučené prvky", + "ExcludedElementsHelp": "Volitelně můžete definovat selektory CSS, abyste vyloučili určité prvky, které by neměly být zobrazeny v náhledu heatmapy. Například vyskakovací okno, které se zobrazí při otevření cílové stránky. Můžete oddělit více selektorů čárkou.", + "FieldIncludedTargetsHelp": "Cíle vám umožňují definovat, na kterých stránkách by se měly aktivity uživatelů zaznamenávat. Můžete definovat jednu nebo více podmínek. Můžete například definovat zaznamenávání aktivit, kdykoli se adresa URL nebo cesta rovná určité hodnotě a pouze v případě, že je v adrese URL přítomen určitý parametr adresy URL. Činnosti budou zaznamenány, pouze pokud jsou splněny všechny podmínky, nikoli pokud je splněna pouze jedna z nich. Všechny podmínky budou vyhodnoceny bez ohledu na velikost písmen. Pokud vyberete možnost „rovná se jednoduchému“, protokol URL, parametry vyhledávání a koncové lomítko budou ignorovány.", + "FieldIncludedTargetsHelpSessions": "Cíle vám umožňují nakonfigurovat spuštění nahrávání relace, jakmile návštěvník dosáhne určité stránky. To vám umožní například zaznamenávat pouze relace, které procházejí procesem odhlášení. Můžete definovat jednu nebo více podmínek. Můžete například definovat, že chcete relaci zaznamenat, pouze pokud se adresa URL nebo cesta rovná určité hodnotě a pouze v případě, že je v adrese URL přítomen určitý parametr adresy URL. Relace se zaznamenají pouze tehdy, jsou-li při zobrazení stránky splněny všechny podmínky, nikoli pokud je splněna pouze jedna z nich. Všechny podmínky budou vyhodnoceny bez ohledu na velikost písmen. Pokud vyberete možnost „rovná se jednoduchému“, protokol URL, parametry vyhledávání a koncové lomítko budou ignorovány.", + "FieldNamePlaceholder": "např. 'Stránka registrace'", + "FilesystemDirectory": "adresář", + "Filter": "Filtr", + "GettingStarted": "Začínáme", + "Heatmap": "Heatmapa", + "HeatmapCreated": "Heatmapa byla úspěšně vytvořena.", + "HeatmapNameHelp": "Definuje název, pod kterým bude k dispozici zpráva pro tuto heatmapu.", + "HeatmapSampleLimit": "Počet zobrazení", + "HeatmapSampleLimitHelp": "Definuje celkový počet zobrazení stránky, které chcete celkem zaznamenat.", + "HeatmapSampleRateHelp": "Také známý jako 'provoz'. Pokud vyberete 100%%, budou zaznamenáni všichni návštěvníci, kteří navštíví vybranou cílovou stránku. Pokud vyberete například 10%%, bude zaznamenán pouze každý 10. návštěvník. Čím nižší procento vyberete, tím déle bude trvat dosažení vybraného limitu vzorku.", + "HeatmapUpdated": "Heatmapa byla úspěšně aktualizována.", + "HeatmapUsageBenefits": "Heatmapy umožňují zaznamenávat všechna kliknutí, pohyby myší a procházení aktivit vašich návštěvníků na určité stránce. To vám pomůže zjistit, kde si uživatelé myslí, že na něco lze kliknout, ale není to, zda existují části stránky, které jsou jen stěží prohlíženy nebo s nimi interagovány, co návštěvníci hledají, kolik stránky je viditelné, když si uživatelé prohlíží vaši stránku a další.", + "HeatmapWidth": "Šířka heatmapy", + "HeatmapX": "Heatmapa %s", + "HeatmapXRecordedSamplesSince": "%1$s vzorků bylo nahráno od %2$s.", + "Heatmaps": "Heatmapy", + "Manage": "Spravovat", + "ManageHeatmapSubcategoryHelp": "Heatmaps vám umožní zaznamenat všechna kliknutí, pohyby myší a posouvání vašich návštěvníků na konkrétní stránku. Tato část umožňuje vytvářet a spravovat sledování Heatmap pro váš web.", + "ManageHeatmaps": "Spravujte heatmapy", + "ManageSessionRecordingSubcategoryHelp": "Session recordings vám umožňují zaznamenávat všechny aktivity skutečného návštěvníka během jeho relace (návštěvy), jako jsou kliknutí, pohyby myši, posouvání, změny velikosti okna, změny stránky a interakce formulářů. Tato část umožňuje vytvářet a spravovat záznamy relací pro vaše stránky.", + "ManageSessionRecordings": "Spravujte záznamy relací", + "MinSessionTime": "Min. doba relace", + "MinSessionTimeHelp": "Relace bude zaznamenána, pouze pokud návštěvník strávil alespoň určitou dobu na stránce.", + "NHeatmaps": "%s heatmapy", + "NSessionRecordings": "%s záznamy relací", + "NoHeatmapSamplesRecordedYet": "Pro tuto heatmapu nebylo dosud zaznamenáno žádné zobrazení stránky. Pokud se do této chvíle mají nahrávat, je možné, že cíle stránky nakonfigurované pro tuto heatmapu neodpovídají žádné stránce na vašem webu. Doporučuje se také zkontrolovat „Kontrola systému“ v části „Administrace“ (pouze Super Users), abyste zjistili, zda je váš systém nakonfigurován na automatické zaznamenávání relací.", + "NoHeatmapScreenshotRecordedYet": "Dosud byly zaznamenány vzorky %1$s. Zatím však nebyl pořízen žádný screenshot. Pokud pro tuto heatmapu existuje \"%2$s\", může chvíli trvat, než bude snímek obrazovky dostupný, protože uživatel musí nejprve otevřít tuto adresu URL obrazovky. V závislosti na vzorkovací frekvenci to může chvíli trvat.", + "NoHeatmapsConfiguredInfo": "Momentálně neexistují žádné aktivní heatmapy. Chcete-li zobrazit heatmapy, požádejte uživatele s alespoň přístupem správce o vytvoření nové heatmapy.", + "NoHeatmapsFound": "Heatmapy nenalezeny", + "NoSessionRecordedYet": "Zatím nebyla zaznamenána žádná relace. Pokud se nyní mají zaznamenat relace, možná nakonfigurovaná vstupní stránka neodpovídá žádné stránce na vašem webu. Doporučuje se také zkontrolovat „Kontrola systému“ v části „Administrace“ (pouze Super Users), abyste zjistili, zda je váš systém nakonfigurován na automatické zaznamenávání relací.", + "NoSessionRecordingsConfiguredInfo": "Momentálně neexistují žádné aktivní záznamy relací. Chcete-li zaznamenat nové relace, požádejte uživatele s alespoň přístupem správce o vytvoření nové nahrávky.", + "NoSessionRecordingsFound": "Záznamy relací nenalezeny", + "NotSupportedBrowser": "Tento prohlížeč není podporován, použijte novější verzi nebo zkuste jiný prohlížeč.", + "OnePageview": "1 zobrazení stránky", + "PageRule": "Pravidlo stránky", + "PageviewXofY": "Stránka %1$s z %2$s", + "PageviewsInVisit": "Zaznamenané zobrazení stránek v této relaci", + "PersonalInformationNote": "Upozorňujeme, že %1$s může zaznamenávat osobní nebo citlivé informace. Pokud si přejete skrýt určitý obsah na svém webu nebo v aplikaci, můžete tento obsah označit přidáním atributu %2$sdata-matomo-mask%3$s k HTML značce. %4$sDalší informace%5$s", + "PlayRecordedSession": "Přehrajte tuto zaznamenanou relaci", + "PlayerDurationXofY": "%1$s z %2$s", + "PlayerForwardFast": "Přeskočit %1$s sekund (zkratka %2$s)", + "PlayerPageViewNext": "Přehrát další zobrazení tohoto návštěvníka (%1$s) (zkratka %2$s)", + "PlayerPageViewPrevious": "Přehrát předchozí zobrazení tohoto návštěvníka %1$s (zkratka %2$s)", + "PlayerPause": "Pauza (zkratka %s nebo mezera)", + "PlayerPlay": "Přehrát (zkratka %s nebo mezera)", + "PlayerReplay": "Přehrát (zkratka %s nebo mezera)", + "PlayerRewindFast": "Převinout %1$s sekund (zkratka %2$s)", + "RecordedHeatmapDocStatusActive": "Tato heatmapa je aktivní. Až %1$d zobrazení stránek bude zaznamenáno se vzorkovací frekvencí %2$s.", + "RecordedHeatmapDocStatusEnded": "Tato heatmapa skončila. Nebudou zaznamenány žádné nové aktivity.", + "RecordedSessions": "Záznamy relací", + "RecordedSessionsDocStatusActive": "Toto nahrávání relace je aktivní. Bude zaznamenáno až %1$d relací se vzorkovací frekvencí %2$s.", + "RecordedSessionsDocStatusEnded": "Toto nahrávání relací skončilo a nebudou zaznamenány žádné nové relace.", + "Recordings": "Záznamy", + "ReplayRecordedSession": "Přehrát záznam relace", + "ReplayX": "Přehrát %s", + "ReportRecordedSessionsDocumentation": "Seznam všech zaznamenaných relací. Můžete přehrát jakoukoli zaznamenanou relaci.", + "RequiresActivity": "Vyžaduje aktivitu", + "RequiresActivityHelp": "Pokud je povoleno, budou zaznamenány pouze relace, které mají v jednom zobrazení stránky svitek a aktivitu kliknutí. Tím se zabrání záznamu relací s malou aktivitou.", + "Rule": "Pravidlo", + "SampleLimit": "Limit vzorku", + "SampleRate": "Vzorkovací frekvence", + "ScreenshotUrl": "Adresa URL snímku obrazovky", + "ScreenshotUrlHelp": "Ve výchozím nastavení je snímek obrazovky pořízen, když první návštěvník zobrazí cílovou stránku. Pokud se vaší cílové stránce shodují různé adresy URL stránek, můžete určit konkrétní adresu URL, která by se měla použít k zachycení snímku obrazovky. Vizualizace heatmapy bude k dispozici, pouze pokud alespoň jeden návštěvník navštívil tuto adresu URL, jejíž aktivity jsou zaznamenány. Pokud je definována, musí se adresa URL přesně shodovat. Chcete-li protokol ignorovat, spusťte adresu URL dvěma dvojitými lomítky (//example.com). Upozorňujeme, že tuto adresu URL nelze změnit, jakmile bude pořízen snímek obrazovky.", + "SessionNameHelp": "Definuje název, pod kterým budou záznamy relace k dispozici.", + "SessionRecording": "Záznam relace", + "SessionRecordingCreated": "Záznam relace byl úspěšně vytvořen.", + "SessionRecordingUpdated": "Záznam relace byl úspěšně aktualizován.", + "SessionRecordingX": "Záznam relace %s", + "SessionRecordings": "Záznamy relací", + "SessionRecordingsUsageBenefits": "Záznamy relací umožňují zaznamenávat všechny aktivity skutečného návštěvníka během jejich relace (návštěvy), jako jsou kliknutí, pohyby myši, svitky, změny velikosti okna, změny stránky a interakce formulářů. Poté můžete tyto interakce přehrát, abyste přesně viděli, jak návštěvník interagoval s vaším webem. Tímto způsobem pochopíte jejich očekávání, problémy, které mohou mít, způsoby používání a další.", + "SessionSampleLimit": "Počet relací", + "SessionSampleLimitHelp": "Definuje celkový počet relací, které chcete zaznamenat.", + "SessionSampleRateHelp": "Také známý jako 'provoz'. Pokud vyberete 100%%, budou všechny relace zaznamenány, jakmile dosáhnou cílové stránky. Pokud vyberete například 10%%, zaznamená se pouze každá 10. relace. Čím nižší procento vyberete, tím déle bude trvat dosažení vybraného limitu vzorku.", + "StatusActive": "Aktivní", + "StatusEnded": "Ukončeno", + "StopX": "Přestaňte zaznamenávat nové aktivity %s.", + "TargetAttributePath": "Cesta", + "TargetAttributeUrl": "URL", + "TargetAttributeUrlParameter": "Parametr adresy URL", + "TargetAttributeUrlParameterExample": "nazevParametru", + "TargetPage": "Cílová stránka", + "TargetPageTestErrorInvalidUrl": "Zadejte adresu URL včetně protokolu.", + "TargetPageTestLabel": "Zadejte úplnou adresu URL včetně protokolu a zkontrolujte, zda budou na této adrese URL zaznamenány aktivity:", + "TargetPageTestTitle": "URL validátor", + "TargetPageTestUrlMatches": "Na této adrese URL budou zaznamenány aktivity", + "TargetPageTestUrlNotMatches": "Na této adrese URL nebudou aktivity zaznamenány", + "TargetPages": "Vstupní stránka", + "TargetTypeContains": "obsahuje", + "TargetTypeEqualsExactly": "rovná se přesně", + "TargetTypeEqualsExactlyInfo": "Hodnota se musí přesně shodovat, včetně protokolu URL, vyhledávacího dotazu a hashe.", + "TargetTypeEqualsSimple": "rovná se jednoduché", + "TargetTypeEqualsSimpleInfo": "URL bude odpovídat každému protokolu (např. http a https) s nebo bez subdomény \"www.\". Jakákoli koncová lomka v cestě, stejně jako vyhledávací dotaz a hashovaná část adresy URL, budou při přiřazování adresy URL ignorovány.", + "TargetTypeExists": "existuje", + "TargetTypeIsAny": "je jakýkoliv", + "TargetTypeIsNot": "ne %s", + "TargetTypeRegExp": "odpovídá regulárnímu výrazu", + "TargetTypeRegExpInfo": "Jakýkoli regulární výraz, například \"^(.*)test(.*)$\".", + "TargetTypeStartsWith": "začíná s", + "TimeOnSite": "Čas na stránce", + "TrackingDisabledDefaultSettingDescription": "Tato funkce může být užitečná, pokud má vaše Matomo více webů a chcete tuto funkci používat pouze na několika konkrétních webech. Když ve výchozím nastavení deaktivujete sledování, funkce Heatmap a Session Recording se nespustí na žádném webu, pokud ji výslovně nepovolíte jako součást svého měřicího kódu, například '_paq.push ([' HeatmapSessionRecording :: enable ']);' . Tím se vyhnete zbytečným síťovým požadavkům na soubor 'configs.php', abyste zjistili, zda jsou pro aktuální web nakonfigurovány heatmapy nebo session recording. Může to být také užitečné z důvodů ochrany osobních údajů, pokud se chcete ujistit, že tato funkce bude použita pouze na některých webech.", + "TrackingDisabledDefaultSettingTitle": "Deaktivovat sledování ve výchozím nastavení", + "UpdatingData": "Aktualizuji data…", + "UrlParameterValueToMatchPlaceholder": "Hodnota odpovídající názvu parametru URL", + "UrlXDoesNotLookLikeUrl": "%s nevypadá jako URL. Ujistěte se, že obsahuje protokol jako http: // nebo že začíná na //", + "ViewReport": "Zobrazit report", + "ViewportResolution": "Rozlišení viditelného zobrazení (šířka x výška)", + "XSamples": "%s vzorků", + "disable": "zakázat", + "enable": "povolit" + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/cy.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/cy.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/cy.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/da.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/da.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/da.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/de.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/de.json new file mode 100644 index 0000000..977699e --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/de.json @@ -0,0 +1,80 @@ +{ + "HeatmapSessionRecording": { + "ActivityClick": "Klick", + "ActivityFormChange": "Form-Änderung", + "ActivityMove": "Bewegung", + "AdvancedOptions": "Erweiterte Optionen", + "CaptureKeystrokes": "Tastenanschläge aufzeichnen", + "ColumnTime": "Zeit", + "CreateNewHeatmap": "Neue Heatmap erstellen", + "CreateNewSessionRecording": "Neues Session Recording erstellen", + "CreationDate": "Erstellungsdatum", + "DeleteX": "Lösche %s", + "EditHeatmapX": "Bearbeite Heatmap %s", + "EditSessionRecordingX": "Bearbeite Session Recording %s", + "EditX": "Bearbeite %s", + "ErrorArrayMissingKey": "Fehlender array Schlüssel \"%1$s\" in \"%2$s\" an der Position \"%3$s\".", + "ErrorArrayMissingValue": "Fehlender Wert für den Array-Schlüssel \"%1$s\" in \"%2$s\" bei Position \"%3$s\".", + "ErrorInnerIsNotAnArray": "Jedes \"%1$s\" innerhalb von \"%2$s\" muss ein array sein.", + "ErrorInvalidRegExp": "Der reguläre Ausdruck \"%1$s\" hat kein gültiges Format.", + "ErrorNotAnArray": "\"%1$s\" muss ein array sein.", + "ErrorXContainsWhitespace": "Der \"%1$s\" darf keine Leerzeichen enthalten.", + "ErrorXNotANumber": "\"%1$s\" muss eine Nummer sein.", + "ErrorXNotProvided": "Bitte geben Sie einen Wert für \"%1$s\" an.", + "ErrorXNotWhitelisted": "Der Wert für \"%1$s\" ist nicht erlaubt, verwenden Sie einen von: %2$s.", + "ErrorXTooHigh": "\"%1$s\" ist zu niedrig, der Wert darf maximal %2$s sein.", + "ErrorXTooLong": "\"%1$s\" ist zu lang, maximal %2$s Zeichen sind erlaubt.", + "ErrorXTooLow": "\"%1$s\" ist zu lang, der Wert muss mindestens %2$s sein.", + "FilesystemDirectory": "Verzeichnis", + "Filter": "Filter", + "GettingStarted": "Erste Schritte", + "Heatmap": "Heatmap", + "HeatmapCreated": "Die Heatmap wurde erfolgreich erstellt.", + "HeatmapSampleLimit": "Anzahl der Seitenansichten", + "HeatmapUpdated": "Die Heatmap wurde erfolgreich aktualisiert.", + "HeatmapWidth": "Heatmap breite", + "Manage": "Verwalten", + "ManageHeatmaps": "Heatmap Verwaltung", + "ManageSessionRecordings": "Session Recording Verwaltung", + "NoHeatmapsFound": "Keine Heatmaps gefunden", + "NoSessionRecordingsFound": "Keine Session Recordings gefunden", + "OnePageview": "1 Seitenansicht", + "PageRule": "Seiten-Regel", + "PlayerDurationXofY": "%1$s von %2$s", + "RequiresActivity": "Aktivität benötigt", + "Rule": "Regel", + "SampleLimit": "Limit", + "SampleRate": "Aufzeichnungsrate", + "SessionRecording": "Sitzungsaufzeichnung", + "SessionRecordingCreated": "Das Session Recording wurde erfolgreich erstellt.", + "SessionRecordingUpdated": "Das Session Recording wurde erfolgreich aktualisiert.", + "SessionSampleLimit": "Anzahl der Sessions", + "StatusActive": "Aktiv", + "StatusEnded": "Beendet", + "TargetAttributePath": "Pfad", + "TargetAttributeUrl": "URL", + "TargetAttributeUrlParameter": "URL Parameter", + "TargetAttributeUrlParameterExample": "nameEinesUrlParameters", + "TargetPage": "Ziel-Seite", + "TargetPageTestErrorInvalidUrl": "Geben Sie eine URL inklusive Schema an.", + "TargetPageTestTitle": "URL Prüfer", + "TargetPages": "Einstiegs-Seite", + "TargetTypeContains": "enthält", + "TargetTypeEqualsExactly": "entspricht genau", + "TargetTypeEqualsExactlyInfo": "Der Wert muss genau übereinstimmen, inklusive des URL-Schemas, der URL-Parameter und des URL-Hashes.", + "TargetTypeEqualsSimple": "entspricht einfach", + "TargetTypeEqualsSimpleInfo": "Das URL-Schema (z.B. http and https) und die Subdomain \"www.\" muss nicht übereinstimmen und wird ignoriert. Ein abschließender Schrägstrich im Pfad wie auch URL-Parameter und URL-Hashes werden ebenso ignoriert, wenn die URL verglichen wird.", + "TargetTypeExists": "existiert", + "TargetTypeIsAny": "ist beliebig", + "TargetTypeIsNot": "nicht %s", + "TargetTypeRegExp": "entspricht dem Regulären Ausdruck", + "TargetTypeRegExpInfo": "Beliebiger Regulärer Ausdruck, zum Beispiel \"^(.*)test(.*)$\".", + "TargetTypeStartsWith": "startet mit", + "TimeOnSite": "Zeit auf der Webseite", + "UpdatingData": "Aktualisiere Daten…", + "UrlParameterValueToMatchPlaceholder": "Wert für den URL-Parameter", + "ViewReport": "Gehe zum Report", + "disable": "deaktivieren", + "enable": "aktivieren" + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/dv.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/dv.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/dv.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/el.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/el.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/el.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/en.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/en.json new file mode 100644 index 0000000..e3a20a0 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/en.json @@ -0,0 +1,358 @@ +{ + "HeatmapSessionRecording": { + "Recordings": "Recordings", + "Heatmaps": "Heatmaps", + "Heatmap": "Heatmap", + "HeatmapX": "Heatmap %s", + "NHeatmaps": "%s heatmaps", + "NSessionRecordings": "%s session recordings", + "NoHeatmapsFound": "No heatmaps found", + "NoSessionRecordingsFound": "No session recordings found", + "HeatmapUsageBenefits": "Heatmaps let you record all clicks, mouse moves, and scroll activities of your visitors on a certain page. This helps you to find out where users think something is clickable but is not, whether there are parts of the page that are being barely viewed or interacted with, what your visitors are looking for, how much of the page is visible when users view your page, and more.", + "HeatmapXRecordedSamplesSince": "%1$s samples have been recorded since %2$s.", + "HeatmapTroubleshoot": "Having issues? %1$sLearn more%2$s.", + "SessionRecordingsUsageBenefits": "Session recordings let you record all activities of a real visitor during their session (visit) such as clicks, mouse movements, scrolls, window resizes, page changes, and form interactions. You can then replay these interactions to see exactly how a visitor interacted with your website. This way you understand their expectations, problems they may have, usage patterns, and more.", + "SessionRecordings": "Session Recordings", + "SessionRecording": "Session Recording", + "AutoPlayNextPageview": "Click to %1$s autoplay next page view (shortcut %2$s)", + "ClickToSkipPauses": "Click to %1$s skip pauses (shortcut %2$s)", + "ChangeReplaySpeed": "Change replay speed (shortcut %s)", + "PageviewXofY": "Pageview %1$s of %2$s", + "enable": "enable", + "disable": "disable", + "ReplayX": "Replay %s", + "DeleteRetakeAutomatically": "Delete & retake automatically", + "DeleteRetakeManually": "Delete & retake manually", + "DeleteRetakeSnapshot": "Delete and retake snapshot", + "DeleteRetakeSnapshotDescription": "This will delete the current snapshot for this heatmap. You can choose the method for retaking a new snapshot. Manual snapshots are recommended in case your page loads elements dynamically.", + "DeleteRetakeSnapshotTitle": "Delete and retake snapshot?", + "DeleteSnapshotConfirm": "Delete snapshot", + "DeleteSnapshotDescription": "This will delete the current snapshot for this heatmap.", + "DeleteSnapshotTitle": "Delete snapshot?", + "EditHeatmap": "Edit heatmap", + "EmptyStateNoInteractionsSubtitle": "Matomo is ready to record visits for this page. Once we capture interactions, your heatmap will appear.", + "EmptyStateNoInteractionsTitle": "No interactions recorded yet", + "EmptyStateSnapshotDeletedAutoSubtitle": "A new snapshot will be taken when a visitor views the target page.", + "EmptyStateSnapshotDeletedEndedSubtitle": "This heatmap has finished recording and the snapshot has been deleted. It is no longer possible to add a snapshot to this heatmap.", + "EmptyStateSnapshotDeletedManualSubtitle": "To create a new snapshot, open the tracked page and wait until it has fully loaded. Then execute the following JavaScript code in your browser:", + "EmptyStateSnapshotDeletedPausedSubtitle": "The snapshot has been deleted. This heatmap is paused; a new snapshot will be taken once you resume it.", + "EmptyStateSnapshotDeletedTitle": "Snapshot deleted", + "HeatmapSettings": "Heatmap Settings", + "LoadingHeatmap": "Loading heatmap…", + "ReadTheGuide": "Read the guide", + "CreationDate": "Creation Date", + "SessionRecordingX": "Session Recording %s", + "ReplayRecordedSession": "Replay recorded session", + "RecordedSessions": "Recorded Sessions", + "RecordedSessionsDocStatusActive": "This session recording is active. Up to %1$d sessions will be recorded with a sample rate of %2$s.", + "RecordedSessionsDocStatusEnded": "This session recording has ended and no new sessions will be recorded.", + "RecordedSessionsDocStatusOnHold": "This session recording has not started recording yet, so there is no data to show.", + "RecordedSessionsDocStatusOnHoldWithDate": "This session recording has not started recording yet, so there is no data to show. It is scheduled to start capturing activities on %1$s.", + "RecordedHeatmapDocStatusActive": "This heatmap is active. Up to %1$d page views will be recorded with a sample rate of %2$s.", + "RecordedHeatmapDocStatusEnded": "This heatmap has ended. No new activity will be recorded.", + "RecordedHeatmapDocStatusOnHold": "This heatmap has not started recording yet, so there is no data to show.", + "RecordedHeatmapDocStatusOnHoldWithDate": "This heatmap has not started recording yet, so there is no data to show. It is scheduled to start capturing activities on %1$s.", + "TrackingDisabledDefaultSettingTitle": "Disable tracking by default", + "TrackingDisabledDefaultSettingDescription": "This feature can be useful if your Matomo has multiple sites and you want to use the feature only in a few specific sites. When disabling tracking by default, the Heatmap and Session Recording feature won't execute on any site unless you specifically enable it as part of your tracking code for example by calling '_paq.push(['HeatmapSessionRecording::enable']);'. This avoids unnecessary network requests to the 'configs.php' file to determine if a heatmap or session recording is configured for the current site. It can also be useful for privacy reasons if you want to make sure the feature will be only used on some sites.", + "NotifyFinishedSetting": "Notify me when a heatmap or session recording has finished collecting data.", + "NotifyFinishedSettingHelp": "When enabled, you will receive an email when your heatmaps and session recordings are complete and available to review.", + "EmailHeatmapFinishedSubject": "Your Heatmap has finished collecting data", + "EmailSessionRecordingFinishedSubject": "Your Session Recording has finished collecting data", + "EmailHeatmapFinishedBody": "Your heatmap %1$s has finished collecting data. You can review the results in %2$s.", + "EmailSessionRecordingFinishedBody": "Your session recording %1$s has finished collecting data. You can review the results in %2$s.", + "EmailHeatmapFinishedBodyV2": "Your heatmap %1$s has finished collecting data. %2$s", + "EmailSessionRecordingFinishedBodyV2": "Your session recording %1$s has finished collecting data. %2$s", + "EmailViewResultsLink": "View the results in Matomo", + "EmailUnsubscribeText": "To unsubscribe from heatmap and session recording email notifications, please follow this link: %1$s", + "EmailFinishedGreeting": "Hi there,", + "EmailFinishedRegards": "Regards,", + "EmailFinishedTeam": "The Matomo Team", + "Unsubscribe": "Unsubscribe", + "UnsubscribeTitle": "Unsubscribe from heatmap and session recording emails", + "UnsubscribeConfirmation": "Are you sure you want to unsubscribe from heatmap and session recording email notifications?", + "UnsubscribeConfirmationDetail": "You will no longer receive emails when heatmaps or session recordings finish collecting data.", + "UnsubscribeSuccess": "You have been successfully unsubscribed from heatmap and session recording email notifications.", + "UnsubscribeNoTokenProvided": "No token was provided in the URL.", + "UnsubscribeInvalid": "This unsubscribe link is invalid or has expired.", + "StatusActive": "Active", + "StatusEnded": "Ended", + "StatusPaused": "Paused", + "StatusOnHold": "On-hold", + "StatusOnHoldHelp": "This heatmap will start tracking on %1$s.", + "StatusOnHoldHelpSessionRecording": "This session recording will start tracking on %1$s.", + "ExcludedElements": "Excluded Elements", + "ExcludedElementsHelp": "You can optionally define CSS selectors to exclude certain elements that should not be visible in the heatmap preview. For example a popup that is shown when the target page is opened. You can separate multiple selectors with a comma.", + "Rule": "Rule", + "Manage": "Manage", + "ViewReport": "View report", + "ErrorXNotANumber": "\"%1$s\" has to be a number.", + "SampleLimit": "Sample Limit", + "TimeOnSite": "Time on website", + "HeatmapSampleLimit": "Number of page views", + "SessionSampleLimit": "Number of sessions", + "HeatmapSampleLimitHelp": "Defines the amount of page views you want to record in total.", + "SessionSampleLimitHelp": "Defines the amount of sessions you want to record in total.", + "PageviewsInVisit": "Recorded pageviews in this session", + "PlayRecordedSession": "Play this recorded session", + "DeleteRecordedSession": "Delete this recorded session (no confirmation asked)", + "DeleteRecordedPageview": "Delete this recorded page view (no confirmation asked)", + "ViewportResolution": "Viewport Resolution (width x height)", + "OnePageview": "1 pageview", + "TargetPage": "Target Page", + "TargetPages": "Entry Pages", + "ActivityClick": "Click", + "ActivityMove": "Move", + "ActivityScroll": "Scroll", + "ActivityScrollElement": "Scroll Element", + "ActivityResize": "Resize", + "ActivityFormChange": "Form Change", + "ActivityFormText": "Form Text Change", + "ActivityFormValue": "Form Value Change", + "ActivityInitialDom": "Initial Page", + "ActivityPageChange": "Change Within Page", + "PlayerRewindFast": "Rewind %1$s seconds (shortcut %2$s)", + "PlayerForwardFast": "Skip %1$s seconds (shortcut %2$s)", + "PlayerPageViewPrevious": "Play previous pageview of this visitor %1$s (shortcut %2$s)", + "PlayerPageViewNext": "Play next pageview of this visitor (%1$s) (shortcut %2$s)", + "PlayerPlay": "Play (shortcut %s or space)", + "PlayerPause": "Pause (shortcut %s or space)", + "PlayerReplay": "Replay (shortcut %s or space)", + "PlayerDurationXofY": "%1$s of %2$s", + "AdvancedOptions": "Advanced options", + "ShowAdvancedOptions": "Show advanced options", + "HideAdvancedOptions": "Hide advanced options", + "AvgFold": "Avg. fold %spx", + "XSamples": "%s samples", + "AvgAboveFoldDescription": "On average visitors see the content above this line without scrolling", + "NoSessionRecordedYet": "No session has been recorded yet. If there are supposed to be recorded sessions by now, maybe the configured entry page does not match any page on your website. It is also recommended to check the \"System Check\" under \"Administration\" (only Super Users) to see if your system is configured to automatically recorded sessions.", + "NoHeatmapSamplesRecordedYet": "No page view has been recorded yet for this heatmap. If there are supposed to be recordings by now, maybe the page targets configured for this heatmap do not match any page on your website. It is also recommended to check the \"System Check\" under \"Administration\" (only Super Users) to see if your system is configured to automatically track Heatmaps.", + "NoSessionRecordedYetWithoutSystemConfiguration": "No session has been recorded yet. If there are supposed to be recorded sessions by now, maybe the configured entry page does not match any page on your website.", + "NoHeatmapSamplesRecordedYetWithoutSystemConfiguration": "No page view has been recorded yet for this heatmap. If there are supposed to be recordings by now, maybe the page targets configured for this heatmap do not match any page on your website.", + "NoHeatmapScreenshotRecordedYet": "There have been %1$s samples recorded so far. However, no screenshot has been taken yet. If there is a \"%2$s\" for this heatmap, it may take a while for the screenshot to become available as a user first needs to open this screenshot URL. Depending on the sample rate this may take a while.", + "ColumnPageviewsDocumentation": "The number of page views that were recorded during this session.", + "ColumnBrowserDocumentation": "The browser the visitor was using when this session was recorded.", + "ColumnDeviceDocumentation": "The device the visitor was using when this session was recorded.", + "ColumnLocationDocumentation": "The location of the visitor when this session was recorded.", + "ColumnOperatingSystem": "OS", + "ColumnTime": "Time", + "ColumnLabelRecordedSessions": "Entry URL → Exit URL", + "ColumnOperatingSystemDocumentation": "The operating system the visitor was using when this session was recorded.", + "ColumnActionsDocumentation": "Play the recorded session or remove the recording permanently.", + "ColumnTimeDocumentation": "The time the recording started in the timezone of the website.", + "ColumnTimeOnSiteDocumentation": "How much time the visitor spent in total while the session was recorded", + "ColumnTimeOnPageDocumentation": "How much time the visitor spent on this page while the session was recorded", + "ColumnResolutionDocumentation": "The number of pixels of the visible viewport in the browser window when the user opened this page.", + "UrlXDoesNotLookLikeUrl": "The %s does not look like a URL. Make sure it contains a protocol like http:\/\/ or that it starts with \/\/", + "ReportRecordedSessionsDocumentation": "A list of all sessions that have been recorded. You can replay any recorded session.", + "NoHeatmapsConfiguredInfo": "There are currently no active heatmaps. To view heatmaps, please ask a user with at least admin access to create a new heatmap.", + "NoSessionRecordingsConfiguredInfo": "There are currently no active session recordings. To record new sessions, please ask a user with at least admin access to create a new recording.", + "NotSupportedBrowser": "This browser is not supported, please use a later version or try a different browser.", + "HeatmapWidth": "Heatmap width", + "Width": "Width", + "CustomWidth": "Custom width", + "LegendLow": "Low", + "LegendHigh": "High", + "WaitingForFirstRecording": "Waiting for first recording", + "TopClickedElements": "Top clicked elements", + "NoClickedElements": "No clicks recorded yet.", + "ScrollReach": "Scroll reach", + "NoScrollData": "No scroll data yet.", + "ScrollReachTop": "Top", + "ScrollReachBottom": "Bottom", + "TopClickedElementLabel": "'%1$s' %2$s", + "ElementTypeArticle": "article", + "ElementTypeButton": "button", + "ElementTypeField": "field", + "ElementTypeFooter": "footer", + "ElementTypeForm": "form", + "ElementTypeHeader": "header", + "ElementTypeHeading": "heading", + "ElementTypeImage": "image", + "ElementTypeLink": "link", + "ElementTypeList": "list", + "ElementTypeMainContent": "main content", + "ElementTypeNavigation": "navigation", + "ElementTypeSection": "section", + "ElementTypeSidebar": "sidebar", + "Action": "Action", + "ExportImage": "Export image", + "ExportImageError": "The heatmap image could not be exported. Some content on the recorded page could not be captured.", + "ExportImageInProgress": "Generating image…", + "DeviceType": "Device Type", + "BreakpointX": "Breakpoint %s", + "BreakpointGeneralHelp": "The device type is automatically detected when a visitor views your website. However, sometimes it might not be possible to detect the device type. If your website is responsive and your layout breaks at a certain point, we will put desktop users with a small resolution into this device type for more accurate heatmaps. Any width lower than this value will be put into this device type. Set it to zero if your website is not responsive.", + "BreakpointGeneralHelpManage": "A user with Super User access can change the default values for these breakpoints in Administration => General Settings.", + "BreakpointMobileManageHelp": "If the device type cannot be automatically detected, any visitor with a lower width than this value will be put into the mobile device type.", + "BreakpointTabletManageHelp": "If the device type cannot be automatically detected, any visitor with a lower width than this value and higher than the mobile breakpoint will be put into the tablet device type.", + "CaptureKeystrokes": "Capture keystrokes", + "CaptureKeystrokesHelp": "If enabled, any text that is entered into text form fields are recorded. While the text is recorded, any character a user enters is replaced with a star ('*'). You may whitelist certain fields to be recorded in plain text by specifying a 'data-matomo-unmask' attribute on a form field. However, password fields and many other fields which could potentially include personal information (address, email, credit card information, username, phone number, …) will be always automatically masked if detected by us as such (%1$slearn more%2$s). Please note that when you enable this feature, you may record personal data which may affect GDPR.", + "PersonalInformationNote": "Please note a %1$s may record personal or sensitive information. If you wish to hide certain content on your website or app, you may tag this content by adding a %2$sdata-matomo-mask%3$s attribute to the HTML markup. %4$sLearn more%5$s", + "RequiresActivity": "Requires activity", + "RequiresActivityHelp": "If enabled, only sessions that have a scroll and a click activity in one page view will be recorded. This prevents the recording of sessions with not much activity.", + "SampleRate": "Sample Rate", + "HeatmapSampleRateHelp": "Also known as 'traffic'. When you select 100%%, all visitors that visit the selected target page will be recorded. When you select for example 10%%, only every 10th visitor will be recorded. The lower the percentage you select, the longer it will take to reach the selected sample limit.", + "SessionSampleRateHelp": "Also known as 'traffic'. When you select 100%%, all sessions will be recorded as soon as they have reached the target page. When you select for example 10%%, only every 10th session will be recorded. The lower the percentage you select, the longer it will take to reach the selected sample limit.", + "MinSessionTime": "Min Session Time", + "MinSessionTimeHelp": "A session will be only recorded when a visitor has spent at least the specified time on a page.", + "ScreenshotUrl": "Screenshot URL", + "ScreenshotUrlHelp": "By default, a screenshot is taken when the first visitor views the target page. If different page URLs match your target page, you can specify a specific URL that should be used to capture the screenshot. The heatmap visualization will be only available once at least one visitor has visited this URL whose activities are recorded. If defined, the URL has to match exactly. To ignore the protocol, start the URL with two double slashes (\/\/example.com). Please note that this URL cannot be changed as soon as a screenshot has been taken.", + "GettingStarted": "Getting Started", + "TargetPageTestTitle": "URL validator", + "TargetPageTestLabel": "Enter a full URL including the protocol to check if activities will be recorded on this URL:", + "TargetPageTestErrorInvalidUrl": "Enter a URL including a protocol.", + "TargetPageTestUrlMatches": "Activities will be recorded on this URL", + "TargetPageTestUrlNotMatches": "Activities will not be recorded on this URL", + "UrlParameterValueToMatchPlaceholder": "Value to match for URL parameter name", + "TargetTypeIsAny": "is any", + "TargetTypeIsNot": "not %s", + "TargetTypeEqualsExactly": "equals exactly", + "TargetTypeEqualsExactlyInfo": "The value has to match exactly, including the URL protocol, search query and hash.", + "TargetTypeEqualsSimple": "equals simple", + "TargetTypeEqualsSimpleInfo": "The URL will match any protocol (eg http and https) with or without \"www.\" subdomain. Any trailing slash in the path as well as the search query and hash part of the URL will be ignored when matching the URL.", + "TargetTypeContains": "contains", + "TargetTypeExists": "exists", + "TargetTypeStartsWith": "starts with", + "TargetTypeRegExp": "matches the regular expression", + "TargetTypeRegExpInfo": "Any regular expression, for example \"^(.*)test(.*)$\".", + "UpdatingData": "Updating data…", + "FieldIncludedTargetsHelp": "Targets allow you to define on which pages the user activities should be recorded. You can define one or more conditions. For example, you can define to record activities whenever the URL or path equals a certain value and only if a certain URL parameter is present in the URL. Activities will be recorded only when all conditions are met, not if only one of them is met. All conditions will be evaluated case-insensitive. When you select 'equals simple', the URL protocol, search parameters and a trailing slash will be ignored.", + "FieldIncludedTargetsHelpSessions": "Targets allow you to configure to only start the recording of a session as soon as a visitor has reached a certain page. This lets you for example only record sessions that go through the check out process. You can define one or more conditions. For example, you can define to record a session only when the URL or path equals a certain value and only if a certain URL parameter is present in the URL. Sessions will be only recorded when all conditions are met on a page view, not if only one of them is met. All conditions will be evaluated case-insensitive. When you select 'equals simple', the URL protocol, search parameters and a trailing slash will be ignored.", + "FieldIncludedTargetsHelpSimple": "Matomo records activity when the page matches the condition entered here. When you select 'equals simple', the URL protocol, search parameters and a trailing slash will be ignored. Use advanced targeting instead if you need paths, URL parameters, regular expressions, exclusions, or multiple rules. %1$sLearn more%2$s.", + "FieldIncludedTargetsHelpSimpleSessions": "Matomo records a session when the page matches the condition entered here. When you select 'equals simple', the URL protocol, search parameters and a trailing slash will be ignored. Use advanced targeting instead if you need paths, URL parameters, regular expressions, exclusions, or multiple rules. %1$sLearn more%2$s.", + "FieldIncludedTargetsHelpAdvanced": "Activities will be recorded only when all conditions are met, not if only one of them is met. All conditions will be evaluated case-insensitive. When you select 'equals simple', the URL protocol, search parameters and a trailing slash will be ignored. %1$sLearn more%2$s.", + "FieldIncludedTargetsHelpAdvancedSessions": "Sessions will be only recorded when all conditions are met on a page view, not if only one of them is met. All conditions will be evaluated case-insensitive. When you select 'equals simple', the URL protocol, search parameters and a trailing slash will be ignored. %1$sLearn more%2$s.", + "UseAdvancedTargeting": "Need more control? %1$sUse advanced targeting instead%2$s", + "UseSimpleTargeting": "Prefer a simple target page condition? %1$sUse simple targeting instead%2$s.", + "DeleteHeatmapConfirm": "Are you sure you want to delete this heatmap? Reports previously generated heatmaps will no longer be available in the reporting UI.", + "DeleteSessionRecordingConfirm": "Are you sure you want to delete this session recording? Recordings previously generated will no longer be available in the reporting UI.", + "EndSessionRecordingConfirm": "Are you sure you want to stop recording any new sessions?", + "EndSessionRecordingRepeatNotice": "This session recording is set to automatically repeat, so stopping it will create a copy scheduled to start tracking about 30 days from now.", + "EndHeatmapConfirm": "Are you sure you want to stop capturing any activities for this heatmap?", + "EndHeatmapRepeatNotice": "This heatmap is set to automatically repeat, so stopping it will create a copy scheduled to start tracking about 30 days from now.", + "ErrorInvalidRegExp": "The regular expression \"%1$s\" does not have a valid format.", + "ErrorHeatmapNameDuplicate": "The heatmap name is already in use by another heatmap.", + "ErrorHeatmapDoesNotExist": "The requested heatmap does not exist", + "ErrorSessionRecordingDoesNotExist": "The requested session recording does not exist", + "ErrorSessionRecordingDisabled": "The session recording feature is disabled. To use this feature it needs to be enabled by a Matomo super user in the general settings.", + "ErrorHeatmapRecordingDisabled": "The heatmap recording feature is disabled. To use this feature it needs to be enabled by a Matomo super user in the general settings.", + "ErrorSnapshotManualRetakeOnlyActive": "A snapshot can only be retaken manually for active heatmaps.", + "ErrorXNotProvided": "Please provide a value for \"%1$s\".", + "ErrorXTooLow": "\"%1$s\" is too low, minimum allowed value is %2$s.", + "ErrorXTooHigh": "\"%1$s\" is too high, maximum allowed value is %2$s.", + "ErrorXTooLong": "\"%1$s\" is too long, max %2$s characters are allowed.", + "ErrorNotAnArray": "\"%1$s\" has to be an array.", + "ErrorInnerIsNotAnArray": "Each \"%1$s\" within \"%2$s\" has to be an array.", + "ErrorArrayMissingKey": "Missing array key \"%1$s\" in \"%2$s\" at position \"%3$s\".", + "ErrorArrayMissingValue": "Missing value for array key \"%1$s\" in \"%2$s\" at position \"%3$s\".", + "ErrorXNotWhitelisted": "The value for \"%1$s\" is not allowed, use one of: %2$s.", + "ErrorXContainsWhitespace": "The \"%1$s\" is not allowed to contain any white space.", + "ErrorPageRuleRequired": "At least one page rule has to be set.", + "HeatmapCreated": "The heatmap has been successfully created.", + "HeatmapUpdated": "The heatmap has been successfully updated.", + "HeatmapCopied": "The heatmap has been successfully copied.", + "SessionRecordingCreated": "The session recording has been successfully created.", + "SessionRecordingUpdated": "The session recording has been successfully updated.", + "FieldNamePlaceholder": "eg 'Sign Up Page'", + "FieldNamePlaceholderV2": "Signup page engagement", + "FieldDescriptionPlaceholder": "User interactions on the signup page", + "PageRule": "Page Rule", + "HeatmapNameHelp": "Defines the name under which the report for this heatmap will be available.", + "HeatmapNameHelpText": "Enter a unique name to clearly identify this heatmap in reports.", + "HeatmapDescriptionHelpText": "Enter a description to provide additional context, such as the purpose or usage.", + "HeatmapAdvancedOptionsSummary": "Current settings: %1$s%% sample rate, %2$s sampled page views, %3$s, %4$s mobile breakpoint, %5$s tablet breakpoint.", + "AutomaticSnapshot": "automatic snapshot", + "ManualSnapshot": "manual snapshot", + "HeatmapAdvancedSectionRecordingSettings": "Recording settings", + "HeatmapAdvancedSectionRecordingSettingsHelp": "Control how much visitor traffic is sampled and how many page views the heatmap collects.", + "HeatmapAdvancedSectionPageSnapshot": "Page snapshot", + "HeatmapAdvancedSectionPageSnapshotHelp": "Choose how Matomo captures the page image used for the heatmap preview.", + "HeatmapAdvancedSectionContentMasking": "Content masking (optional)", + "HeatmapAdvancedSectionContentMaskingHelp": "Hide sensitive or distracting page elements from the heatmap preview.", + "HeatmapAdvancedSectionDeviceBreakpoints": "Device breakpoints", + "HeatmapAdvancedSectionDeviceBreakpointsHelp": "Define the browser widths used to group heatmap data into mobile, tablet, and desktop views in case the device type cannot be automatically detected. Set it to zero if your website is not responsive. The default breakpoint values can be changed in Administration -> General Settings.", + "SessionNameHelp": "Defines the name under which the session recordings will be available.", + "SessionNameHelpText": "Enter a unique name to clearly identify this session recording in reports.", + "DescriptionOptional": "Description (optional)", + "CreateNewHeatmap": "Create new heatmap", + "StopX": "Stop recording any new activities for this %s.", + "EditX": "Edit %s", + "DeleteX": "Delete %s", + "EditHeatmapX": "Edit Heatmap %s", + "Filter": "Filter", + "EditSessionRecordingX": "Edit Session Recording %s", + "CreateNewSessionRecording": "Create new session recording", + "TargetAttributeUrl": "URL", + "TargetAttributePath": "Path", + "FilesystemDirectory": "directory", + "TargetAttributeUrlParameter": "URL Parameter", + "TargetAttributeUrlParameterExample": "nameOfUrlParameter", + "ManageSessionRecordings": "Manage Session Recordings", + "ManageHeatmaps": "Manage Heatmaps", + "ReportNoLongerExists": "This report no longer exists.", + "ConfigsPhpSuccess": "'%s' is accessible", + "ConfigsPhpNotAccessible": "The URL '%s' seems to be not accessible from the Internet or Intranet.", + "ConfigsPhpErrorResult": "As a result, tracking Heatmaps and Session Recordings may not work. You may need to change your webserver configuration to allow access to this file via the Internet or Intranet.", + "ConfigsPhpSelfSignedError": "Requesting '%s' resulted in an SSL error. Maybe you are using a self signed certificate?", + "ConfigsInternetDisabled": "We couldn't check if '%s' is accessible because the internet connection is disabled on this Matomo.", + "ConfigsPhpUnknown": "We couldn't check if '%s' is accessible over the Internet or Intranet.", + "ConfigsPhpManualCheck": "Please open the URL manually in a browser to see if the response contains 'Piwik.HeatmapSessionRecording'. If not, you might need to modify your server configuration as this file needs to be accessible via a browser from the Internet or Intranet.", + "ConfigsPhpUnknownError": "Requesting '%1$s' resulted in an error: %2$s.", + "ManageHeatmapSubcategoryHelp": "Heatmaps let you record all clicks, mouse moves, and scroll activities of your visitors on a specific page. This section enables you to create and manage Heatmap tracking for your website.", + "ManageSessionRecordingSubcategoryHelp": "Session recordings let you record all activities of a real visitor during their session (visit) such as clicks, mouse movements, scrolls, window resizes, page changes, and form interactions. This section enables you to create and manage Session Recordings for your site.", + "DisableSessionRecordingTitle": "Disable Session Recording", + "DisableSessionRecordingDescription": "By disabling this feature no session recordings can be configured or tracked anymore.", + "DisableSessionRecordingInlineHelp": "%1$sNote: Disabling this feature will delete all session recordings including previously tracked session recordings. There will be no changes for heatmaps if you enable or disable this feature.%2$s", + "PeriodDisabledErrorMessage": "The \"%1$s\" period is disabled but required for this feature to work. Select a different period or ask a system administrator to change Matomo's config file 'config\/config.ini.php' to allow the \"%1$s\" period in the \"enabled_periods_API\" setting.", + "DisableHeatmapRecordingTitle": "Disable Heatmaps", + "DisableHeatmapRecordingDescription": "By disabling this feature no heatmap can be configured or recorded anymore.", + "DisableHeatmapRecordingInlineHelp": "%1$sNote: Disabling this feature will delete all heatmaps including previously tracked heatmaps. There will be no changes for session recording if you enable or disable this feature.%2$s", + "EnableIncludeCountriesTitle": "Only track visitors from specific countries", + "EnableIncludeCountriesDescription": "By default, all visits are recorded. Enable this section when you want to track visits from specific countries.", + "Country": "Country", + "HeatmapInfoTrackVisitsFromCountries": "The heatmap is configured to only track visits from %1$s.", + "SessionRecordingInfoTrackVisitsFromCountries": "The session recording is configured to only track visits from %1$s.", + "AdBlockerDetected": "%1$s might not be visible when using an ad blocker. Please disable your ad blocker on this site to make sure Matomo works without any issues.", + "PauseReason": "The %1$s was paused due to exhaustion of your quota, please contact support for further details.", + "TotalEvents": "Events", + "ColumnTotalEventsDocumentation": "The number of events that were recorded during this session. For example: click, move, scroll, resize, page changes and form interaction events.", + "CaptureDomTitle": "Capture Heatmap Snapshot Manually", + "CaptureDomInlineHelp": "By default, heatmap snapshots are automatically taken on page load. If your page dynamically loads elements (such as with lazy loading) your snapshot could be incomplete. You can manually capture a complete snapshot by enabling this option and executing the following Javascript code in your browser: %1$s %2$sNote:%3$s This option resets automatically after capturing the snapshot.", + "AutoRepeatHeatmap": "Automatically repeat this heatmap", + "AutoRepeatHeatmapColumn": "Repeat heatmap", + "AutoRepeatHeatmapColumnHelp": "When enabled, Matomo automatically creates a new heatmap with the same settings 30 days after this one finishes.", + "AutoRepeatHeatmapHelp": "Matomo will create a new heatmap with the same settings 30 days after this recording finishes. Recommended for key pages you want to monitor regularly, such as your homepage or pricing page. %1$sLearn more%2$s.", + "AutoRepeatSessionRecording": "Automatically repeat this session recording", + "AutoRepeatSessionRecordingColumn": "Repeat recording", + "AutoRepeatSessionRecordingColumnHelp": "When enabled, Matomo automatically creates a new session recording with the same settings 30 days after this one finishes.", + "AutoRepeatSessionRecordingHelp": "Matomo will create a new session recording with the same settings 30 days after this recording finishes. Recommended for key pages you want to monitor regularly, such as your homepage or pricing page. %1$sLearn more%2$s.", + "MatomoJSNotWritableErrorMessage": "HeatmapSessionRecording: %1$s are currently unable to track visits, because the matomo.js file is read-only. Please see the %2$sdocumentation%3$s about how to make the file writable.", + "Clicks": "Clicks:", + "ClickRate": "Click rate:", + "Moves": "Moves:", + "MoveRate": "Move rate:", + "HeatmapAddedActivity": "added a heatmap \"%1$s\" for site \"%2$s\"", + "HeatmapDeletedActivity": "deleted the heatmap \"%1$s\" for site \"%2$s\"", + "HeatmapPausedActivity": "paused the heatmap \"%1$s\" for site \"%2$s\"", + "HeatmapResumedActivity": "resumed the heatmap \"%1$s\" for site \"%2$s\"", + "HeatmapEndedActivity": "ended the heatmap \"%1$s\" for site \"%2$s\"", + "HeatmapScreenshotDeletedActivity": "deleted the heatmap screenshot \"%1$s\" for site \"%2$s\"", + "HeatmapUpdatedActivity": "updated the heatmap \"%1$s\" for site \"%2$s\"", + "SessionRecordingAddedActivity": "added a session recording \"%1$s\" for site \"%2$s\"", + "SessionRecordingDeletedActivity": "deleted the session recording \"%1$s\" for site \"%2$s\"", + "SessionRecordingEndedActivity": "ended the session recording \"%1$s\" for site \"%2$s\"", + "SessionRecordingUpdatedActivity": "updated the session recording \"%1$s\" for site \"%2$s\"", + "SessionRecordingPausedActivity": "paused the session recording \"%1$s\" for site \"%2$s\"", + "SessionRecordingResumedActivity": "resumed the session recording \"%1$s\" for site \"%2$s\"", + "RecordedSessionDeletedActivity": "deleted a recorded session for session recording \"%1$s\" for site \"%2$s\"", + "RecordedPageviewDeletedActivity": "deleted a recorded page view for session recording \"%1$s\" for site \"%2$s\"", + "QuotaReachedForX": "You’ve reached your quota limit. Unable to copy the %1$s due to exceeded usage. Consider deleting unused or less critical %2$s to manage your quota.", + "HeatmapDuplicationError": "Process incomplete. The heatmap could not be copied to all selected sites. Failed sites: %1$s. Successful sites: %2$s.", + "None": "None", + "SourceHeatmapLookupError": "There was an unexpected error looking up the source heatmap. Please try again. Contact your administrator or support if the problem persists.", + "HeatmapDuplicationSiteTypeError": "Copy feature is not supported for sites: %1$s.", + "PolicyHeatmapTitle": "Heatmaps - Disable Heatmap Recording", + "PolicySessionTitle": "Heatmaps - Disable Session Recording", + "PolicyHeatmapRequirementNote": "Heatmap recording in the HeatmapSessionRecording plugin must be disabled", + "PolicySessionRequirementNote": "Session recording in the HeatmapSessionRecording plugin must be disabled" + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/eo.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/eo.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/eo.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/es-ar.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/es-ar.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/es-ar.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/es.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/es.json new file mode 100644 index 0000000..f11ba37 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/es.json @@ -0,0 +1,183 @@ +{ + "HeatmapSessionRecording": { + "ActivityClick": "Clic", + "ActivityFormChange": "Cambiar formulario", + "ActivityFormText": "Cambio del texto del formulario", + "ActivityFormValue": "Cambio de valor del formulario", + "ActivityInitialDom": "Página inicial", + "ActivityMove": "Mover", + "ActivityResize": "Redimensionar", + "ActivityScroll": "Desplazar", + "ActivityScrollElement": "Desplazar elemento", + "AdvancedOptions": "Opciones avanzadas", + "AutoPlayNextPageview": "Haga clic para %1$s reproducir automáticamente la siguiente página (atajo %2$s)", + "AvgAboveFoldDescription": "En promedio, los visitantes ven el contenido sobre esta línea sin desplazarse", + "AvgAboveFoldTitle": "Promedio sobre el doblez %spx", + "BreakpointGeneralHelp": "El tipo de dispositivo se detecta automáticamente cuando un visitante visita su sitio web. Sin embargo, a veces puede que no sea posible detectarlo. Si su sitio web es receptivo y su diseño en un cierto punto se fractura, pondremos usuarios de escritorio con una pequeña resolución en este tipo de dispositivo para obtener mapas de calor más precisos. Cualquier ancho inferior a este valor se pondrá en este tipo de dispositivo. Establézcalo en cero si su sitio web no es receptivo.", + "BreakpointGeneralHelpManage": "Un usuario con acceso de Super Usuario puede cambiar los valores predeterminados para estos puntos de interrupción en Administración => Configuración general.", + "BreakpointX": "Punto de interrupción %s", + "CaptureKeystrokes": "Captura de pulsaciones de teclado", + "CaptureKeystrokesHelp": "Si está habilitado, se registrará cualquier texto que se ingrese en los campos de formulario de texto. Mientras se graba el texto, cualquier carácter que ingrese un usuario se reemplaza con una estrella ('*'). Puede incluir en la lista blanca ciertos campos para que se registren en texto sin formato especificando un atributo 'data-matomo-unmask' en un campo de formulario. Sin embargo, los campos de contraseña y muchos otros campos que podrían incluir información personal (dirección, correo electrónico, información de tarjeta de crédito, nombre de usuario, número de teléfono, …) siempre se ocultarán automáticamente si los detectamos como tales (%1$sobtenga más información%2$s). Tenga en cuenta que cuando habilita esta función, puede registrar datos personales que pueden afectar a GDPR.", + "ChangeReplaySpeed": "Cambiar la velocidad de repetición (atajo %s)", + "ClickToSkipPauses": "Haga clic %1$s para saltar pausas (atajo %2$s)", + "ColumnActionsDocumentation": "Reproducir la sesión grabada o eliminarla permanentemente.", + "ColumnBrowserDocumentation": "El navegador que el visitante estaba usando cuando esta sesión fue grabada.", + "ColumnDeviceDocumentation": "El dispositivo que el visitante estaba usando cuando esta sesión fue grabada.", + "ColumnLabelRecordedSessions": "URL de entrada → URL de salida", + "ColumnLocationDocumentation": "La ubicación del visitante cuando esta sesión fue grabada.", + "ColumnOperatingSystem": "OS", + "ColumnOperatingSystemDocumentation": "El sistema operativo que estaba utilizando el visitante cuando se grabó esta sesión.", + "ColumnPageviewsDocumentation": "El número de páginas vistas que se registraron durante esta sesión.", + "ColumnResolutionDocumentation": "El número de píxeles de la ventana visible en la ventana del navegador cuando el usuario abrió esta página.", + "ColumnTime": "Tiempo", + "ColumnTimeDocumentation": "La hora en que comenzó la grabación en la zona horaria del sitio web.", + "ColumnTimeOnPageDocumentation": "Cuánto tiempo pasó el visitante en esta página mientras se grabó la sesión", + "ColumnTimeOnSiteDocumentation": "Cuánto tiempo pasó en total el visitante mientras se grabó la sesión", + "ConfigsInternetDisabled": "No pudimos verificar si '%s' está accesible porque la conexión a internet está deshabilitada en esta instalación Matomo.", + "ConfigsPhpErrorResult": "Como resultado, el seguimiento de los mapas de calor y las grabaciones de sesión puede no funcionar. Es posible que deba cambiar la configuración del servidor web para permitir el acceso a este archivo a través de Internet o Intranet.", + "ConfigsPhpManualCheck": "Abra la dirección URL manualmente en un navegador para ver si la respuesta contiene 'Piwik.HeatmapSessionRecording'. De lo contrario, es posible que deba modificar la configuración de su servidor, ya que este archivo debe ser accesible a través de un navegador desde Internet o Intranet.", + "ConfigsPhpNotAccessible": "La URL '%s' parece no estar accesible desde Internet o Intranet.", + "ConfigsPhpSelfSignedError": "La solicitud '%s' resultó en un error SSL. Tal vez ¿está utilizando un certificado autofirmado?", + "ConfigsPhpSuccess": "'%s' es accessible", + "ConfigsPhpUnknown": "No pudimos comprobar si '%s' está accesible a través de Internet o Intranet.", + "ConfigsPhpUnknownError": "Solicitando '%1$s' resultó en un error: %2$s.", + "CreateNewHeatmap": "Crear nuevo mapa de calor", + "CreateNewSessionRecording": "Crear nueva sesión de grabación", + "CreationDate": "Fecha de creación", + "DeleteHeatmapConfirm": "¿Estás seguro de que desea eliminar este mapa de calor? Los informes de mapas de calor generados anteriormente ya no estarán disponibles en la interfaz de usuario de los informes.", + "DeleteRecordedPageview": "Eliminar esta página vista registrada (sin solicitud de confirmación)", + "DeleteRecordedSession": "Eliminar esta sesión grabada (no se solicita confirmación)", + "DeleteSessionRecordingConfirm": "¿Estás seguro de que desea eliminar esta grabación de sesión? Las grabaciones generadas anteriormente ya no estarán disponibles en la interfaz de usuario de los informes.", + "DeleteX": "Eliminar %s", + "EditHeatmapX": "Modificar mapa de calor %s", + "EditSessionRecordingX": "Editar grabación de sesión %s", + "EditX": "Modificar %s", + "EndHeatmapConfirm": "¿Está seguro de que desea dejar de capturar cualquier actividad para este mapa de calor?", + "EndSessionRecordingConfirm": "¿Está seguro de que desea dejar de grabar nuevas sesiones?", + "ErrorArrayMissingKey": "Falta la clave \"%1$s\" en \"%2$s\" en la posición \"%3$s\".", + "ErrorArrayMissingValue": "Falta el valor para la clave de la matriz \"%1$s\" en \"%2$s\" en la posición \"%3$s\".", + "ErrorHeatmapDoesNotExist": "El mapa de calor solicitado no existe.", + "ErrorHeatmapNameDuplicate": "El nombre de mapa de calor ya está en uso por otro mapa de calor.", + "ErrorInnerIsNotAnArray": "Cada \"%1$s\" dentro de \"%2$s\" tiene que ser una matriz.", + "ErrorInvalidRegExp": "La expresión regular \"%1$s\" no tiene en formato válido.", + "ErrorNotAnArray": "\"%1$s\" tiene que ser una matriz.", + "ErrorPageRuleRequired": "Al menos una regla de página debe ser dispuesta.", + "ErrorSessionRecordingDoesNotExist": "No existe el registro de la sesión solicitada.", + "ErrorXContainsWhitespace": "El \"%1$s\" no puede contener ningún espacio en blanco.", + "ErrorXNotANumber": "\"%1$s\" tiene que ser un número.", + "ErrorXNotProvided": "Por favor suministre un valor para \"%1$s\".", + "ErrorXNotWhitelisted": "El valor para \"%1$s\" no está permitido, use uno de: %2$s.", + "ErrorXTooHigh": "\"%1$s\" es demasiado alto, el valor máximo permitido es %2$s.", + "ErrorXTooLong": "\"%1$s\" es muy largo, un máximo de %2$s caracteres es permitido.", + "ErrorXTooLow": "\"%1$s\" es demasiado bajo, el valor mínimo permitido es %2$s.", + "ExcludedElements": "Elementos excluidos", + "ExcludedElementsHelp": "Opcionalmente, puede definir los selectores CSS para excluir ciertos elementos que no deberían ser visibles en la vista previa del mapa de calor. Por ejemplo, una ventana emergente que se muestra cuando se abre la página de destino. Puede separar múltiples selectores con una coma.", + "FieldIncludedTargetsHelp": "Los objetivos le permiten definir en qué páginas se deben registrar las actividades del usuario. Puede definir una o más condiciones. Por ejemplo, puede definir registrar actividades cuando la dirección URL o la ruta de acceso es igual a un cierto valor y solo si un determinado parámetro de URL está presente en la URL. Las actividades se registrarán solo cuando se cumplan todas las condiciones, no si solo se cumple una de ellas. Todas las condiciones serán evaluadas sin distinción de mayúsculas y minúsculas. Cuando selecciona 'es igual a simple', se ignorarán el protocolo de URL, los parámetros de búsqueda y la barra diagonal.", + "FieldIncludedTargetsHelpSessions": "Los objetivos le permiten configurar para iniciar solo la grabación de una sesión tan pronto como un visitante haya alcanzado una determinada página. Esto le permite, por ejemplo, solo grabar sesiones que pasan por el proceso de salida. Puede definir una o más condiciones. Por ejemplo, puede definir grabar una sesión solo cuando la URL o la ruta de acceso es igual a un cierto valor y solo si un determinado parámetro de URL está presente en la URL. Las sesiones solo se grabarán cuando se cumplan todas las condiciones en una página vista, no si solo se cumple una de ellas. Todas las condiciones serán evaluadas sin distinción de mayúsculas y minúsculas. Cuando selecciona 'es igual a simple', se ignorarán el protocolo de URL, los parámetros de búsqueda y la barra diagonal.", + "FieldNamePlaceholder": "ej 'Registrarse'", + "FilesystemDirectory": "directorio", + "Filter": "Filtro", + "GettingStarted": "Empezando", + "Heatmap": "Mapa de calor", + "HeatmapCreated": "El mapa de calor ha sido creado con éxito.", + "HeatmapNameHelp": "Define el nombre bajo el cual el informe para este mapa de calor estará disponible.", + "HeatmapSampleLimit": "Número de páginas vistas", + "HeatmapSampleLimitHelp": "Define la cantidad de página vistas que desea grabar en total.", + "HeatmapSampleRateHelp": "También conocido como 'tráfico'. Cuando selecciona el 100%%, se registrarán todos los visitantes que visiten la página de destino seleccionada. Cuando selecciona, por ejemplo, el 10%%, solo se registrará cada décimo visitante. Cuanto menor sea el porcentaje que seleccione, más tardará en alcanzar el límite de muestra seleccionado.", + "HeatmapUpdated": "El mapa de calor se ha actualizado con éxito.", + "HeatmapUsageBenefits": "Los mapas de calor le permiten registrar todos los clics, movimientos del mouse y actividades de desplazamiento de sus visitantes en una página determinada. Esto le ayuda a descubrir dónde los usuarios creen que se puede hacer clic en algo pero no si hay partes de la página que apenas se ven o interactúan, qué buscan sus visitantes, qué parte de la página está visible cuando los usuarios ven su página, y más.", + "HeatmapWidth": "Ancho del mapa del calor", + "HeatmapX": "Mapa de calor %s", + "HeatmapXRecordedSamplesSince": "%1$s muestras se han grabado desde %2$s.", + "Heatmaps": "Mapas de color", + "Manage": "Administrar", + "ManageHeatmaps": "Gestionar mapas de calor", + "ManageSessionRecordings": "Administrar grabaciones de sesión", + "MinSessionTime": "Tiempo de sesión min", + "MinSessionTimeHelp": "Una sesión solo se grabará cuando un visitante haya pasado al menos el tiempo especificado en una página.", + "NHeatmaps": "%s mapas de calor", + "NSessionRecordings": "%s grabaciones de sesión", + "NoHeatmapSamplesRecordedYet": "Aún no se ha registrado una vista de página para este mapa de calor. Si se supone que ya debiera haber grabaciones, tal vez los objetivos de página configurados para este mapa de calor no coincidan con ninguna página de su sitio web. También se recomienda verificar la \"Comprobación del sistema\" en \"Administración\" (solo para los Super usuarios) para ver si su sistema está configurado para rastrear automáticamente los mapas de calor.", + "NoHeatmapScreenshotRecordedYet": "Se han registrado %1$s muestras hasta el momento. Sin embargo, aún no se ha tomado ninguna captura de pantalla. Si hay un \"%2$s\" para este mapa de calor, la captura de pantalla puede tardar un tiempo en estar disponible, ya que el usuario primero debe abrir esta dirección URL de captura de pantalla. Dependiendo de la frecuencia de muestreo, esto puede tomar un tiempo.", + "NoHeatmapsConfiguredInfo": "Actualmente no hay mapas de calor activos. Para verlos, solicite a un usuario con al menos acceso de administrador para crear un nuevo mapa de calor.", + "NoHeatmapsFound": "No se encontraron mapas de calor", + "NoSessionRecordedYet": "Aún no se ha registrado ninguna sesión. Si se supone que ya habrá sesiones grabadas, tal vez la página de entrada configurada no coincida con ninguna página de su sitio web. También se recomienda verificar la \"Comprobación del sistema\" en \"Administración\" (solo para los Super Usuarios) para ver si su sistema está configurado para sesiones grabadas automáticamente.", + "NoSessionRecordingsConfiguredInfo": "Actualmente no hay grabaciones de sesión activas. Para grabar nuevas sesiones, solicite a un usuario con al menos acceso de administrador para crear una nueva grabación.", + "NoSessionRecordingsFound": "No se encontraron grabaciones de sesión", + "NotSupportedBrowser": "Este navegador no es compatible, utilice una versión posterior o inténtelo con otro navegador.", + "OnePageview": "1 página vista", + "PageRule": "Norma de la página", + "PageviewXofY": "Vista de página %1$s de %2$s", + "PageviewsInVisit": "Páginas vistas registradas en esta sesión", + "PersonalInformationNote": "Tenga en cuenta que %1$spuede grabar información personal o confidencial. Si desea ocultar cierto contenido en su sitio web o aplicación, puede etiquetar este contenido agregando un atributo %2$sdata-matomo-mask%3$s al código HTML. %4$sAprende más%5$s", + "PlayRecordedSession": "Reproducir esta sesión registrada", + "PlayerDurationXofY": "%1$s de %2$s", + "PlayerForwardFast": "Saltar %1$s segundos (atajo %2$s)", + "PlayerPageViewNext": "Reproducir la siguiente página vista de este visitante (%1$s) (atajo %2$s)", + "PlayerPageViewPrevious": "Reproducir la próxima página vista de este visitante %1$s (atajo %2$s)", + "PlayerPause": "Pausa (atajo %s o barra espaciadora)", + "PlayerPlay": "Reproducir (atajo %s o barra espaciadora)", + "PlayerReplay": "Repetición (atajo %s o barra espaciadora)", + "PlayerRewindFast": "Rebobinar %1$s segundos (atajo %2$s)", + "RecordedHeatmapDocStatusActive": "Este mapa de calor está activo. Se registrarán hasta %1$d vistas de página con una frecuencia de muestreo de %2$s.", + "RecordedHeatmapDocStatusEnded": "Este mapa de calor ha terminado. No se grabará ninguna nueva actividad.", + "RecordedSessions": "Sesiones registradas", + "RecordedSessionsDocStatusActive": "Esta sesión registrada está activa. Hasta %1$d sesiones serán registradas con una frecuencia de muestreo de %2$s.", + "RecordedSessionsDocStatusEnded": "Esta sesión de grabación ha finalizado y no se grabarán nuevas sesiones.", + "Recordings": "Registros", + "ReplayRecordedSession": "Repetir la sesión registrada", + "ReplayX": "Repetir %s", + "ReportRecordedSessionsDocumentation": "Una lista de todas las sesiones que se han grabado. Puede reproducir cualquier sesión grabada.", + "RequiresActivity": "Requiere actividad", + "RequiresActivityHelp": "Si está habilitado, solo se grabarán las sesiones que tengan un desplazamiento y actividades de los clics en una vista de página. Esto evita la grabación de sesiones con poca actividad.", + "Rule": "Regla", + "SampleLimit": "Límite de muestra", + "SampleRate": "Frecuencia de muestreo", + "ScreenshotUrl": "URL de la captura de pantalla", + "ScreenshotUrlHelp": "De forma predeterminada, se toma una captura de pantalla cuando el primer visitante ve la página de destino. Si diferentes URL de página coinciden con su página de destino, puede especificar una URL específica que se debe utilizar para capturar la captura de pantalla. La visualización del mapa de calor solo estará disponible una vez que al menos un visitante haya visitado esta URL cuyas actividades estén registradas. Si se define, la URL debe coincidir exactamente. Para ignorar el protocolo, inicie la URL con dos barras diagonales dobles (//example.com). Tenga en cuenta que esta URL no se puede cambiar tan pronto como se haya tomado una captura de pantalla.", + "SessionNameHelp": "Define el nombre bajo el cual estarán disponibles las grabaciones de la sesión.", + "SessionRecording": "Registro de sesión", + "SessionRecordingCreated": "La grabación de la sesión se ha creado con éxito.", + "SessionRecordingUpdated": "La grabación de la sesión se ha actualizado con éxito.", + "SessionRecordingX": "Registrando sesión %s", + "SessionRecordings": "Registros de sesión", + "SessionRecordingsUsageBenefits": "Las grabaciones de sesión le permiten registrar todas las actividades de un visitante durante su sesión (visita), como clics, movimientos del mouse, desplazamientos, cambio de tamaño de ventana, cambios de página e interacciones de formularios. Luego puede reproducir estas interacciones para ver exactamente cómo interactuó con su sitio web. De esta manera puede comprende las expectativas, los problemas que pueden tener, sus patrones de uso y mucho más.", + "SessionSampleLimit": "Número de sesiones", + "SessionSampleLimitHelp": "Defina la cantidad de sesiones que desea registrar en total.", + "SessionSampleRateHelp": "También conocido como 'tráfico'. Cuando selecciona el 100%%, todas las sesiones se grabarán tan pronto como alcancen la página de destino. Cuando selecciona, por ejemplo, el 10%%, solo se grabará cada décima sesión. Cuanto menor sea el porcentaje que seleccione, más tardará en alcanzar el límite de muestra seleccionado.", + "StatusActive": "Activo", + "StatusEnded": "Finalizó", + "StopX": "Dejar de grabar nuevas actividades para este %s.", + "TargetAttributePath": "Ruta", + "TargetAttributeUrl": "URL", + "TargetAttributeUrlParameter": "Parámetro URL", + "TargetAttributeUrlParameterExample": "nameOfUrlParameter", + "TargetPage": "Página de destino", + "TargetPageTestErrorInvalidUrl": "Ingrese un URL incluyendo su protocolo.", + "TargetPageTestLabel": "Ingrese una URL completa que incluya el protocolo para verificar si las actividades se registrarán en esta URL:", + "TargetPageTestTitle": "Validador URL", + "TargetPageTestUrlMatches": "Las actividades serán registradas en esta URL.", + "TargetPageTestUrlNotMatches": "Las actividades no serán registradas en esta URL.", + "TargetPages": "Páginas de entrada", + "TargetTypeContains": "contiene", + "TargetTypeEqualsExactly": "exactamente igual", + "TargetTypeEqualsExactlyInfo": "El valor debe coincidir exactamente, incluido el protocolo de URL, la consulta de búsqueda y el hash.", + "TargetTypeEqualsSimple": "es igual a simple", + "TargetTypeEqualsSimpleInfo": "La URL coincidirá con cualquier protocolo (por ejemplo, http y https) con o sin subdominio \"www\". Cualquier barra diagonal final en la ruta, así como la consulta de búsqueda y la parte de hash de la URL se ignorarán cuando coincida la URL.", + "TargetTypeExists": "existe", + "TargetTypeIsAny": "es cualquier", + "TargetTypeIsNot": "no %s", + "TargetTypeRegExp": "coincide con la expresión regular", + "TargetTypeRegExpInfo": "Cualquier expresión regular, por ejemplo \"^(.*)test(.*)$\".", + "TargetTypeStartsWith": "empieza con", + "TimeOnSite": "Tiempo en sitio web", + "UpdatingData": "Actualizando datos…", + "UrlParameterValueToMatchPlaceholder": "Valor para coincidir con el nombre del parámetro de la URL", + "UrlXDoesNotLookLikeUrl": "La %s no se parece a una dirección URL. Asegúrese que respeta el protocolo http:// o que comienza con //", + "ViewReport": "Ver informe", + "ViewportResolution": "Resolución de la ventana gráfica (ancho x alto)", + "XSamples": "%s muestras", + "disable": "deshabilitar", + "enable": "habilitar" + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/et.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/et.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/et.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/eu.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/eu.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/eu.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/fa.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/fa.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/fa.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/fi.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/fi.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/fi.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/fr.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/fr.json new file mode 100644 index 0000000..847c27e --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/fr.json @@ -0,0 +1,247 @@ +{ + "HeatmapSessionRecording": { + "Action": "Action", + "ActivityClick": "Clic", + "ActivityFormChange": "Changement de formulaire", + "ActivityFormText": "Modification du texte du formulaire", + "ActivityFormValue": "Changement de valeur du formulaire", + "ActivityInitialDom": "Page initiale", + "ActivityMove": "Mouvement", + "ActivityPageChange": "Changement dans la page", + "ActivityResize": "Redimensionner", + "ActivityScroll": "Défilement", + "ActivityScrollElement": "Élément de défilement", + "AdBlockerDetected": "%1$s peut ne pas être visible lorsque vous utilisez un bloqueur de publicités. Veuillez désactiver votre bloqueur de publicités sur ce site pour vous assurer que Matomo fonctionne correctement.", + "AdvancedOptions": "Options avancées", + "AutoPlayNextPageview": "Cliquez sur %1$s pour lire automatiquement la page suivante (raccourci %2$s)", + "AvgAboveFoldDescription": "En moyenne, les visiteurs voient le contenu au-dessus de cette ligne sans défiler", + "AvgAboveFoldTitle": "Moy. au-dessus de la ligne de flottaison %spx", + "BreakpointGeneralHelp": "Le type d'appareil est automatiquement détecté lorsqu'un visiteur consulte votre site web. Cependant, il arrive parfois qu'il ne soit pas possible de détecter le type d'appareil. Si votre site Web est responsive et que votre mise en page s'interrompt à un moment donné, nous classerons les utilisateurs d'ordinateurs de bureau ayant une faible résolution dans ce type d'appareil pour obtenir des cartes de chaleur plus précises. Toute largeur inférieure à cette valeur sera classée dans ce type d'appareil. Mettez la valeur zéro si votre site Web n'est pas responsive.", + "BreakpointGeneralHelpManage": "Un utilisateur disposant d'un accès Super utilisateur peut modifier les valeurs par défaut de ces points d'arrêt dans Administration => Paramètres généraux.", + "BreakpointX": "Point d'arrêt %s", + "CaptureDomInlineHelp": "Par défaut, les instantanés de la carte de chaleur sont automatiquement pris au chargement de la page. Si votre page charge dynamiquement des éléments (comme avec le chargement différé), votre instantané pourrait être incomplet (page blanche). Vous pouvez capturer manuellement un instantané complet en activant cette option et en exécutant le code Javascript suivant dans votre navigateur : %1$s %2$sNote :%3$s Cette option se réinitialise automatiquement après la capture de l'instantané.", + "CaptureDomTitle": "Capturer un instantané de la carte de chaleur manuellement", + "CaptureKeystrokes": "Enregistrement des frappes de clavier", + "CaptureKeystrokesHelp": "Si cette option est activée, tout le texte saisi dans les champs de formulaire texte seront enregistrés. Pendant l'enregistrement du texte, tout caractère saisi par l'utilisateur est remplacé par une étoile ('*'). Vous pouvez établir une liste blanche de certains champs qui seront enregistrés en texte brut en spécifiant un attribut \"data-matomo-unmask\" sur un champ de formulaire. Toutefois, les champs relatifs au mot de passe et de nombreux autres champs susceptibles de contenir des informations personnelles (adresse, courriel, informations relatives à la carte de crédit, nom d'utilisateur, numéro de téléphone, ...) seront toujours automatiquement masqués si nous les détectons comme tels (%1$sen savoir plus%2$s). Veuillez noter que lorsque vous activez cette fonction, vous pouvez enregistrer des données personnelles, ce qui peut entrer en conflit avec le RGPD.", + "ChangeReplaySpeed": "Modifier la vitesse de lecture (raccourci %s)", + "ClickRate": "Taux de clics :", + "ClickToSkipPauses": "Cliquez pour %1$s sauter les pauses (raccourci %2$s)", + "Clicks": "Clics :", + "ColumnActionsDocumentation": "Lire la session enregistrée ou supprimez l'enregistrement de façon permanente.", + "ColumnBrowserDocumentation": "Le navigateur que le visiteur utilisait au moment de l'enregistrement de cette session.", + "ColumnDeviceDocumentation": "L'appareil que le visiteur utilisait lorsque cette session a été enregistrée.", + "ColumnLabelRecordedSessions": "URL d'entrée → URL de sortie", + "ColumnLocationDocumentation": "L'emplacement du visiteur lorsque cette session a été enregistrée.", + "ColumnOperatingSystem": "Système d'exploitation", + "ColumnOperatingSystemDocumentation": "Le système d'exploitation que le visiteur utilisait lorsque cette session a été enregistrée.", + "ColumnPageviewsDocumentation": "Le nombre de pages vues qui ont été enregistrées pendant cette session.", + "ColumnResolutionDocumentation": "Le nombre de pixels visiblede la fenêtre d'affichage dans la fenêtre du navigateur lorsque l'utilisateur a ouvert cette page.", + "ColumnTime": "Heure", + "ColumnTimeDocumentation": "L'heure à laquelle l'enregistrement a commencé dans le fuseau horaire du site web.", + "ColumnTimeOnPageDocumentation": "Le temps que le visiteur a passé sur cette page pendant l'enregistrement de la session", + "ColumnTimeOnSiteDocumentation": "Combien de temps le visiteur a passé au total pendant que la session était enregistrée", + "ColumnTotalEventsDocumentation": "Le nombre d'événements qui ont été enregistrés durant cette session. Par exemple : clic, déplacement, défilement, redimensionnement, changements de page et interactions avec les formulaires.", + "ConfigsInternetDisabled": "Nous n'avons pas pu vérifier si '%s' est accessible car la connexion internet est désactivée sur ce Matomo.", + "ConfigsPhpErrorResult": "Par conséquent, le suivi des cartes de chaleur et des enregistrements de sessions peut ne pas fonctionner. Vous devrez peut-être modifier la configuration de votre serveur Web pour permettre l'accès à ce fichier via Internet ou Intranet.", + "ConfigsPhpManualCheck": "Veuillez ouvrir l'URL manuellement dans un navigateur pour voir si la réponse contient 'Piwik.HeatmapSessionRecording'. Si ce n'est pas le cas, vous devrez peut-être modifier la configuration de votre serveur car ce fichier doit être accessible via un navigateur depuis Internet ou Intranet.", + "ConfigsPhpNotAccessible": "L'URL '%s' semble ne pas être accessible depuis Internet ou Intranet.", + "ConfigsPhpSelfSignedError": "La requête de '%s' a donné lieu à une erreur SSL. Peut-être utilisez-vous un certificat auto-signé ?", + "ConfigsPhpSuccess": "'%s' est accessible", + "ConfigsPhpUnknown": "Nous n'avons pas pu vérifier si '%s' est accessible par Internet ou Intranet.", + "ConfigsPhpUnknownError": "La requête de '%1$s' a donné lieu à une erreur : %2$s.", + "Country": "Pays", + "CreateNewHeatmap": "Créer une nouvelle carte de chaleur", + "CreateNewSessionRecording": "Créer un nouvel enregistrement de session", + "CreationDate": "Date de création", + "DeleteHeatmapConfirm": "Êtes-vous sûr de vouloir supprimer cette carte de chaleur ? Les cartes de chaleur générées précédemment ne seront plus disponibles dans l'interface de création de rapports.", + "DeleteHeatmapScreenshotConfirm": "Êtes-vous sûr de vouloir supprimer cette capture d'écran ? Il peut s'écouler un certain temps avant que la capture d'écran ne soit refaite.", + "DeleteRecordedPageview": "Supprimer cette vue de la page enregistrée (aucune confirmation demandée)", + "DeleteRecordedSession": "Supprimer cette session enregistrée (aucune confirmation n'est demandée)", + "DeleteScreenshot": "Supprimer la capture d'écran", + "DeleteSessionRecordingConfirm": "Êtes-vous sûr de vouloir supprimer cet enregistrement de session ? Les enregistrements générés précédemment ne seront plus disponibles dans l'interface utilisateur des rapports.", + "DeleteX": "Supprimer %s", + "DeviceType": "Type d'appareil", + "DisableHeatmapRecordingDescription": "En désactivant cette fonction, aucune carte de chaleur ne peut plus être configurée ou enregistrée.", + "DisableHeatmapRecordingInlineHelp": "%1$sRemarque : la désactivation de cette fonction entraîne la suppression de toutes les cartes de chaleur, y compris les cartes de chaleur précédemment suivies. Aucun changement ne sera apporté à l'enregistrement de la session si vous activez ou désactivez cette fonctionnalité.%2$s", + "DisableHeatmapRecordingTitle": "Désactiver les cartes de chaleur", + "DisableSessionRecordingDescription": "En désactivant cette fonctionnalité, aucun enregistrement de session ne peut plus être configuré ou suivi.", + "DisableSessionRecordingInlineHelp": "%1$sRemarque : la désactivation de cette fonctionnalité supprimera tous les enregistrements de session, y compris les enregistrements de session précédemment suivis. Il n'y aura aucun changement pour les cartes de chaleur si vous activez ou désactivez cette fonction.%2$s", + "DisableSessionRecordingTitle": "Désactiver l'enregistrement des sessions", + "EditHeatmapX": "Modifier la carte de chaleur %s", + "EditSessionRecordingX": "Modifier l'enregistrement de la session %s", + "EditX": "Modifier %s", + "EnableIncludeCountriesDescription": "Par défaut, toutes les visites sont enregistrées. Activez cette section si vous souhaitez suivre les visites provenant de certains pays.", + "EnableIncludeCountriesTitle": "Ne suivre que les visiteurs provenant de certains pays", + "EndHeatmapConfirm": "Êtes-vous sûr de vouloir arrêter de mesurer des activités pour cette carte de chaleur ?", + "EndSessionRecordingConfirm": "Êtes-vous sûr de vouloir arrêter d'enregistrer de nouvelles sessions ?", + "ErrorArrayMissingKey": "Clé de tableau manquante \"%1$s\" dans \"%2$s\" à la position \"%3$s\".", + "ErrorArrayMissingValue": "Valeur manquante pour la clé du tableau \"%1$s\" dans \"%2$s\" à la position \"%3$s\".", + "ErrorHeatmapDoesNotExist": "La carte de chaleur demandée n'existe pas", + "ErrorHeatmapNameDuplicate": "Le nom de la carte de chaleur est déjà utilisé par une autre carte.", + "ErrorHeatmapRecordingDisabled": "La fonction d'enregistrement des cartes de chaleur est désactivée. Pour utiliser cette fonction, elle doit être activée par un super utilisateur Matomo dans les paramètres généraux.", + "ErrorInnerIsNotAnArray": "Chaque \"%1$s\" dans \"%2$s\" doit être un tableau.", + "ErrorInvalidRegExp": "L'expression régulière \"%1$s\" n'a pas un format valide.", + "ErrorNotAnArray": "\"%1$s\" doit être un tableau.", + "ErrorPageRuleRequired": "Au moins une règle de page doit être définie.", + "ErrorSessionRecordingDisabled": "La fonction d'enregistrement de session est désactivée. Pour utiliser cette fonction, elle doit être activée par un super utilisateur Matomo dans les paramètres généraux.", + "ErrorSessionRecordingDoesNotExist": "L'enregistrement de session demandé n'existe pas", + "ErrorXContainsWhitespace": "Le \"%1$s\" n'est pas autorisé car ne doit pas contenir d'espace blanc.", + "ErrorXNotANumber": "\"%1$s\" doit être un nombre.", + "ErrorXNotProvided": "Veuillez indiquer une valeur pour \"%1$s\".", + "ErrorXNotWhitelisted": "La valeur pour \"%1$s\" n'est pas autorisée, utilisez une de celles-ci : %2$s.", + "ErrorXTooHigh": "\"%1$s\" est trop élevé, la valeur maximale autorisée est %2$s.", + "ErrorXTooLong": "\"%1$s\" est trop long, %2$s caractères maximum sont autorisés.", + "ErrorXTooLow": "\"%1$s\" est trop faible, la valeur minimale autorisée est %2$s.", + "ExcludedElements": "Éléments exclus", + "ExcludedElementsHelp": "Vous pouvez éventuellement définir des sélecteurs CSS pour exclure certains éléments qui ne doivent pas être visibles dans l'aperçu de la carte de chaleur. Par exemple, une fenêtre contextuelle qui s'affiche lorsque la page cible est ouverte. Vous pouvez séparer plusieurs sélecteurs par une virgule.", + "FieldIncludedTargetsHelp": "Les cibles vous permettent de définir sur quelles pages les activités des utilisateurs doivent être enregistrées. Vous pouvez définir une ou plusieurs conditions. Par exemple, vous pouvez définir l'enregistrement des activités lorsque l'URL ou le chemin d'accès est égal à une certaine valeur et seulement si un certain paramètre d'URL est présent dans l'URL. Les activités ne seront enregistrées que si toutes les conditions sont remplies, et non si une seule d'entre elles l'est. Toutes les conditions sont évaluées sans tenir compte de la casse. Lorsque vous sélectionnez \"est égal à\", le protocole de l'URL, les paramètres de recherche et la barre oblique de fin de ligne sont ignorés.", + "FieldIncludedTargetsHelpSessions": "Les cibles vous permettent de configurer le démarrage de l'enregistrement d'une session uniquement lorsqu'un visiteur a atteint une certaine page. Cela vous permet par exemple de n'enregistrer que les sessions qui passent par le processus de paiement. Vous pouvez définir une ou plusieurs conditions. Par exemple, vous pouvez définir l'enregistrement d'une session uniquement lorsque l'URL ou le chemin d'accès est égal à une certaine valeur et uniquement si un certain paramètre d'URL est présent dans l'URL. Les sessions ne seront enregistrées que si toutes les conditions sont remplies lors de l'affichage d'une page, et non si une seule d'entre elles l'est. Toutes les conditions sont évaluées sans tenir compte de la casse. Lorsque vous sélectionnez \"est égal à\", le protocole de l'URL, les paramètres de recherche et la barre oblique de fin de ligne sont ignorés.", + "FieldNamePlaceholder": "ex 'Page d'inscription'", + "FilesystemDirectory": "répertoire", + "Filter": "Filtre", + "GettingStarted": "Démarrer", + "Heatmap": "Carte de chaleur", + "HeatmapAddedActivity": "a ajouté une carte thermique \"%1$s\" pour le site \"%2$s\"", + "HeatmapCopied": "La carte de chaleur a été copiée avec succès.", + "HeatmapCreated": "La carte de chaleur a été créée avec succès.", + "HeatmapDeletedActivity": "a supprimé la carte de chaleur \"%1$s\" pour le site \"%2$s\"", + "HeatmapDuplicationError": "Processus incomplet. La carte de chaleur n’a pas pu être copiée sur tous les sites sélectionnés. Sites en échec : %1$s. Sites réussis : %2$s.", + "HeatmapDuplicationSiteTypeError": "La fonction de copie n’est pas prise en charge pour les sites suivants : %1$s.", + "HeatmapEndedActivity": "a terminé la carte de chaleur \"%1$s\" pour le site \"%2$s\"", + "HeatmapInfoTrackVisitsFromCountries": "La carte de chaleur est configurée pour ne suivre que les visites provenant de %1$s.", + "HeatmapNameHelp": "Définit le nom sous lequel le rapport de cette carte de chaleur sera disponible.", + "HeatmapPausedActivity": "a mis en pause la carte de chaleur \"%1$s\" pour le site \"%2$s\"", + "HeatmapResumedActivity": "a repris la carte de chaleur \"%1$s\" pour le site \"%2$s\"", + "HeatmapSampleLimit": "Nombre de pages vues", + "HeatmapSampleLimitHelp": "Définit le nombre de pages vues que vous souhaitez enregistrer au total.", + "HeatmapSampleRateHelp": "Également connu sous le nom de \"trafic\". Lorsque vous sélectionnez 100%%, tous les visiteurs qui se rendent sur la page cible sélectionnée seront enregistrés. Si vous sélectionnez par exemple 10 %%, seul un visiteur sur dix sera enregistré. Plus le pourcentage sélectionné est faible, plus il faudra de temps pour atteindre la limite d'échantillonnage choisie.", + "HeatmapScreenshotDeletedActivity": "a supprimé la capture d'écran de la carte de chaleur \"%1$s\" pour le site \"%2$s\"", + "HeatmapTroubleshoot": "Vous rencontrez des problèmes ? %1$sEn savoir plus%2$s.", + "HeatmapUpdated": "La carte de chaleur a été mise à jour avec succès.", + "HeatmapUpdatedActivity": "a mis à jour la carte de chaleur \"%1$s\" pour le site \"%2$s\"", + "HeatmapUsageBenefits": "Les cartes de chaleur vous permettent d'enregistrer tous les clics, mouvements de souris et activités de défilement de vos visiteurs sur une page donnée. Cela vous permet de savoir où les utilisateurs pensent que quelque chose est cliquable mais ne l'est pas, s'il y a des parties de la page qui sont à peine consultées ou avec lesquelles il y a peu d'interaction, ce que vos visiteurs recherchent, quelle partie de la page est visible lorsque les utilisateurs consultent votre page, et plus encore.", + "HeatmapWidth": "Largeur de la carte de chaleur", + "HeatmapX": "Carte de chaleur %s", + "HeatmapXRecordedSamplesSince": "%1$s échantillons ont été enregistrés depuis %2$s.", + "Heatmaps": "Cartes de chaleur", + "Manage": "Gérer", + "ManageHeatmapSubcategoryHelp": "Les cartes de chaleur vous permettent d'enregistrer tous les clics, mouvements de souris et activités de défilement de vos visiteurs sur une page spécifique. Cette section vous permet de créer et de gérer le suivi de cartes de chaleur pour votre site Web.", + "ManageHeatmaps": "Gérer les cartes de chaleur", + "ManageSessionRecordingSubcategoryHelp": "Les enregistrements de session vous permettent d'enregistrer toutes les activités d'un visiteur réel au cours de sa session (visite), telles que les clics, les mouvements de souris, les défilements, les redimensionnements de fenêtre, les changements de page et les interactions de formulaire. Cette section vous permet de créer et de gérer les enregistrements de session pour votre site.", + "ManageSessionRecordings": "Gérer les enregistrements des sessions", + "MatomoJSNotWritableErrorMessage": "HeatmapSessionRecording : %1$s ne peut actuellement pas suivre les visites, car le fichier matomo.js est en lecture seule. Veuillez consulter la %2$sdocumentation%3$s pour savoir comment rendre le fichier modifiable.", + "MinSessionTime": "Durée minimale de la visite", + "MinSessionTimeHelp": "Une session ne sera enregistrée que lorsqu'un visiteur aura passé au moins le temps spécifié sur une page.", + "MoveRate": "Taux de mouvements :", + "Moves": "Mouvements :", + "NHeatmaps": "%s cartes de chaleur", + "NSessionRecordings": "%s enregistrements de sessions", + "NoHeatmapSamplesRecordedYet": "Aucune page vue n'a encore été enregistrée pour cette carte de chaleur. Si des enregistrements sont censés avoir lieu à l'heure actuelle, il se peut que les pages cibles configurées pour cette carte de chaleur ne correspondent à aucune page de votre site Web. Il est également recommandé de vérifier la \"Vérification du système\" sous \"Administration\" (uniquement pour les super utilisateurs) pour voir si votre système est configuré pour suivre automatiquement les cartes de chaleur.", + "NoHeatmapSamplesRecordedYetWithoutSystemConfiguration": "Aucune vue de page n'a encore été enregistrée pour cette carte de chaleur. S'il devait y avoir des enregistrements à ce stade, il est possible que les pages configurées ciblées pour cette carte de chaleur ne correspondent à aucune page de votre site web.", + "NoHeatmapScreenshotRecordedYet": "Il y a eu %1$s échantillons enregistrés jusqu'à présent. Cependant, aucune capture d'écran n'a encore été effectuée. S'il y a un \"%2$s\" pour cette carte de chaleur, la capture d'écran peut prendre un certain temps avant d'être disponible car l'utilisateur doit d'abord ouvrir cette URL de capture d'écran. Selon le taux d'échantillonnage, cela peut prendre un certain temps.", + "NoHeatmapsConfiguredInfo": "Il n'y a actuellement aucune carte de chaleur active. Pour afficher les cartes de chaleur, veuillez demander à un utilisateur ayant au moins un accès administrateur de créer une nouvelle carte de chaleur.", + "NoHeatmapsFound": "Pas de carte de chaleur trouvée", + "NoSessionRecordedYet": "Aucune session n'a encore été enregistrée. Si des sessions sont censées être déjà enregistrées, il se peut que la page d'entrée configurée ne corresponde à aucune page de votre site Web. Il est également recommandé de vérifier la \"Vérification du système\" sous \"Administration\" (uniquement pour les super utilisateurs) pour voir si votre système est configuré pour enregistrer automatiquement les sessions.", + "NoSessionRecordedYetWithoutSystemConfiguration": "Aucune session n'a encore été enregistrée. S'il devait y avoir des sessions enregistrées à ce stade, il est possible que la page d'entrée configurée ne corresponde à aucune page de votre site web.", + "NoSessionRecordingsConfiguredInfo": "Il n'y a actuellement aucun enregistrement de session actif. Pour enregistrer de nouvelles sessions, veuillez demander à un utilisateur ayant au moins un accès administrateur de créer un nouvel enregistrement.", + "NoSessionRecordingsFound": "Aucun enregistrement de session trouvé", + "None": "Aucun", + "NotSupportedBrowser": "Ce navigateur n'est pas pris en charge. Veuillez utiliser une version plus récente ou essayer un autre navigateur.", + "OnePageview": "1 page vue", + "PageRule": "Règle de la page", + "PageviewXofY": "Page vue %1$s de %2$s", + "PageviewsInVisit": "Pages vues enregistrées au cours de cette session", + "PauseReason": "Le %1$s a été mis en pause en raison de la consommation de votre quota, veuillez contacter le support pour plus de détails.", + "PeriodDisabledErrorMessage": "La période \"%1$s\" est désactivée mais est nécessaire pour que cette fonctionnalité marche. Sélectionnez une autre période ou demandez à un administrateur système de modifier le fichier de configuration 'config/config.ini.php' de Matomo pour autoriser la période \"%1$s\" dans le paramètre \"enabled_periods_API\".", + "PersonalInformationNote": "Veuillez noter qu'un %1$s peut enregistrer des informations personnelles ou sensibles. Si vous souhaitez masquer certains contenus sur votre site Web ou votre application, vous pouvez marquer ces contenus en ajoutant un attribut %2$sdata-matomo-mask%3$s au balisage HTML. %4$sEn savoir plus%5$s", + "PlayRecordedSession": "Lire cette session enregistrée", + "PlayerDurationXofY": "%1$s de %2$s", + "PlayerForwardFast": "Passer %1$s secondes (raccourci %2$s)", + "PlayerPageViewNext": "Lire la page suivante de ce visiteur (%1$s) (raccourci %2$s)", + "PlayerPageViewPrevious": "Lire la page précédente de ce visiteur %1$s (raccourci %2$s)", + "PlayerPause": "Pause (raccourci %s ou espace)", + "PlayerPlay": "Lire (raccourci %s ou espace)", + "PlayerReplay": "Relecture (raccourci %s ou espace)", + "PlayerRewindFast": "Rembobiner %1$s secondes (raccourci %2$s)", + "PolicyHeatmapRequirementNote": "L’enregistrement de la carte de chaleur dans le plugin HeatmapSessionRecording doit être désactivé", + "PolicyHeatmapTitle": "Cartes de chaleur – Désactiver l’enregistrement de la carte de chaleur", + "PolicySessionRequirementNote": "L’enregistrement de session dans le plugin HeatmapSessionRecording doit être désactivé", + "PolicySessionTitle": "Cartes de chaleur – Désactiver l’enregistrement de session", + "QuotaReachedForX": "Vous avez atteint votre limite de quota. Impossible de copier le %1$s en raison d’une utilisation dépassée. Envisagez de supprimer les %2$s inutilisés ou moins importants pour gérer votre quota.", + "RecordedHeatmapDocStatusActive": "Cette carte de chaleur est active. Jusqu'à %1$d pages vues seront enregistrées avec un taux d'échantillonnage de %2$s.", + "RecordedHeatmapDocStatusEnded": "Cette carte de chaleur est terminée. Aucune nouvelle activité ne sera enregistrée.", + "RecordedPageviewDeletedActivity": "a supprimé une vue de page enregistrée pour l'enregistrement de session \"%1$s\" pour le site \"%2$s\"", + "RecordedSessionDeletedActivity": "a supprimé un enregistrement de session pour l'enregistrement de session \"%1$s\" pour le site \"%2$s\"", + "RecordedSessions": "Sessions enregistrées", + "RecordedSessionsDocStatusActive": "L'enregistrement de cette session est actif. Jusqu'à %1$d sessions seront enregistrées avec une fréquence d'échantillonnage de %2$s.", + "RecordedSessionsDocStatusEnded": "L'enregistrement de cette session est terminé et aucune nouvelle session ne sera enregistrée.", + "Recordings": "Enregistrements", + "ReplayRecordedSession": "Regarder à nouveau l'enregistrement de session", + "ReplayX": "Regarder à nouveau %s", + "ReportRecordedSessionsDocumentation": "Une liste de toutes les sessions qui ont été enregistrées. Vous pouvez revisionner n'importe quelle session enregistrée.", + "RequiresActivity": "Requiert une activité", + "RequiresActivityHelp": "Si cette option est activée, seules les sessions qui présentent une activité de défilement et de clic dans une vue de page seront enregistrées. Cela permet d'éviter l'enregistrement de sessions avec peu d'activité.", + "Rule": "Règle", + "SampleLimit": "Limite d'échantillonnage", + "SampleRate": "Taux d'échantillonnage", + "ScreenshotUrl": "URL de la capture d'écran", + "ScreenshotUrlHelp": "Par défaut, une capture d'écran est réalisée lorsque le premier visiteur visualise la page cible. Si différentes URLs de pages correspondent à votre page cible, vous pouvez spécifier une URL spécifique qui doit être utilisée pour capturer l'écran. La visualisation de la carte de chaleur ne sera disponible que lorsqu'au moins un visiteur aura visité cette URL dont les activités sont enregistrées. Si elle est définie, l'URL doit correspondre exactement. Pour ignorer le protocole, commencez l'URL par deux doubles barres obliques (//exemple.com). Veuillez noter que cette URL ne peut pas être modifiée dès qu'une capture d'écran a été réalisée.", + "SessionNameHelp": "Définit le nom sous lequel les enregistrements de la session seront disponibles.", + "SessionRecording": "Enregistrement de sessions", + "SessionRecordingAddedActivity": "a ajouté un enregistrement de session \"%1$s\" pour le site \"%2$s\"", + "SessionRecordingCreated": "L'enregistrement de la session a été créé avec succès.", + "SessionRecordingDeletedActivity": "a supprimé l'enregistrement de session \"%1$s\" pour le site %2$s\"", + "SessionRecordingEndedActivity": "a terminé l'enregistrement de session \"%1$s\" pour le site \"%2$s\"", + "SessionRecordingInfoTrackVisitsFromCountries": "L'enregistrement de session est configuré pour ne suivre que les visites provenant de %1$s.", + "SessionRecordingPausedActivity": "a mis en pause l'enregistrement de session \"%1$s\" pour le site \"%2$s\"", + "SessionRecordingResumedActivity": "a repris l'enregistrement de session \"%1$s\" pour le site \"%2$s\"", + "SessionRecordingUpdated": "L'enregistrement de la session a été mis à jour avec succès.", + "SessionRecordingUpdatedActivity": "a mis à jour l'enregistrement de session \"%1$s\" pour le site \"%2$s\"", + "SessionRecordingX": "Enregistrement de session %s", + "SessionRecordings": "Enregistrements des sessions", + "SessionRecordingsUsageBenefits": "Les enregistrements de session vous permettent d'enregistrer toutes les activités d'un visiteur réel au cours de sa session (visite), telles que les clics, les mouvements de souris, les défilements, les redimensionnements de fenêtre, les changements de page et les interactions de formulaire. Vous pouvez ensuite rejouer ces interactions pour voir exactement comment un visiteur a interagi avec votre site Web. De cette façon, vous comprenez leurs attentes, les problèmes qu'ils peuvent rencontrer, leurs habitudes d'utilisation, etc.", + "SessionSampleLimit": "Nombre de sessions", + "SessionSampleLimitHelp": "Définit le nombre de sessions que vous souhaitez enregistrer au total.", + "SessionSampleRateHelp": "Également connu sous le nom de \"trafic\". Si vous sélectionnez 100%%, toutes les sessions seront enregistrées dès qu'elles auront atteint la page cible. Si vous sélectionnez par exemple 10 %%, seule une session sur dix sera enregistrée. Plus le pourcentage sélectionné est faible, plus il faudra de temps pour atteindre la limite d'échantillonnage choisie.", + "SourceHeatmapLookupError": "Une erreur inattendue s’est produite lors de la recherche de la carte de chaleur source. Veuillez réessayer. Contactez votre administrateur ou le support si le problème persiste.", + "StatusActive": "Active", + "StatusEnded": "Terminée", + "StatusPaused": "En pause", + "StopX": "Arrêtez d'enregistrer toute nouvelle activité pour ce %s.", + "TargetAttributePath": "Chemin", + "TargetAttributeUrl": "URL", + "TargetAttributeUrlParameter": "Paramètre d'URL", + "TargetAttributeUrlParameterExample": "nameOfUrlParameter", + "TargetPage": "Page cible", + "TargetPageTestErrorInvalidUrl": "Entrez une URL incluant un protocole.", + "TargetPageTestLabel": "Saisissez une URL complète, y compris le protocole, pour vérifier si les activités seront enregistrées sur cette URL :", + "TargetPageTestTitle": "Validation de l'URL", + "TargetPageTestUrlMatches": "Les activités seront enregistrées pour cette URL", + "TargetPageTestUrlNotMatches": "Les activités ne seront pas enregistrées pour cette URL", + "TargetPages": "Pages d'entrée", + "TargetTypeContains": "contient", + "TargetTypeEqualsExactly": "strictement égal", + "TargetTypeEqualsExactlyInfo": "La valeur doit correspondre exactement, y compris le protocole URL, la requête de recherche et le hachage.", + "TargetTypeEqualsSimple": "est égal à", + "TargetTypeEqualsSimpleInfo": "L'URL correspondra à n'importe quel protocole (par exemple http et https) avec ou sans le sous-domaine \"www.\". Tout slash de fin de chemin ainsi que la requête de recherche et la partie hash de l'URL seront ignorés lors de la recherche de l'URL.", + "TargetTypeExists": "existe", + "TargetTypeIsAny": "n'importe quel", + "TargetTypeIsNot": "n'est pas %s", + "TargetTypeRegExp": "correspond à l'expression régulière", + "TargetTypeRegExpInfo": "Toute expression régulière, par exemple \"^(.*)test(.*)$\".", + "TargetTypeStartsWith": "commence par", + "TimeOnSite": "Temps passé sur le site web", + "TotalEvents": "Événements", + "TrackingDisabledDefaultSettingDescription": "Cette fonctionnalité peut être utile si votre Matomo possède plusieurs sites et que vous souhaitez utiliser la fonction uniquement sur quelques sites spécifiques. Lorsque vous désactivez le suivi par défaut, la fonction d'enregistrement des cartes de chaleur et des sessions ne s'exécutera sur aucun site, sauf si vous l'activez spécifiquement dans le cadre de votre code de suivi, par exemple en appelant '_paq.push(['HeatmapSessionRecording::enable']);'. Cela permet d'éviter des requêtes réseau inutiles vers le fichier 'configs.php' pour déterminer si une carte de chaleur ou un enregistrement de session est configuré pour le site actuel. Cela peut également être utile pour des raisons de confidentialité si vous voulez vous assurer que la fonctionnalité ne sera utilisée que sur certains sites.", + "TrackingDisabledDefaultSettingTitle": "Désactiver le suivi par défaut", + "UpdatingData": "Mise à jour des données…", + "UrlParameterValueToMatchPlaceholder": "Valeur à faire correspondre au nom du paramètre de l'URL", + "UrlXDoesNotLookLikeUrl": "Le %s ne ressemble pas à une URL. Assurez-vous qu'il contient un protocole comme http:// ou qu'il commence par //", + "ViewReport": "Voir le rapport", + "ViewportResolution": "Résolution de la fenêtre de visualisation (largeur x hauteur)", + "Width": "Largeur", + "XSamples": "%s échantillons", + "disable": "désactivé", + "enable": "activé" + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/ga.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/ga.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/ga.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/gl.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/gl.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/gl.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/gu.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/gu.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/gu.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/he.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/he.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/he.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/hi.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/hi.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/hi.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/hr.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/hr.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/hr.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/hu.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/hu.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/hu.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/hy.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/hy.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/hy.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/id.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/id.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/id.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/is.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/is.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/is.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/it.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/it.json new file mode 100644 index 0000000..960baeb --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/it.json @@ -0,0 +1,190 @@ +{ + "HeatmapSessionRecording": { + "ActivityClick": "Clicca", + "ActivityFormChange": "Cambio Form", + "ActivityFormText": "Cambio Testo Form", + "ActivityFormValue": "Cambio Valore Form", + "ActivityInitialDom": "Pagina Iniziale", + "ActivityMove": "Sposta", + "ActivityPageChange": "Cambia all'interno della pagina", + "ActivityResize": "Ridimensiona", + "ActivityScroll": "Scorri", + "ActivityScrollElement": "Elemento Scroll", + "AdvancedOptions": "Opzioni avanzate", + "AutoPlayNextPageview": "Clicca per %1$s riprodurre automaticamente la successiva visualizzazione di pagina (scorciatoia %2$s)", + "AvgAboveFoldDescription": "In media i visitatori vedono il contenuto sopra questa riga senza dover scorrere", + "AvgAboveFoldTitle": "Media Parte Alta (Above Fold) %spx", + "BreakpointGeneralHelp": "Il tipo di dispositivo viene rilevato automaticamente quando un visitatore visualizza il tuo sito web. Tuttavia, a volte potrebbe non essere possibile rilevarlo. Se il tuo sito web è responsive e il tuo layout si interrompe a un certo punto, metteremo gli utenti desktop con una piccola risoluzione in questo tipo di dispositivo per heatmap più accurate. Qualsiasi larghezza inferiore a questo valore verrà inserita in questo tipo di dispositivo. Impostalo su zero se il tuo sito web non è responsive.", + "BreakpointGeneralHelpManage": "Un utente con accesso Super User può modificare i valori predefiniti per questi breakpoints in Amministrazione => Impostazioni generali.", + "BreakpointX": "Breakpoint %s", + "CaptureKeystrokes": "Cattura sequenze di tasti", + "CaptureKeystrokesHelp": "Se abilitato, qualsiasi testo inserito nei campi del modulo di testo viene registrato. Mentre il testo viene registrato, ogni carattere inserito da un utente viene sostituito da un asterisco (*). È possibile autorizzare la registrazione di determinati campi di testo semplice mediante la specifica di un attributo \"data-matomo-unmask\" in un campo del form. Tuttavia, i campi password e molti altri campi che potrebbero potenzialmente includere informazioni personali (indirizzo, e-mail, dati della carta di credito, nome utente, numero di telefono, ...) saranno sempre mascherati automaticamente se rilevati come tali (%1$sulteriori informazioni%2$s). Si noti che quando si attiva questa funzione, è possibile registrare i dati personali che possono essere rilevanti per il GDPR.", + "ChangeReplaySpeed": "Cambia velocità di riproduzione (scorciatoia %s)", + "ClickToSkipPauses": "Clicca per %1$s saltare le pause (scorciatoia %2$s)", + "ColumnActionsDocumentation": "Riproduci la sessione registrata o elimina definitivamente la registrazione.", + "ColumnBrowserDocumentation": "Browser che il visitatore stava utilizzando quando è stata registrata questa sessione.", + "ColumnDeviceDocumentation": "Dispositivo che il visitatore stava utilizzando quando è stata registrata questa sessione.", + "ColumnLabelRecordedSessions": "URL Ingresso → URL Uscita", + "ColumnLocationDocumentation": "Posizione del visitatore quando è stata registrata questa sessione.", + "ColumnOperatingSystem": "SO", + "ColumnOperatingSystemDocumentation": "Sistema operativo che il visitatore stava utilizzando quando è stata registrata questa sessione.", + "ColumnPageviewsDocumentation": "Numero di visualizzazioni di pagina registrate durante questa sessione.", + "ColumnResolutionDocumentation": "Numero di pixel della finestra visibile del browser quando l'utente ha aperto questa pagina.", + "ColumnTime": "Ora", + "ColumnTimeDocumentation": "Ora della registrazione nel fuso orario del sito.", + "ColumnTimeOnPageDocumentation": "Quanto tempo ha trascorso il visitatore in questa pagina durante la registrazione della sessione", + "ColumnTimeOnSiteDocumentation": "Quanto tempo ha trascorso il visitatore in totale durante la registrazione della sessione", + "ConfigsInternetDisabled": "Non è stato possibile verificare se \"%s\" è accessibile perché la connessione Internet è disabilitata su questo Matomo.", + "ConfigsPhpErrorResult": "Di conseguenza, il tracciamento delle Heatmaps e delle Registrazioni di Sessione potrebbe non funzionare. Potrebbe essere necessario modificare la configurazione del server per consentire l'accesso a questo file tramite Internet o Intranet.", + "ConfigsPhpManualCheck": "Si prega di aprire l'URL manualmente in un browser per vedere se la risposta contiene \"Piwik.HeatmapSessionRecording\". In caso contrario, potrebbe essere necessario modificare la configurazione del server in quanto questo file deve essere accessibile tramite un browser da Internet o Intranet.", + "ConfigsPhpNotAccessible": "L'URL \"%s\" sembra non essere accessibile da Internet o Intranet.", + "ConfigsPhpSelfSignedError": "La richiesta \"%s\" ha provocato un errore SSL. Forse stai usando un certificato \"self-signed\"?", + "ConfigsPhpSuccess": "\"%s\" è accessibile", + "ConfigsPhpUnknown": "Non è stato possibile verificare se \"%s\" è accessibile su Internet o Intranet..", + "ConfigsPhpUnknownError": "La richiesta \"%1$s\" ha provocato un errore: %2$s.", + "CreateNewHeatmap": "Crea nuova heatmap", + "CreateNewSessionRecording": "Crea nuova registrazione di sessione", + "CreationDate": "Data Creazione", + "DeleteHeatmapConfirm": "Sei sicuro di voler eliminare questa heatmap? I report sulle heatmap generati in precedenza non saranno più disponibili nell'interfaccia utente dei rapporti.", + "DeleteHeatmapScreenshotConfirm": "Sei sicuro di voler eliminare questo screenshot? Potrebbe essere necessario un po 'di tempo prima che lo screenshot venga ripreso.", + "DeleteRecordedPageview": "Elimina questa visualizzazione di pagina registrata (non è richiesta conferma)", + "DeleteRecordedSession": "Elimina questa sessione registrata (non è richiesta conferma)", + "DeleteScreenshot": "Elimina Screenshot", + "DeleteSessionRecordingConfirm": "Sei sicuro di voler eliminare questa registrazione di sessione? Le registrazioni precedentemente generate non saranno più disponibili nell'interfaccia utente dei rapporti.", + "DeleteX": "Cancella %s", + "EditHeatmapX": "Modifica Heatmap %s", + "EditSessionRecordingX": "Modifica Registrazione di Sessione %s", + "EditX": "Modifica %s", + "EndHeatmapConfirm": "Sei sicuro di voler arrestare la cattura di ogni attività per questa heatmap?", + "EndSessionRecordingConfirm": "Sei sicuro di voler arrestate la registrazione delle nuove sessioni?", + "ErrorArrayMissingKey": "Chiave array mancante \"%1$s\" in \"%2$s\" nella posizione \"%3$s\".", + "ErrorArrayMissingValue": "Valore chiave array \"%1$s\" mancante in \"%2$s\" nella posizione \"%3$s\".", + "ErrorHeatmapDoesNotExist": "La heatmap richiesta non esiste", + "ErrorHeatmapNameDuplicate": "Questo nome di heatmap è già utilizzato da un'altra heatmap.", + "ErrorInnerIsNotAnArray": "Ciascun \"%1$s\" in \"%2$s\" deve essere un array.", + "ErrorInvalidRegExp": "L'espressione regolare \"%1$s\" non è in un formato valido.", + "ErrorNotAnArray": "\"%1$s\" deve essere un array.", + "ErrorPageRuleRequired": "Deve essere impostata almeno una regola di pagina", + "ErrorSessionRecordingDoesNotExist": "La registrazione di sessione richiesta non esiste", + "ErrorXContainsWhitespace": "\"%1$s\" non può contenere spazi vuoti.", + "ErrorXNotANumber": "\"%1$s\" deve essere un numero.", + "ErrorXNotProvided": "Si prega di assegnare un valore per \"%1$s\".", + "ErrorXNotWhitelisted": "Il valore per \"%1$s\" non è consentito, usane uno tra: %2$s.", + "ErrorXTooHigh": "\"%1$s\" è troppo alto, il valore massimo consentito è %2$s.", + "ErrorXTooLong": "\"%1$s\" è troppo lungo, è permesso un massimo di %2$s caratteri.", + "ErrorXTooLow": "\"%1$s\" è troppo piccolo, il valore minimo consentito è %2$s.", + "ExcludedElements": "Elementi Esclusi", + "ExcludedElementsHelp": "Puoi opzionalmente definire dei selettori CSS per escludere determinati elementi che non devono essere visibili nell'anteprima della heatmap. Ad esempio, un popup che viene mostrato quando viene aperta la pagina di destinazione. È possibile separare più selettori con una virgola.", + "FieldIncludedTargetsHelp": "I target consentono di definire su quali pagine devono essere registrate le attività dell'utente. È possibile definire una o più condizioni. Ad esempio, è possibile stabilire di registrare le attività ogni volta che l'URL o il percorso è uguale a un determinato valore e solo se è presente. un determinato parametro URL. Le attività verranno registrate solo quando tutte le condizioni sono soddisfatte, non se ne viene soddisfatta solo una. Tutte le condizioni saranno valutate senza distinzione tra maiuscole e minuscole. Quando selezioni \"uguale semplice\", il protocollo URL, i parametri di ricerca e una barra finale verranno ignorati.", + "FieldIncludedTargetsHelpSessions": "I target consentono di configurare solo l'avvio della registrazione di una sessione non appena un visitatore ha raggiunto una determinata pagina. Questo ti consente, ad esempio, di registrare solo le sessioni che arrivano al processo di checkout. È possibile definire una o più condizioni. Ad esempio, è possibile stabilire di registrare una sessione solo quando l'URL o il percorso sono uguali a un determinato valore e solo se è presente un determinato parametro URL. Le sessioni verranno registrate solo quando tutte le condizioni sono soddisfatte in una visualizzazione di pagina, non se viene soddisfatta solo una di esse. Tutte le condizioni saranno valutate senza distinzione tra maiuscole e minuscole. Quando selezioni \"uguale semplice\", il protocollo URL, i parametri di ricerca e una barra finale verranno ignorati.", + "FieldNamePlaceholder": "es. \"Pagina di registrazione\"", + "FilesystemDirectory": "directory", + "Filter": "Filtra", + "GettingStarted": "Cominciamo", + "Heatmap": "Heatmap", + "HeatmapCreated": "La heatmap è stata creata con successo.", + "HeatmapNameHelp": "Definisce il nome con cui sarà disponibile il report per questa heatmap.", + "HeatmapSampleLimit": "Numero di visualizzazioni di pagina", + "HeatmapSampleLimitHelp": "Definisce il numero totale di visualizzazioni di pagina che vuoi registrare.", + "HeatmapSampleRateHelp": "Conosciuto anche come \"traffico\". Quando selezioni 100%%, verranno registrati tutti i visitatori che visitano la pagina di destinazione impostata. Quando selezioni, per esempio, 10%%, verrà registrato solo un visitatore ogni 10. Più bassa è la percentuale selezionata, più tempo ci vorrà per raggiungere il limite di campionamento selezionato.", + "HeatmapUpdated": "La heatmap è stata aggiornata con successo.", + "HeatmapUsageBenefits": "Le heatmaps, o \"mappe di calore\", consentono di registrare tutti i click, i movimenti del mouse e le attività di scroll dei visitatori su una determinata pagina. Questo ti aiuta a scoprire dove gli utenti pensano che qualcosa sia cliccabile ma non lo è, se ci sono parti della pagina che vengono visualizzate o interagiscono a malapena, cosa cercano i tuoi visitatori, quanta parte della pagina è visibile quando gli utenti la visualizzano e altro.", + "HeatmapWidth": "Larghezza heatmap", + "HeatmapX": "Heatmap %s", + "HeatmapXRecordedSamplesSince": "%1$s campioni sono stati registrati dal %2$s.", + "Heatmaps": "Heatmaps", + "Manage": "Gestione", + "ManageHeatmapSubcategoryHelp": "Le heatmaps ti consentono di registrare tutti i click, i movimenti del mouse e le attività di scorrimento dei tuoi visitatori su una pagina specifica. Questa sezione ti consente di creare e di gestire il tracking Heatmap per il tuo sito web.", + "ManageHeatmaps": "Gestisci Heatmaps", + "ManageSessionRecordingSubcategoryHelp": "Le registrazioni delle sessioni ti consentono di registrare tutte le attività di un visitatore reale durante la sua sessione (visita) come click, movimenti del mouse, scroll, ridimensionamenti delle finestre, cambi di pagina e interazioni del modulo. Questa sezione ti consente di creare e gestire le registrazioni delle sessioni per il tuo sito.", + "ManageSessionRecordings": "Gestione Registrazioni di Sessione", + "MinSessionTime": "Tempo Minimo per Sessione", + "MinSessionTimeHelp": "Una sessione verrà registrata solo quando un visitatore ha trascorso almeno il tempo specificato su una pagina.", + "NHeatmaps": "%s heatmaps", + "NSessionRecordings": "%s registrazioni di sessione", + "NoHeatmapSamplesRecordedYet": "Nessuna visualizzazione di pagina è stata ancora registrata per questa heatmap. Se si suppone che ci dovrebbero essere delle registrazioni, forse le pagine obiettivo configurate per questa heatmap non corrispondono a nessuna pagina sul tuo sito web. Si consiglia inoltre di verificare \"Controllo Sistema\" in \"Amministrazione\" (solo Super Users) per vedere se il sistema è configurato per monitorare automaticamente le Heatmap.", + "NoHeatmapScreenshotRecordedYet": "Finora sono stati registrati %1$s campioni. Tuttavia, nessuno screenshot è stato ancora preso. Se c'è un \"%2$s\" per questa heatmap, potrebbe volerci un po' prima che lo screenshot diventi disponibile, poiché un utente deve prima aprire l'URL di questo screenshot. A seconda della frequenza di campionamento, ciò potrebbe richiedere del tempo.", + "NoHeatmapsConfiguredInfo": "Non ci sono al momento heatmap attive. Per visualizzare le heatmap, chiedi a un utente con almeno l'accesso come amministratore di crearne una nuova.", + "NoHeatmapsFound": "Non sono state trovate heatmaps", + "NoSessionRecordedYet": "Nessuna sessione è stata ancora registrata. Se in questo momento dovrebbero essere state registrate delle sessioni, forse la pagina di ingresso configurata non corrisponde a nessuna pagina sul tuo sito web. Si consiglia inoltre di verificare \"Controllo del Sistema\" in \"Amministrazione\" (solo Super Users) per vedere se il sistema è configurato per registrare automaticamente le sessioni.", + "NoSessionRecordingsConfiguredInfo": "Non ci sono attualmente registrazioni di sessioni attive. Per registrare nuove sessioni, chiedi a un utente che abbia almeno l'accesso come amministratore di creare una nuova registrazione.", + "NoSessionRecordingsFound": "Non sono state trovate registrazioni di sessione", + "NotSupportedBrowser": "Questo browser non è supportato, si prega di utilizzare una versione successiva o di provare un browser diverso.", + "OnePageview": "1 visualizzazione di pagina", + "PageRule": "Regola Pagina", + "PageviewXofY": "Vista pagina %1$s di %2$s", + "PageviewsInVisit": "Visualizzazioni di pagina registrate in questa sessione", + "PersonalInformationNote": "Si noti che %1$s può registrare informazioni personali o sensibili. Se desideri nascondere determinati contenuti sul tuo sito web o app, puoi taggare questo contenuto aggiungendo un attributo %2$sdata-matomo-mask%3$s al codice HTML. %4$sPer saperne di più%5$s", + "PlayRecordedSession": "Riproduci questa sessione registrata", + "PlayerDurationXofY": "%1$s di %2$s", + "PlayerForwardFast": "Salta %1$s secondi (scorciatoia %2$s)", + "PlayerPageViewNext": "Riproduci la successiva visualizzazione di pagina (%1$s) (scorciatoia %2$s)", + "PlayerPageViewPrevious": "Riproduci le precedenti visualizzazioni di pagina di questo visitatore %1$s (scorciatoia %2$s)", + "PlayerPause": "Pausa (scorciatoia %s o spazio)", + "PlayerPlay": "Riproduci (scorciatoia %s o spazio)", + "PlayerReplay": "Ripeti (scorciatoia %s o spazio)", + "PlayerRewindFast": "Indietro di %1$s secondi (scorciatoia %2$s)", + "RecordedHeatmapDocStatusActive": "Questa heatmap è attiva. Verranno registrate fino a %1$d visualizzazioni di pagina con una frequenza di campionamento di %2$s.", + "RecordedHeatmapDocStatusEnded": "Questa registrazione di sessione è terminata. Non verranno registrate nuove attività.", + "RecordedSessions": "Sessioni Registrate", + "RecordedSessionsDocStatusActive": "Questa registrazione di sessione è attiva. Verranno registrate fino a %1$d sessioni, con una frequenza di campionamento di %2$s.", + "RecordedSessionsDocStatusEnded": "Questa registrazione di sessione è terminata e non verranno registrate nuove sessioni.", + "Recordings": "Registrazioni", + "ReplayRecordedSession": "Riproduci sessione registrata", + "ReplayX": "Riproduci %s", + "ReportRecordedSessionsDocumentation": "Elenco di tutte le sessioni che sono state registrate. Puoi ripetere qualsiasi sessione registrata.", + "RequiresActivity": "Richiede attività", + "RequiresActivityHelp": "Se abilitato, verranno registrate solo le sessioni che hanno un'attività di scorrimento e di click in una visualizzazione di pagina. Ciò impedisce la registrazione di sessioni con poca attività.", + "Rule": "Regola", + "SampleLimit": "Limite di Campionamnto", + "SampleRate": "Frequenza di Campionamento", + "ScreenshotUrl": "Screenshot URL", + "ScreenshotUrlHelp": "Per impostazione predefinita, viene eseguito uno screenshot quando il primo visitatore visualizza la pagina di destinazione. Se diversi URL di pagina corrispondono alla tua pagina di destinazione, puoi impostare un URL specifico affinché sia usato per catturare lo screenshot. La visualizzazione della heatmap sarà disponibile solo quando almeno un visitatore ha visitato questo URL, le cui attività sono registrate. Se definito, l'URL deve corrispondere in maniera esatta. Per ignorare il protocollo, avvia l'URL con due doppie barre (//example.com). Si prega di notare che questo URL non può essere modificato dal momento in cui viene preso uno screenshot.", + "SessionNameHelp": "Definisce il nome con cui saranno disponibili le registrazioni della sessione.", + "SessionRecording": "Registrazione di Sessione", + "SessionRecordingCreated": "La registrazione di sessione è stata creata con successo.", + "SessionRecordingUpdated": "La registrazione di sessione è stata aggiornata con successo.", + "SessionRecordingX": "Registrazione di Sessione %s", + "SessionRecordings": "Registrazioni di Sessione", + "SessionRecordingsUsageBenefits": "Le registrazioni di sessione consentono di registrare tutte le attività di un visitatore reale durante la propria sessione (visita) quali click, movimenti del mouse, scroll, ridimensionamenti della finestra, modifiche della pagina e interazioni tra moduli. È quindi possibile ripetere queste interazioni per vedere esattamente come un visitatore ha interagito con il tuo sito web. In questo modo puoi capire le aspettative, i problemi, gli schemi di utilizzo e altro ancora.", + "SessionSampleLimit": "Numero di sessioni", + "SessionSampleLimitHelp": "Definisce il numero totale di sessioni che vuoi registrare.", + "SessionSampleRateHelp": "Conosciuto anche come \"traffico\". Quando selezioni 100%%, verranno registrate tutte le sessioni non appena avranno raggiunto la pagina di destinazione. Quando selezioni, per esempio, 10%%, verrà registrata solo una sessione ogni 10. Più bassa è la percentuale selezionata, più tempo ci vorrà per raggiungere il limite di campionamento selezionato.", + "StatusActive": "Attiva", + "StatusEnded": "Terminata", + "StopX": "Ferma la registrazione di ogni nuova attività per questa %s.", + "TargetAttributePath": "Percorso", + "TargetAttributeUrl": "URL", + "TargetAttributeUrlParameter": "Parametro URL", + "TargetAttributeUrlParameterExample": "nameOfUrlParameter", + "TargetPage": "Pagina Obiettivo", + "TargetPageTestErrorInvalidUrl": "Inserisci un URL incluso un protocollo.", + "TargetPageTestLabel": "Inserisci un URL completo, incluso il protocollo, per verificare se le attività su questo URL verranno registrate:", + "TargetPageTestTitle": "Convalida URL", + "TargetPageTestUrlMatches": "Le attività su questo URL verranno registrate", + "TargetPageTestUrlNotMatches": "Le attività su questo URL non verranno registrate", + "TargetPages": "Pagine di Ingresso", + "TargetTypeContains": "Contiene", + "TargetTypeEqualsExactly": "esattamente uguale a", + "TargetTypeEqualsExactlyInfo": "Il valore deve corrispondere esattamente, incluso il protocollo URL, la query di ricerca e l'hash.", + "TargetTypeEqualsSimple": "uguale semplice", + "TargetTypeEqualsSimpleInfo": "L'URL corrisponderà a qualsiasi protocollo (ad esempio http e https) con o senza il sottodominio \"www.\". Qualsiasi barra finale del percorso, nonché la query di ricerca e la parte hash dell'URL verranno ignorate durante la corrispondenza dell'URL.", + "TargetTypeExists": "esiste", + "TargetTypeIsAny": "è qualsiasi", + "TargetTypeIsNot": "non %s", + "TargetTypeRegExp": "corrisponde all'espressione regolare", + "TargetTypeRegExpInfo": "Ogni espressione regolare, per esempio \"^(.*)test(.*)$\".", + "TargetTypeStartsWith": "comincia con", + "TimeOnSite": "Tempo sul sito", + "TrackingDisabledDefaultSettingDescription": "Questa funzionalità può essere utile se il tuo Matomo gestisce più siti e desideri utilizzare la funzione solo in alcuni siti specifici. Quando si disabilita il tracking come impostazione predefinita, le funzionalità Heatmap e Registrazione della sessione non verranno eseguite su alcun sito a meno che non le si abiliti specificamente come parte del codice di tracking, ad esempio chiamando '_paq.push (['HeatmapSessionRecording :: enable']); ' . Ciò evita richieste di rete non necessarie al file 'configs.php' per determinare se una heatmap o una registrazione di sessione è configurata per il sito corrente. Può anche essere utile per motivi di privacy se vuoi assicurarti che la funzione venga utilizzata solo su alcuni siti.", + "TrackingDisabledDefaultSettingTitle": "Disabilita il tracking come impostazione predefinita", + "UpdatingData": "Aggiornamento dati…", + "UrlParameterValueToMatchPlaceholder": "Valore da abbinare per il nome del parametro URL", + "UrlXDoesNotLookLikeUrl": "%snon sembra un URL. Assicurati che contenga un protocollo come http:// oppure che inizi con //", + "ViewReport": "Vedi report", + "ViewportResolution": "Risoluzione della finestra visibile (Viewport: larghezza x altezza)", + "XSamples": "%s campioni", + "disable": "disabilita", + "enable": "abilita" + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/ja.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/ja.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/ja.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/ka.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/ka.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/ka.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/ko.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/ko.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/ko.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/ku.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/ku.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/ku.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/lb.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/lb.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/lb.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/lt.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/lt.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/lt.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/lv.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/lv.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/lv.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/ms.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/ms.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/ms.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/nb.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/nb.json new file mode 100644 index 0000000..ef82999 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/nb.json @@ -0,0 +1,35 @@ +{ + "HeatmapSessionRecording": { + "Recordings": "Opptak", + "Heatmaps": "Varmekart", + "Heatmap": "Varmekart", + "HeatmapX": "Varmekart %s", + "NHeatmaps": "%s varmekart", + "NSessionRecordings": "%s opptak av besøk", + "NoHeatmapsFound": "Fant ingen varmekart", + "NoSessionRecordingsFound": "Fant ingen opptak av besøk", + "HeatmapUsageBenefits": "Varmekart lar deg ta opp og visualisere alle klikk, musbevegelser og skrolle-aktiviteter når dine besøkere er på en gitt side. Dette hjelper deg å finne ut hva brukere tror kan klikkes på men ikke er det, om det er deler av siden som ikke brukes, hva besøkerne ser etter, hvor mye av siden som synlig når brukere ser på siden, og mer.", + "HeatmapXRecordedSamplesSince": "%1$s opptak har blitt gjort siden %2$s.", + "SessionRecordingsUsageBenefits": "Opptak av besøk lar deg spille inn alle aktiviteter av reelle brukere i løpet av besøkene, slik som klikk, musebevegelser, skrolling, justering av vindusstørrelse, sideendringer og skjemainteraksjoner. Du kan så spille av disse interaksjonene for å se nøyaktig hvordan en bruker brukte ditt nettsted. På denne måten forstår du bedre deres forventninger, problemer, bruksmønstre, og mer.", + "SessionRecordings": "Opptak av besøk", + "SessionRecording": "Opptak av besøk", + "AutoPlayNextPageview": "Klikk for å %1$s autospilling av neste sidevisning (snarvei %2$s)", + "ClickToSkipPauses": "Klikk for å %1$s hoppe over pauser (snarvei %2$s)", + "ChangeReplaySpeed": "Endre avspillingshastighet (snarvei %s)", + "PageviewXofY": "Sidevisning %1$s av %2$s", + "enable": "aktiver", + "disable": "deaktiver", + "ReplayX": "Spill av %s", + "CreationDate": "Opprettet dato", + "SessionRecordingX": "Opptak %s", + "ReplayRecordedSession": "Spill av innspilt besøk", + "RecordedSessions": "Opptak av besøk", + "RecordedSessionsDocStatusActive": "Dette opptaket er aktivt. %2$s av besøkene vil bli tatt opp, opp til %1$d besøk.", + "RecordedSessionsDocStatusEnded": "Dette opptaket har blitt avsluttet, og ingen nye besøk vil bli tatt opp.", + "RecordedHeatmapDocStatusActive": "Varmekartet er aktivt. %2$s av sidevisningene vil bli tatt opp, opp til %1$d sidevisninger.", + "RecordedHeatmapDocStatusEnded": "Opptaket av varmekartet er avsluttet. Ingen ny aktivitet vil bli tatt opp.", + "StatusActive": "Aktiv", + "StatusEnded": "Avsluttet", + "ExcludedElements": "Ekskluderte elementer" + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/nl.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/nl.json new file mode 100644 index 0000000..9eb6ade --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/nl.json @@ -0,0 +1,185 @@ +{ + "HeatmapSessionRecording": { + "ActivityClick": "Klik", + "ActivityFormChange": "Formulier Wijziging", + "ActivityFormText": "Formulier Tekst Wijziging", + "ActivityFormValue": "Formulier Waarde Wijziging", + "ActivityInitialDom": "Initiële Pagina", + "ActivityMove": "Verplaats", + "ActivityResize": "Verkleinen", + "ActivityScroll": "Scroll", + "ActivityScrollElement": "Scroll Element", + "AdvancedOptions": "Geavanceerde Instellingen", + "AutoPlayNextPageview": "Klik %1$s om de volgende pagina bezoek automatisch af te spelen (shortcut %2$s)", + "AvgAboveFoldDescription": "Gemiddeld zien bezoekers de inhoud boven deze regel zonder te scrollen", + "AvgAboveFoldTitle": "Gem. Boven Vouw %spx", + "BreakpointGeneralHelp": "Het apparaattype wordt automatisch gedetecteerd wanneer een bezoeker uw website bezoekt. Soms is het echter niet mogelijk om het apparaattype te detecteren. Als uw website reageert en uw lay-out op een bepaald punt breekt, zullen we desktopgebruikers met een kleine resolutie in dit apparaattype plaatsen voor nauwkeurigere heatmaps. Elke breedte lager dan deze waarde wordt in dit apparaattype geplaatst. Zet het op nul als uw website niet reageert.", + "BreakpointGeneralHelpManage": "Een gebruiker met Super User rechten kan de standaard waarden voor deze breakpoints wijzigen in Administratie => Algemene Instellingen.", + "BreakpointX": "Breakpoint %s", + "CaptureKeystrokes": "Leg toetsaanslagen vast", + "CaptureKeystrokesHelp": "Indien ingeschakeld, wordt alle tekst die is ingevoerd in tekstformuliervelden, vastgelegd. Terwijl de tekst wordt opgenomen, wordt elk teken dat een gebruiker invoert vervangen door een ster ('*'). U kunt bepaalde velden die in platte tekst moeten worden opgenomen, op de witte lijst zetten door een 'data-matomo-unmask'-kenmerk op te geven in een formulierveld. Wachtwoordvelden en vele andere velden die mogelijk persoonlijke informatie kunnen bevatten (adres, e-mail, creditcardgegevens, gebruikersnaam, telefoonnummer, ...) worden echter altijd automatisch gemaskeerd als ze door ons als zodanig worden gedetecteerd (%1$slearn more%2$s) . Houd er rekening mee dat wanneer u deze functie inschakelt, u persoonlijke gegevens kunt opnemen die van invloed kunnen zijn op de AVG.", + "ChangeReplaySpeed": "Wijzig afspeel snelheid (shortcut %s)", + "ClickToSkipPauses": "Klik op %1$s om pauzes over te slaan (shortcut %2$s)", + "ColumnActionsDocumentation": "Speel de opgenomen sessie of verwijder de opname definitief.", + "ColumnBrowserDocumentation": "De browser die de bezoeker gebruikte toen deze sessie werd opgenomen.", + "ColumnDeviceDocumentation": "Het apparaat die de bezoeker gebruikte toen deze sessie werd opgenomen.", + "ColumnLabelRecordedSessions": "Binnenkomst URL → Exit URL", + "ColumnLocationDocumentation": "De locatie van de bezoeker toen deze sessie werd opgenomen.", + "ColumnOperatingSystem": "OS", + "ColumnOperatingSystemDocumentation": "Het besturingssysteem dat de bezoeker gebruikte toen deze sessie werd opgenomen.", + "ColumnPageviewsDocumentation": "Het aantal paginaweergaven dat is opgenomen gedurende deze sessie.", + "ColumnResolutionDocumentation": "Het aantal pixels van de zichtbare viewport in het browservenster toen de gebruiker deze pagina opende.", + "ColumnTime": "Tijd", + "ColumnTimeDocumentation": "Het tijdstip de opname startte in de tijdzone van de website.", + "ColumnTimeOnPageDocumentation": "Hoeveel tijd de bezoeker op deze pagina heeft doorgebracht terwijl de sessie werd opgenomen", + "ColumnTimeOnSiteDocumentation": "Hoeveel tijd de bezoeker in totaal doorbracht terwijl de sessie werd opgenomen.", + "ConfigsInternetDisabled": "We konden niet controleren of '%s' toegankelijk is omdat de internetverbinding op deze Matomo is uitgeschakeld.", + "ConfigsPhpErrorResult": "Als gevolg hiervan werkt het bijhouden van Heatmaps en Session Recordings mogelijk niet. Mogelijk moet u uw webserverconfiguratie wijzigen om toegang tot dit bestand via internet of intranet mogelijk te maken.", + "ConfigsPhpManualCheck": "Open de URL handmatig in een browser om te zien of het antwoord 'Piwik.HeatmapSessionRecording' bevat. Als dat niet het geval is, moet u mogelijk uw serverconfiguratie wijzigen omdat dit bestand toegankelijk moet zijn via een browser vanaf internet of intranet.", + "ConfigsPhpNotAccessible": "De URL '%s' lijkt niet toegankelijk vanaf het Internet of Intranet.", + "ConfigsPhpSelfSignedError": "Opvragen van '%s' resulteerde in een SSL error. Misschien gebruik je een zelfondertekend certificaat?", + "ConfigsPhpSuccess": "'%s' is toegankelijk", + "ConfigsPhpUnknown": "We konden niet controleren of '%s' toegankelijk is via internet of intranet.", + "ConfigsPhpUnknownError": "Het opvragen van '%1$s' resulteerde in een error: %2$s.", + "CreateNewHeatmap": "Maak een nieuwe heatmap", + "CreateNewSessionRecording": "Creëer nieuwe sessie opname", + "CreationDate": "Creatie Datum", + "DeleteHeatmapConfirm": "Weet je zeker dat je deze heatmap wilt verwijderen? Rapporten die eerder zijn gegenereerd, zijn niet langer beschikbaar in de rapportage-gebruikersinterface.", + "DeleteHeatmapScreenshotConfirm": "Weet je zeker dat je dit screenshot wilt verwijderen? Het kan een tijd duren voordat het screenshot opnieuw genomen wordt.", + "DeleteRecordedPageview": "Verwijder deze opgenomen paginaweergave (er wordt geen bevestiging gevraagd)", + "DeleteRecordedSession": "Verwijder deze opgenomen sessie (er wordt geen bevestiging gevraagd)", + "DeleteScreenshot": "Verwijder Screenshot", + "DeleteSessionRecordingConfirm": "Weet u zeker dat u deze sessie-opname wilt verwijderen? Eerder gegenereerde opnamen zijn niet langer beschikbaar in de rapportage-UI.", + "DeleteX": "Verwijder %s", + "EditHeatmapX": "Bewerk Heatmap %s", + "EditSessionRecordingX": "Bewerk Sessie Opname %s", + "EditX": "Bewerk %s", + "EndHeatmapConfirm": "Weet je zeker dat je wilt stoppen met het opnemen van activiteiten voor deze heatmap?", + "EndSessionRecordingConfirm": "Weet je zeker dat je wilt stoppen met het opnemen van nieuwe sessies?", + "ErrorArrayMissingKey": "Missende array sleutel \"%1$s\" in \"%2$s\" op positie \"%3$s\".", + "ErrorArrayMissingValue": "Missende waarde voor array sleutel \"%1$s\" in \"%2$s\" op positie \"%3$s\".", + "ErrorHeatmapDoesNotExist": "De opgevraagde heatmap bestaat niet.", + "ErrorHeatmapNameDuplicate": "De heatmap naam is al in gebruik door een andere heatmap.", + "ErrorInnerIsNotAnArray": "Elke \"%1$s\" binnen \"%2$s\" dient een array (reeks) te zijn.", + "ErrorInvalidRegExp": "De reguliere expressie \"%1$s\" heeft geen geldig formaat.", + "ErrorNotAnArray": "\"%1$s\" dient een reeks te zijn.", + "ErrorPageRuleRequired": "Er moet minimaal één paginaregel worden ingesteld.", + "ErrorSessionRecordingDoesNotExist": "De gevraagde sessie-opname bestaat niet", + "ErrorXContainsWhitespace": "De \"%1$s\" mag geen witruimte bevatten.", + "ErrorXNotANumber": "\"%1$s\" moet een getal zijn.", + "ErrorXNotProvided": "Geef een waarde voor \"1%1$s\".", + "ErrorXNotWhitelisted": "De waarde voor \"%1$s\" is niet toegestaan. Gebruik één van de: %2$s", + "ErrorXTooHigh": "\"%1$s\" is te hoog, de maximaal toegestane waarde is %2$s.", + "ErrorXTooLong": "\"%1$s\" is te lang, maximaal %2$skarakters zijn toegestaan.", + "ErrorXTooLow": "\"%1$s\" is te laag, de minimaal toegestane waarde is %2$s.", + "ExcludedElements": "Uitgesloten elementen", + "ExcludedElementsHelp": "Optioneel kunnen CSS-selectors gedefiniërd worden om bepaalde elementen uit te sluiten die niet zichtbaar zouden moeten zijn in het voorbeeld van de heatmap. Bijvoorbeeld een pop-up die wordt getoond wanneer de doelpagina wordt geopend. Meerdere selectors worden gescheiden met een komma.", + "FieldIncludedTargetsHelp": "Met doelen kunt u definiëren op welke pagina's de gebruikersactiviteiten moeten worden geregistreerd. U kunt een of meer voorwaarden definiëren. U kunt bijvoorbeeld definiëren om activiteiten vast te leggen wanneer de URL of het pad gelijk is aan een bepaalde waarde en alleen als een bepaalde URL-parameter aanwezig is in de URL. Activiteiten worden alleen geregistreerd als aan alle voorwaarden is voldaan, niet als slechts aan één ervan is voldaan. Alle voorwaarden worden hoofdletterongevoelig beoordeeld. Als u 'is gelijk aan eenvoudig' selecteert, worden het URL-protocol, zoekparameters en een slash achterwege gelaten.", + "FieldIncludedTargetsHelpSessions": "Targets stellen u in staat om te configureren om de opname van een sessie pas te starten zodra een bezoeker een bepaalde pagina heeft bereikt. Hiermee kunt u bijvoorbeeld alleen sessies opnemen die het afrekenproces doorlopen. U kunt een of meer voorwaarden definiëren. U kunt bijvoorbeeld definiëren om een sessie alleen op te nemen als de URL of het pad gelijk is aan een bepaalde waarde en alleen als een bepaalde URL-parameter aanwezig is in de URL. Sessies worden alleen geregistreerd als aan alle voorwaarden op een paginaweergave is voldaan, niet als slechts aan één ervan is voldaan. Alle voorwaarden worden hoofdletterongevoelig beoordeeld. Als u 'is gelijk aan eenvoudig' selecteert, worden het URL-protocol, zoekparameters en een slash achterwege gelaten.", + "FieldNamePlaceholder": "bijv 'Aanmeld Pagina'", + "FilesystemDirectory": "directory", + "Filter": "Filter", + "GettingStarted": "Getting started", + "Heatmap": "Heatmap", + "HeatmapCreated": "De heatmap is met succes aangemaakt.", + "HeatmapNameHelp": "Bepaalt de naam waaronder het rapport voor deze heatmap beschikbaar zal zijn.", + "HeatmapSampleLimit": "Aantal paginaweergaven", + "HeatmapSampleLimitHelp": "Bepaalt het aantal paginaweergaven dat je in totaal wilt opnemen.", + "HeatmapSampleRateHelp": "Ook wel 'traffic' genoemd. Wanneer u 100%% selecteert, worden alle bezoekers die de geselecteerde doelpagina bezoeken, geregistreerd. Als u bijvoorbeeld 10%% selecteert, wordt alleen elke 10e bezoeker geregistreerd. Hoe lager het percentage dat u selecteert, hoe langer het duurt om de geselecteerde monsterlimiet te bereiken.", + "HeatmapUpdated": "De heatmap is succesvol bijgewerkt.", + "HeatmapUsageBenefits": "Met heatmaps worden alle klikken, muisbewegingen en schuifactiviteiten van bezoekers op een bepaalde pagina opgenomen. Dit helpt om erachter te komen waar gebruikers denken dat iets klikbaar is, maar dat niet is, of dat er delen van de pagina zijn die nauwelijks worden bekeken of interactie mee is, of wat bezoekers zoeken, of hoeveel van de pagina zichtbaar is wanneer gebruikers uw pagina bezoeken en meer.", + "HeatmapWidth": "Heatmap breedte", + "HeatmapX": "Heatmap %s", + "HeatmapXRecordedSamplesSince": "%1$s samples zijn opgenomen sinds %2$s.", + "Heatmaps": "Heatmaps", + "Manage": "Beheer", + "ManageHeatmaps": "Beheer Heatmaps", + "ManageSessionRecordings": "Beheer Sessie Opnames", + "MinSessionTime": "Min Sessie Tijd", + "MinSessionTimeHelp": "Een sessie wordt alleen opgenomen als een bezoeker ten minste de opgegeven tijd op een pagina heeft doorgebracht.", + "NHeatmaps": "%s heatmaps", + "NSessionRecordings": "%s sessie opnames", + "NoHeatmapSamplesRecordedYet": "Er is nog geen paginaweergave geregistreerd voor deze heatmap. Als er nu opnames zouden moeten zijn, komen de paginadoelen die voor deze heatmap zijn geconfigureerd misschien niet overeen met een pagina op uw website. Het wordt ook aanbevolen om de \"Systeemcontrole\" onder \"Beheer\" (alleen Supergebruikers) te controleren om te zien of uw systeem is geconfigureerd om Heatmaps automatisch te volgen.", + "NoHeatmapScreenshotRecordedYet": "Er zijn tot nu toe %1$s samples opgenomen. Er is echter nog geen screenshot gemaakt. Als er een \"%2$s\" is voor deze heatmap, kan het even duren voordat de schermafbeelding beschikbaar komt, aangezien een gebruiker eerst deze schermafbeelding-URL moet openen. Afhankelijk van de samplefrequentie kan dit even duren.", + "NoHeatmapsConfiguredInfo": "Er zijn momenteel geen actieve heatmaps. Om heatmaps te bekijken, vraag een gebruiker met minstens admin-toegang om een nieuwe heatmap te maken.", + "NoHeatmapsFound": "Geen heatmaps gevonden", + "NoSessionRecordedYet": "Er is nog geen sessie opgenomen. Als er inmiddels opnamesessies moeten zijn, komt de geconfigureerde invoerpagina misschien niet overeen met een pagina op uw website. Het wordt ook aanbevolen om de \"Systeemcontrole\" onder \"Beheer\" (alleen Supergebruikers) te controleren om te zien of uw systeem is geconfigureerd voor automatisch opgenomen sessies.", + "NoSessionRecordingsConfiguredInfo": "Er zijn momenteel geen actieve sessie-opnames. Vraag een gebruiker met ten minste admin rechten om een nieuwe opname te maken om nieuwe sessies op te nemen.", + "NoSessionRecordingsFound": "Geen sessie opnames gevonden", + "NotSupportedBrowser": "Deze browser wordt niet ondersteund, gebruik een latere versie of een andere browser.", + "OnePageview": "1 pageweergave", + "PageRule": "Pagina Regel", + "PageviewXofY": "Paginaweergave %1$s van %2$s", + "PageviewsInVisit": "Opgenomen paginaweergaven in deze sessie", + "PersonalInformationNote": "Houd er rekening mee dat %1$s persoonlijke of gevoelige informatie kan vastleggen. Als u bepaalde inhoud op uw website of app wilt verbergen, kunt u deze inhoud taggen door een kenmerk %2$s data-matomo-mask %3$s toe te voegen aan de HTML-opmaak. %4$s Meer informatie %5$s", + "PlayRecordedSession": "Speel deze opgenomen sessie af", + "PlayerDurationXofY": "%1$s of %2$s", + "PlayerForwardFast": "Sla %1$s seconden over (shortcut %2$s)", + "PlayerPageViewNext": "Speel de volgende paginaweergave van deze bezoeker (%1$s) (shortcut %2$s)", + "PlayerPageViewPrevious": "Speel de vorige paginaweergave van deze bezoeker %1$s (shortcut %2$s)", + "PlayerPause": "Pauze (shortcut %s of spatie)", + "PlayerPlay": "Afspelen (shortcut %s of spatie)", + "PlayerReplay": "Herhaal (shortcut %s of spatie)", + "PlayerRewindFast": "Terugspoelen %1$s seconden (shortcut %2$s)", + "RecordedHeatmapDocStatusActive": "Deze heatmap is actief. Tot %1$d paginaweergaves zullen worden met een sample percentage van %2$s.", + "RecordedHeatmapDocStatusEnded": "Deze heatmap is geëindigd. Nieuwe activiteiten worden niet meer opgenomen.", + "RecordedSessions": "Opgenomen sessies", + "RecordedSessionsDocStatusActive": "Deze sessie opname is actief. Tot %1$d sessies zullen worden opgenomen met een sample percentage van %2$s.", + "RecordedSessionsDocStatusEnded": "Deze sessie opname is beëindigd en nieuwe sessies worden niet meer opgenomen.", + "Recordings": "Opnamen", + "ReplayRecordedSession": "Herhaal opgenomen sessie", + "ReplayX": "Herhaal %s", + "ReportRecordedSessionsDocumentation": "Een lijst met all sessie die opgenomen zijn. Je kan alle opgenomen sessie opnieuw afspelen.", + "RequiresActivity": "Vereist activiteit", + "RequiresActivityHelp": "Indien ingeschakeld, worden alleen sessies met een scroll- en klikactiviteit in één paginaweergave opgenomen. Dit voorkomt het opnemen van sessies met weinig activiteit.", + "Rule": "Regel", + "SampleLimit": "Sample Limiet", + "SampleRate": "Sample Percentage", + "ScreenshotUrl": "Screenshot URL", + "ScreenshotUrlHelp": "Standaard wordt er een screenshot gemaakt wanneer de eerste bezoeker de doelpagina bekijkt. Als verschillende pagina-URL's overeenkomen met uw doelpagina, kunt u een specifieke URL specificeren die moet worden gebruikt om de schermafbeelding vast te leggen. De heatmap-visualisatie is pas beschikbaar als ten minste één bezoeker deze URL heeft bezocht waarvan de activiteiten zijn vastgelegd. Indien gedefinieerd, moet de URL exact overeenkomen. Om het protocol te negeren, start u de URL met twee dubbele schuine strepen (//example.com). Houd er rekening mee dat deze URL niet kan worden gewijzigd zodra er een screenshot is gemaakt", + "SessionNameHelp": "Definieert de naam waaronder de sessie-opnames beschikbaar zullen zijn.", + "SessionRecording": "Sessie Opname", + "SessionRecordingCreated": "De sessie-opname is met succes gemaakt.", + "SessionRecordingUpdated": "De sessie-opname is succesvol bijgewerkt.", + "SessionRecordingX": "Sessie Opname %s", + "SessionRecordings": "Sessie Opnames", + "SessionRecordingsUsageBenefits": "Met sessie-opnames worden alle activiteiten van een echte bezoeker tijdens zijn of haar sessie (bezoek) opgenomen, zoals klikken, muisbewegingen, schuiven, schermgrootte, paginawijzigingen en formulier interacties. Deze interacties kunnen vervolgens opnieuw afgespeeld worden om precies te zien hoe een bezoeker door de website ging. Op deze manier begrijpt je hun verwachtingen, problemen die ze kunnen hebben, gebruikers patronen en meer.", + "SessionSampleLimit": "Aantal sessies", + "SessionSampleLimitHelp": "Bepaalt het aantal sessies dat je in totaal wilt opnemen.", + "SessionSampleRateHelp": "Ook wel 'traffic' genoemd. Wanneer u 100%% selecteert, worden alle sessies opgenomen zodra ze de doelpagina hebben bereikt. Als u bijvoorbeeld 10%% selecteert, wordt alleen elke 10e sessie geregistreerd. Hoe lager het percentage dat u selecteert, hoe langer het duurt om de geselecteerde monsterlimiet te bereiken.", + "StatusActive": "Actief", + "StatusEnded": "Gestopt", + "StopX": "Stop met het opnemen van nieuwe activiteiten voor %s.", + "TargetAttributePath": "Pad", + "TargetAttributeUrl": "URL", + "TargetAttributeUrlParameter": "URL Parameter", + "TargetAttributeUrlParameterExample": "naamVanUrlParameter", + "TargetPage": "Landingspagina", + "TargetPageTestErrorInvalidUrl": "Voer een URL in inclusief het protocol.", + "TargetPageTestLabel": "Voer een volledige URL inclusief het protocol in om te controleren of alle activiteiten opgenomen op deze URL:", + "TargetPageTestTitle": "URL validator", + "TargetPageTestUrlMatches": "Activiteiten worden op deze URL vastgelegd", + "TargetPageTestUrlNotMatches": "Activiteiten worden niet vastgelegd op deze URL", + "TargetPages": "Binnenkomst Pagina's", + "TargetTypeContains": "bevat", + "TargetTypeEqualsExactly": "is precies hetzelfde", + "TargetTypeEqualsExactlyInfo": "De waarde dient precies hetzelfde te zijn, inclusief het URL protocol, zoek vraag en hash.", + "TargetTypeEqualsSimple": "is gelijk aan", + "TargetTypeEqualsSimpleInfo": "De URL komt overeen met elk protocol (bijv. Http en https) met of zonder \"www.\" subdomein. Elke volgende slash in het pad, evenals de zoekopdracht en het hash gedeelte van de URL worden genegeerd bij het matchen van de URL.", + "TargetTypeExists": "bestaat", + "TargetTypeIsAny": "is er een", + "TargetTypeIsNot": "geen %s", + "TargetTypeRegExp": "komt overeen met reguliere expressie", + "TargetTypeRegExpInfo": "Elke reguliere expressie, bijvoorbeeld \"^(.*)test(.*)$\".", + "TargetTypeStartsWith": "begint met", + "TimeOnSite": "Tijd op de website", + "UpdatingData": "Bijwerken gegevens…", + "UrlParameterValueToMatchPlaceholder": "Te matchen waarde voor URL-parameternaam", + "UrlXDoesNotLookLikeUrl": "%s lijkt niet op een URL. Zorg ervoor dat deze een protocol zoals http:// bevat of start met //", + "ViewReport": "Bekijk rapport", + "ViewportResolution": "Viewport Resolutie (breedte x hoogte)", + "XSamples": "%s samples", + "disable": "deactiveer", + "enable": "activeer" + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/nn.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/nn.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/nn.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/pl.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/pl.json new file mode 100644 index 0000000..8b4e9ad --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/pl.json @@ -0,0 +1,185 @@ +{ + "HeatmapSessionRecording": { + "ActivityClick": "Kliknięcie", + "ActivityFormChange": "Zmiana w formularzu", + "ActivityFormText": "Zmiana tekstu w formularzu", + "ActivityFormValue": "Zmiana wartości w formularzu", + "ActivityInitialDom": "Strona początkowa", + "ActivityMove": "Ruch", + "ActivityResize": "Zmiana rozmiaru", + "ActivityScroll": "Przewinięcie", + "ActivityScrollElement": "Przewijany element", + "AdvancedOptions": "Zaawansowane", + "AutoPlayNextPageview": "Kliknij aby %1$s odtworzyć następne nagranie (skrót %2$s)", + "AvgAboveFoldDescription": "Przeciętny odwiedzający widzi zawartość powyżej tej linii bez przewijania", + "AvgAboveFoldTitle": "Śr. powyżej zgięcia %spx", + "BreakpointGeneralHelp": "Typ urządzenia jest automatycznie wykrywany podczas odwiedzin na Twojej stronie. Czasami zdarza się, że poprawne wykrycie urządzenia nie jest możliwe. W przypadku stron responsywnych, gdy projekt strony zmienia się dla pewnych rozdzielczości, możemy umieścić użytkowników komputerów stacjonarnych z ekranami o niskich rozdzielczościach umieścić w tej grupie, aby wygenerować dokładniejsze mapy aktywności. Dowolna szerokość mniejsza niż ta wartość spowoduje umieszczenie odwiedzającego w tej grupie urządzeń. Ustaw zero dla nieresponsywnej strony.", + "BreakpointGeneralHelpManage": "Użytkownik z dostępem Super Użytkownika może zmienić domyślne wartości punktów zatrzymania w Ustawienia => Ustawienia ogólne.", + "BreakpointX": "Punkt podziału %s", + "CaptureKeystrokes": "Przechwytuj naciskane klawisze", + "CaptureKeystrokesHelp": "Włączenie tej opcji spowoduje rejestrację dowolnego tekstu wprowadzanego w polach formularza.dy wprowadzony przez użytkownika znak jest zamieniany na gwiazdkę ('*'). Możesz dodać wybrane pola do białej listy, co spowoduje ich zapisywanie jako zwykłego tekstu poprzez dodanie im atrybutu 'data-matomo-unmask'. Pola haseł i inne potencjalnie przenoszące dane osobowe (adres, email, dane karty kredytowej, nazwa użytkownika, numer telefonu) po wykryciu przez nas zawsze będą maskowane automatycznie (%1$sdowiedz się więcej%2$s). Prosimy pamiętaj, że włączając tą funkcjonalność możesz zapisać dane osobowe, co może naruszać zapisy RODO.", + "ChangeReplaySpeed": "Zmień prędkość odtwarzania (skrót %s)", + "ClickToSkipPauses": "Kliknij aby %1$s pominąć pauzy (skrót %2$s)", + "ColumnActionsDocumentation": "Odtwórz zapisaną sesję lub trwale usuń nagranie.", + "ColumnBrowserDocumentation": "Przeglądarka używana przez odwiedzającego podczas nagrywania tej sesji.", + "ColumnDeviceDocumentation": "Urządzenie użyte podczas nagrywania tej sesji.", + "ColumnLabelRecordedSessions": "Adres wejścia → Adres wyjścia", + "ColumnLocationDocumentation": "Lokalizacja odwiedzającego w czasie nagrywania tej sesji.", + "ColumnOperatingSystem": "System operacyjny", + "ColumnOperatingSystemDocumentation": "System operacyjny zainstalowany na urządzeniu odwiedzającego podczas nagrywania sesji.", + "ColumnPageviewsDocumentation": "Liczba odsłon strony, które zostały zapisane podczas tej sesji.", + "ColumnResolutionDocumentation": "Liczba pikseli widocznych w oknie przeglądarki w momencie otwarcia strony przez odwiedzającego.", + "ColumnTime": "Czas", + "ColumnTimeDocumentation": "Czas rozpoczęcia nagrania w strefie czasowej serwisu.", + "ColumnTimeOnPageDocumentation": "Czas spędzony przez odwiedzającego na stronie podczas nagrywania sesji", + "ColumnTimeOnSiteDocumentation": "Łączny czas spędzony przez odwiedzającego podczas nagrywania sesji", + "ConfigsInternetDisabled": "Nie mogliśmy sprawdzić, czy '%s' jest dostępny z powodu braku połączenia internetowego na tym Matomo.", + "ConfigsPhpErrorResult": "Wynikiem tego może być brak śledzenia Map Aktywności lub Zapisów Sesji. Możliwe, że musisz zmienić konfigurację swojego serwera www, aby udostępnić ten plik w Internecie lub Intranecie.", + "ConfigsPhpManualCheck": "Otwórz, proszę, stronę pod tym adresem w swojej przeglądarce i sprawdź, czy odpowiedź zawiera 'Piwik.HeatmapSessionRecording'. Jeśli nie znajdujesz tej frazy w odpowiedzi, konieczna może być zmiana ustawień serwera, ponieważ plik ten musi być dostępny dla przeglądarki w Internecie lub Intranecie.", + "ConfigsPhpNotAccessible": "Adres '%s' zdaje się być niedostępny z Internetu lub Intranetu.", + "ConfigsPhpSelfSignedError": "Żądanie '%s' zwróciło błąd SSL. Czy korzystasz z samopodpisanego certyfikatu?", + "ConfigsPhpSuccess": "'%s' jest dostępne", + "ConfigsPhpUnknown": "Nie mogliśmy sprawdzić, czy '%s' jest dostępne z Internetu lub Intranetu.", + "ConfigsPhpUnknownError": "Żądanie '%1$s' zwróciło błąd: %2$s.", + "CreateNewHeatmap": "Utwórz nową mapę aktywności", + "CreateNewSessionRecording": "Utwórz nowe nagranie sesji", + "CreationDate": "Data utworzenia", + "DeleteHeatmapConfirm": "Czy na pewno chcesz usunąć tą mapę aktywności? Poprzednio wygenerowane raporty map aktywności przestaną być dostępne w panelu.", + "DeleteHeatmapScreenshotConfirm": "Na pewno chcesz usunąć ten zrzut ekranu? Ponowne wygenerowania zrzutu potrwa dłuższą chwilę.", + "DeleteRecordedPageview": "Usuń to nagranie wyświetlenia strony (bez wyświetlenia potwierdzenia)", + "DeleteRecordedSession": "Usuń tą nagraną sesję (bez wyświetlenia potwierdzenia)", + "DeleteScreenshot": "Usuń zrzut ekranu", + "DeleteSessionRecordingConfirm": "Czy na pewno chcesz usunąć ten zapis sesji? Poprzednio wygenerowane zapisy sesji przestaną być dostępne w panelu.", + "DeleteX": "Usuń %s", + "EditHeatmapX": "Edytuj Mapę Aktywności %s", + "EditSessionRecordingX": "Edytuj Zapis Sesji %s", + "EditX": "Edytuj %s", + "EndHeatmapConfirm": "Czy na pewno chcesz wstrzymać przechwytywanie aktywności dla wybranej mapy?", + "EndSessionRecordingConfirm": "Czy na pewno chcesz wstrzymać zapisywanie nowych sesji?", + "ErrorArrayMissingKey": "W tablicy \"%2$s\" brak klucza \"%1$s\" na pozycji \"%3$s\".", + "ErrorArrayMissingValue": "W tablicy \"%2$s\" brakuje wartości \"%1$s\" na pozycji \"%3$s\".", + "ErrorHeatmapDoesNotExist": "Poszukiwana mapa aktywności nie istnieje", + "ErrorHeatmapNameDuplicate": "Nazwa mapy aktywności jest już wykorzystywana przez inną mapę.", + "ErrorInnerIsNotAnArray": "Każdy \"%1$s\" w \"%2$s\" musi być tablicą.", + "ErrorInvalidRegExp": "Wyrażenie regularne \"%1$s\" nie ma właściwego formatu.", + "ErrorNotAnArray": "\"%1$s\" musi być tablicą.", + "ErrorPageRuleRequired": "Przynajmniej 1 reguła strony musi być ustawiona", + "ErrorSessionRecordingDoesNotExist": "Poszukiwany zapis sesji nie istnieje", + "ErrorXContainsWhitespace": "Pole \"%1$s\" nie może zawierać spacji.", + "ErrorXNotANumber": "\"%1$s\" musi być liczbą.", + "ErrorXNotProvided": "Proszę podać wartość dla \"%1$s\".", + "ErrorXNotWhitelisted": "Niedozwolona wartość pola \"%1$s\", spróbuj jednej z: %2$s.", + "ErrorXTooHigh": "Wartość \"%1$s\" przekracza maksimum, które wynosi %2$s.", + "ErrorXTooLong": "Ciąg \"%1$s\" jest za długi. Dopuszczone są ciągi o długości do %2$s znaków.", + "ErrorXTooLow": "Wartość \"%1$s\" jest poniżej minimum wynoszącego %2$s.", + "ExcludedElements": "Wykluczone elementy", + "ExcludedElementsHelp": "Tu możesz zdefiniować selektory CSS, które ukryją wybrane elementy, których nie chcesz wyświetlać w podglądzie aktywności. Przykładem może być popup otwierany po dotarciu do strony docelowej. Do rozdzielenia kilku selektorów użyj przecinka.", + "FieldIncludedTargetsHelp": "Cele pozwalają Ci zdefiniować, na których stronach aktywności użytkownika powinny być rejestrowane. Możesz zdefiniować jeden lub więcej warunków. Możesz, na przykład, zdefiniować rejestrowanie aktywności zawsze, gdy adres lub ścieżka równa jest pewnej wartości i tylko jeśli wybrany parametr adresu jest w nim obecny. Aktywności będą zapisywane, tylko gdy wszystkie warunki zostaną spełnione. Wszystkie warunki weryfikowane są z rozróżnieniem wielkości liter. Wybierając 'proste porównanie' spowodujesz ignorowanie protokołu, parametrów wyszukiwania i ostatniego ukośnika adresu.", + "FieldIncludedTargetsHelpSessions": "Cele pozwalają Ci zdefiniować strony, na które musi dotrzeć odwiedzający, aby uruchomić zapis sesji. W ten sposób możesz na przykład rejestrować sesje, podczas których użytkownik przechodzi przez koszyk. Możesz zdefiniować jeden lub więcej warunków. Możesz, na przykład, skonfigurować zapis sesji tylko, gdy adres lub ścieżka zawiera określoną wartość i tylko gdy wybrany parametr adresu jest w nim obecny. Sesje będą rejestrowane tylko, gdy wszystkie warunki zostaną spełnione podczas odsłony strony. Wszystkie warunki są sprawdzane z rozróżnieniem wielkości liter. Wybierając 'proste porównanie' spowodujesz ignorowanie protokołu, parametrów wyszukiwania i ostatniego ukośnika adresu.", + "FieldNamePlaceholder": "n.p. 'Strona rejestracji'", + "FilesystemDirectory": "katalog", + "Filter": "Filtruj", + "GettingStarted": "Jak rozpocząć", + "Heatmap": "Mapa aktywności", + "HeatmapCreated": "Mapa aktywności została pomyślnie utworzona.", + "HeatmapNameHelp": "Definiuje nazwę reprezentującą raport tej mapy aktywności.", + "HeatmapSampleLimit": "Liczba wyświetleń stron", + "HeatmapSampleLimitHelp": "Określ liczbę wszystkich wyświetleń strony, które chcesz nagrać.", + "HeatmapSampleRateHelp": "Znany jako 'transfer'. Po wybraniu wartości 100%%, funkcjonalność zarejestruje każdego odwiedzającego skonfigurowaną stronę docelową. Zmiana tego ustawienia na przykład na 10%%, spowoduje rejestrowanie co 10 odwiedzającego. Niskie wartości tego parametru spowodują wydłużenie czasu, w którym aplikacja zbierze wymaganą liczbę próbek.", + "HeatmapUpdated": "Mapa aktywności została pomyślnie zaktualizowana.", + "HeatmapUsageBenefits": "Mapy aktywności pozwalają Ci zapisywać kliknięcia, ruchy wskaźnika i przewijania, wykonywane przez Twoich odwiedzających wybraną stronę. To pozwoli Ci zrozumieć, gdzie użytkownikom wydaje się, że jest klikalny element, czy na stronie znajdują się rzadko oglądane obszary, lub z którymi użytkownicy rzadko wchodzą w interakcję. Dzięki nim zrozumiesz, jakich informacji poszukują odwiedzający, jaką część strony widzą podczas odwiedzin i wiele więcej.", + "HeatmapWidth": "Szerokość mapy aktywności", + "HeatmapX": "Mapa aktywności %s", + "HeatmapXRecordedSamplesSince": "%1$s próbek zostało zarejestrowane od %2$s.", + "Heatmaps": "Mapy aktywności", + "Manage": "Zarządzaj", + "ManageHeatmaps": "Zarządzaj Mapami Aktywności", + "ManageSessionRecordings": "Zarządzań Nagraniami Sesji", + "MinSessionTime": "Min. czas sesji", + "MinSessionTimeHelp": "Sesja zostanie zapisana tylko wtedy, gdy użytkownik spędzi na stronie przynajmniej określoną tu ilość czasu.", + "NHeatmaps": "%s map aktywności", + "NSessionRecordings": "%s nagranych sesji", + "NoHeatmapSamplesRecordedYet": "Nie nagrano jeszcze żadnego wyświetlenia strony dla tej mapy aktywności. Jeśli powinny tu już widnieć nagrania, możliwe jest, że skonfigurowane dla tej mapy aktywności cele nie pasują do stron Twojego serwisu. Zalecane jest również odwiedzenie zakładki \"Sprawdzenie systemu\" w \"Ustawieniach\" (tylko Super Użytkownicy), w celu sprawdzenia, czy w systemie zostało skonfigurowane automatyczne tworzenie map aktywności.", + "NoHeatmapScreenshotRecordedYet": "Do tej pory zarejestrowano %1$s próbek, lecz nie wykonano zrzutu ekranu. Jeśli dla tej mapy aktywności istnieje \"%2$s\", to udostępnienie zrzutu ekranu może zająć chwilę, gdyż użytkownik musi wpierw odwiedzić jego adres. Czas jego dostępności zależy od częstotliwości próbkowania.", + "NoHeatmapsConfiguredInfo": "Brakuje obecnie aktywnych map aktywności. W celu ich przeglądania, poproś użytkownika, który ma przynajmniej dostęp administratora, aby utworzył nową mapę aktywności.", + "NoHeatmapsFound": "Brak map aktywności", + "NoSessionRecordedYet": "Nie nagrano jeszcze żadnej sesji. Jeśli powinny tu już widnieć nagrane sesje, możliwe jest, że skonfigurowana strona wejścia nie pasuje do stron Twojego serwisu. Zalecane jest również odwiedzenie zakładki \"Sprawdzenie systemu\" w \"Ustawieniach\" (tylko Super Użytkownicy), w celu sprawdzenia, czy w systemie zostało skonfigurowane automatyczne nagrywanie sesji.", + "NoSessionRecordingsConfiguredInfo": "W tej chwili nie nagrywasz żadnej sesji. Aby nagrać nowe sesje, skontaktuj się z użytkownikiem o dostępie co najmniej administratora, aby utworzył nowe nagrania.", + "NoSessionRecordingsFound": "Brak nagranych sesji", + "NotSupportedBrowser": "Twoja przeglądarka nie jest wspierana, proszę użyj aktualniejszej wersji lub użyj innej przeglądarki.", + "OnePageview": "1 wyświetlenie strony", + "PageRule": "Reguły strony", + "PageviewXofY": "Odwiedziny %1$s z %2$s", + "PageviewsInVisit": "Wyświetlenia strony nagrane w tej sesji", + "PersonalInformationNote": "Pamiętaj, że %1$s może zapisywać dane osobiste i wrażliwe. Jeśli życzysz sobie ukryć wybrane treści na swojej stronie lub aplikacji, możesz nadać im atrybut %2$sdata-matomo-mask%3$s w źródle HTML. Kliknij, aby %4$sdowiedzieć się więcej%5$s", + "PlayRecordedSession": "Odtwórz tą nagraną sesję", + "PlayerDurationXofY": "%1$s z %2$s", + "PlayerForwardFast": "W przód o %1$s sekund (skrót %2$s)", + "PlayerPageViewNext": "Odtwórz następne wyświetlenie strony użytkownika %1$s (skrót %2$s)", + "PlayerPageViewPrevious": "Odtwórz poprzednie wyświetlenie strony użytkownika %1$s (skrót %2$s)", + "PlayerPause": "Pauza (skrót %s lub spacja)", + "PlayerPlay": "Odtwórz (skrót %s lub spacja)", + "PlayerReplay": "Ponów (skrót %s lub spacja)", + "PlayerRewindFast": "Cofnij o %1$s sekund (skrót %2$s)", + "RecordedHeatmapDocStatusActive": "Ta mapa aktywności jest aktywna. Do %1$d wyświetleń strony zostanie zapisane z częstotliwością próbkowania %2$s.", + "RecordedHeatmapDocStatusEnded": "Ta mapa aktywności została zakończona. Żadne nowe interakcje nie będą zapisywane.", + "RecordedSessions": "Nagrane sesje", + "RecordedSessionsDocStatusActive": "Ta sesja jest w trakcie nagrywania. Do %1$d sesji zostanie nagrane z częstotliwością próbkowania %2$s.", + "RecordedSessionsDocStatusEnded": "Nagranie tej sesji zakończyło się. Żadne nowe sesje nie będą zapisywane.", + "Recordings": "Nagrania", + "ReplayRecordedSession": "Odtwórz nagraną sesję", + "ReplayX": "Odtwórz %s", + "ReportRecordedSessionsDocumentation": "Lista wszystkich zarejestrowanych sesji. Możesz odtworzyć dowolną zapisaną sesję.", + "RequiresActivity": "Wymagana aktywność", + "RequiresActivityHelp": "Włączenie spowoduje zapis tylko tych sesji, w czasie których zostaną zarejestrowane wydarzenia przewinięcia i kliknięcia na stronie. To zapobiega rejestracji sesji z niską aktywnością.", + "Rule": "Reguła", + "SampleLimit": "Limit próbkowania", + "SampleRate": "Częstotliwość próbkowania", + "ScreenshotUrl": "Adres zrzutu ekranu", + "ScreenshotUrlHelp": "Domyślnie, zrzut ekranu wykonywany jest podczas odwiedzin pierwszego użytkownika na stronie docelowej. W sytuacji, gdy możliwe będzie dopasowanie adresu innej strony, możesz podać adres wybranej strony, na której ma zostać wykonany zrzut ekranu. Wizualizacja Mapy Aktywności stanie się dostępna po wizycie pierwszego odwiedzającego, którego aktywność zostanie zarejestrowana. Po zdefiniowaniu, adres zostanie dokładnie dopasowany. W celu pominięcia protokołu, rozpocznij wpis adresu od podwójnego ukośnika (//example.com). Pamiętaj, że adresu tego nie można zmienić po wykonaniu zrzutu ekranu.", + "SessionNameHelp": "Definiuje nazwę reprezentującą to nagranie sesji.", + "SessionRecording": "Nagrana sesja", + "SessionRecordingCreated": "Konfiguracja nagrania sesji została pomyślnie zapisana.", + "SessionRecordingUpdated": "Konfiguracja nagrania sesji została pomyślnie zaktualizowana.", + "SessionRecordingX": "Nagranie sesji %s", + "SessionRecordings": "Nagrania sesji", + "SessionRecordingsUsageBenefits": "Nagrania sesji pozwalają Ci zapisać wszystkie aktywności użytkowników w czasie trwania ich sesji (wizyty). Do aktywności tych należy zaliczyć kliknięcia, ruchy wskaźnika, przewijanie, zmiany wielkości okna, zmiany strony czy korzystanie z formularzy. Po zapisaniu możesz odtworzyć te interakcje, dzięki czemu uzyskasz dokładny obraz działań użytkowników w Twoim serwisie. W ten sposób zrozumiesz ich oczekiwania, możliwe problemy, wzorce użytkowania i wiele więcej.", + "SessionSampleLimit": "Liczba sesji", + "SessionSampleLimitHelp": "Określ liczbę wszystkich sesji, które chcesz nagrać.", + "SessionSampleRateHelp": "Znany jako 'transfer'. Po wybraniu wartości 100%%, funkcjonalność zarejestruje każdego odwiedzającego skonfigurowaną stronę docelową już w momencie jej wyświetlenia. Zmiana tego ustawienia na przykład na 10%%, spowoduje rejestrowanie co 10 odwiedzającego. Niskie wartości tego parametru spowodują wydłużenie czasu, w którym aplikacja zbierze wymaganą liczbę próbek.", + "StatusActive": "Aktywna", + "StatusEnded": "Zakończona", + "StopX": "Przerwij zapis nowych wydarzeń dla sesji %s.", + "TargetAttributePath": "Ścieżka", + "TargetAttributeUrl": "Adres", + "TargetAttributeUrlParameter": "Parametr adresu", + "TargetAttributeUrlParameterExample": "nazwaParamertuAdresu", + "TargetPage": "Strona docelowa", + "TargetPageTestErrorInvalidUrl": "Wprowadź adres z protokołem.", + "TargetPageTestLabel": "Wprowadź pełny adres URL włącznie z protokołem, aby sprawdzić, czy aktywności będą zapisywane dla tego adresu:", + "TargetPageTestTitle": "Walidator adresu", + "TargetPageTestUrlMatches": "Aktywność będzie rejestrowana dla tego adresu", + "TargetPageTestUrlNotMatches": "Aktywność nie będzie rejestrowana dla tego adresu", + "TargetPages": "Strony wejścia", + "TargetTypeContains": "zawiera", + "TargetTypeEqualsExactly": "jest dokładnie", + "TargetTypeEqualsExactlyInfo": "Wartość musi być dokładnie dopasowana i zawierać adres z protokołem, ciąg zapytania i fragment.", + "TargetTypeEqualsSimple": "porównanie proste", + "TargetTypeEqualsSimpleInfo": "Adres zostanie dopasowany do dowolnego protokołu (n.p. HTTP i HTTPS) z lub bez subdomeny \"www.\". Ukośnik, ciąg zapytania jak i hash zostaną pominięte podczas dopasowywania adresu do wzorca.", + "TargetTypeExists": "występuje", + "TargetTypeIsAny": "dowolny", + "TargetTypeIsNot": "nie%s", + "TargetTypeRegExp": "pasuje do wyrażenia regularnego", + "TargetTypeRegExpInfo": "Dowolne wyrażenie regularne, na przykład \"^(.*)test(.*)$\".", + "TargetTypeStartsWith": "zaczyna się od", + "TimeOnSite": "Czas w serwisie", + "UpdatingData": "Aktualizacja danych…", + "UrlParameterValueToMatchPlaceholder": "Wartość dopasowywana dla parametru URL", + "UrlXDoesNotLookLikeUrl": "%s nie wygląda jak adres URL. Upewnij się, że adres zawiera protokół np. http:// lub zaczyna się od //", + "ViewReport": "Raport wyświetleń", + "ViewportResolution": "Rozdzielczość przeglądarki (szerokość x wysokość)", + "XSamples": "%s próbek", + "disable": "wyłącz", + "enable": "włącz" + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/pt-br.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/pt-br.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/pt-br.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/pt.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/pt.json new file mode 100644 index 0000000..0bd96f3 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/pt.json @@ -0,0 +1,185 @@ +{ + "HeatmapSessionRecording": { + "ActivityClick": "Clique", + "ActivityFormChange": "Alteração no formulário", + "ActivityFormText": "Alteração de texto no formulário", + "ActivityFormValue": "Alteração de valor no formulário", + "ActivityInitialDom": "Página inicial", + "ActivityMove": "Movimento", + "ActivityResize": "Redimensionamento", + "ActivityScroll": "Deslocamento", + "ActivityScrollElement": "Deslocamento de elemento", + "AdvancedOptions": "Opções avançadas", + "AutoPlayNextPageview": "Clique para %1$s reproduzir de forma automática a próxima visualização da página (atalho %2$s)", + "AvgAboveFoldDescription": "Em média os visitantes vêm o conteúdo acima desta linha sem deslocamento", + "AvgAboveFoldTitle": "%spx de média sob o limite", + "BreakpointGeneralHelp": "O tipo de dispositivo é detetado automaticamente quando um visitante visita o seu site. No entanto, por vezes, pode não ser possível detetar o tipo de dispositivo. Se o seu site é responsivo e o seu interface se modifica em determinado ponto, nós iremos colocar utilizadores de computadores com uma resolução pequena neste tipo de dispositivos para mapas térmicos mais fiáveis. Qualquer largura inferior a este valor será colocada neste tipo de dispositivo. Defina zero se o seu site não é responsivo.", + "BreakpointGeneralHelpManage": "Um utilizador com acesso de super-utilizador pode alterar os valores predefinidos para estes pontos de quebra em Administração => Definições gerais.", + "BreakpointX": "Ponto de quebra %s", + "CaptureKeystrokes": "Capturar teclas pressionadas", + "CaptureKeystrokesHelp": "Se ativo, qualquer texto que seja introduzido em campos de texto de formulários será gravado. Enquanto o texto é gravado, qualquer carater que um utilizador introduza é substituído por um asterisco ('*'). Pode permitir que determinados campos sejam gravados em texto-simples, especificando um atributo 'data-matomo-unmask' num campo do formulário. No entanto, campos de palavras-passe e muitos outros campos que podem potencialmente conter informações pessoais (endereços, e-mail, informações do cartão de crédito, nome de utilizador, número de telefone, ...) serão sempre mascarados automaticamente se for detetado por nós como tal (%1$ssaber mais%2$s). Por favor, note que quando ativa esta funcionalidade, pode guardar informações pessoais que podem afetar o RGPD.", + "ChangeReplaySpeed": "Alterar velocidade de reprodução (atalho %s)", + "ClickToSkipPauses": "Clique para %1$s saltar pausas (atalho %2$s)", + "ColumnActionsDocumentation": "Reproduzir a sessão gravada ou eliminar a gravação de forma permanente.", + "ColumnBrowserDocumentation": "O navegador que o visitante estava a utilizar quando esta sessão foi gravada.", + "ColumnDeviceDocumentation": "O dispositivo que o visitante estava a utilizar quando esta sessão foi gravada.", + "ColumnLabelRecordedSessions": "Endereço de entrada → Endereço de saída", + "ColumnLocationDocumentation": "A localização do visitante quando esta sessão foi gravada.", + "ColumnOperatingSystem": "Sistema operativo", + "ColumnOperatingSystemDocumentation": "O sistema operativo que o visitante estava a utilizar quando esta sessão foi guardada.", + "ColumnPageviewsDocumentation": "O número de visualizações de páginas que foram guardadas durante esta sessão.", + "ColumnResolutionDocumentation": "O número de pixeis da área de visualização vísivel na janela do navegador quando o utilizador abriu esta página.", + "ColumnTime": "Hora", + "ColumnTimeDocumentation": "A hora que a gravação começou no fuso horário do site.", + "ColumnTimeOnPageDocumentation": "Quanto tempo o visitante despendeu nesta página enquanto a sessão foi gravada", + "ColumnTimeOnSiteDocumentation": "Quanto tempo o visitante despendeu no total enquanto a sessão foi gravada", + "ConfigsInternetDisabled": "Não conseguimos confirmar se '%s' é acessível porque a ligação à Internet está desativada neste Matomo.", + "ConfigsPhpErrorResult": "Como resultado, o acompanhamento de mapas térmicos e gravações de sessões pode não funcionar. Pode necessitar de alterar a configuração do seu servidor web para permitir o acesso a este ficheiro via Internet ou Intranet.", + "ConfigsPhpManualCheck": "Por favor, abra o endereço manualmente num navegador para verificar se a resposta contem 'Piwik.HeatmapSessionRecording'. Caso contrário, pode precisar de modificar a configuração do seu servidor dado que este ficheiro precisa de ser acessível através de um navegador a partir da Internet ou Intranet.", + "ConfigsPhpNotAccessible": "O endereço '%s' aparenta não ser acessível a partir da Internet ou Intranet.", + "ConfigsPhpSelfSignedError": "A solicitação de '%s' resultou num erro SSL. Talvez esteja a utilizar um certificado auto-assinado?", + "ConfigsPhpSuccess": "'%s' está acessível", + "ConfigsPhpUnknown": "Não conseguimos confirmar se '%s' é acessível pela Internet ou Intranet.", + "ConfigsPhpUnknownError": "A solicitação de '%1$s' resultou num erro: %2$s.", + "CreateNewHeatmap": "Criar novo mapa térmico", + "CreateNewSessionRecording": "Criar nova gravação de sessão", + "CreationDate": "Data de criação", + "DeleteHeatmapConfirm": "Tem a certeza que pretende eliminar este mapa térmico? Os relatórios dos mapas térmicos gerados anteriormente deixarão de estar disponíveis a partir do interface dos relatórios.", + "DeleteHeatmapScreenshotConfirm": "Tem a certeza que pretende eliminar esta captura de ecrã? Pode demorar algum tempo até a captura de ecrã ser novamente feita.", + "DeleteRecordedPageview": "Eliminar esta visualização de página (não é solicitada confirmação)", + "DeleteRecordedSession": "Eliminar esta sessão gravada (não é solicitada confirmação)", + "DeleteScreenshot": "Eliminar captura de ecrã", + "DeleteSessionRecordingConfirm": "Tem a certeza que pretende eliminar esta gravação da sessão? As gravações geradas anteriormente deixarão de estar disponíveis a partir do interface dos relatórios.", + "DeleteX": "Eliminar %s", + "EditHeatmapX": "Editar mapa térmico %s", + "EditSessionRecordingX": "Editar gravação de sessão %s", + "EditX": "Editar %s", + "EndHeatmapConfirm": "Tem a certeza que pretende parar a captura de quaisquer atividades para este mapa térmico?", + "EndSessionRecordingConfirm": "Tem a certeza que pretende parar a gravação de quaisquer novas sessões?", + "ErrorArrayMissingKey": "Falta a chave \"%1$s\" do vetor \"%2$s\" na posição \"%3$s\".", + "ErrorArrayMissingValue": "Falta o valor para a chave \"%1$s\" do vetor \"%2$s\" na posição \"%3$s\".", + "ErrorHeatmapDoesNotExist": "O mapa térmico solicitado não existe", + "ErrorHeatmapNameDuplicate": "O nome do mapa térmico já está a ser utilizado por outro mapa térmico.", + "ErrorInnerIsNotAnArray": "Cada \"%1$s\" dentro de \"%2$s\" tem de ser um vetor.", + "ErrorInvalidRegExp": "A expressão regular \"%1$s\" não tem um formato válido.", + "ErrorNotAnArray": "\"%1$s\" tem de ser um vetor.", + "ErrorPageRuleRequired": "Tem de ser definido pelo menos uma regra de página.", + "ErrorSessionRecordingDoesNotExist": "A gravação da sessão solicitada não existe", + "ErrorXContainsWhitespace": "Não é permitido que \"%1$s\" contenha qualquer espaço em branco.", + "ErrorXNotANumber": "\"%1$s\" tem de ser um número.", + "ErrorXNotProvided": "Por favor, forneça um valor para \"%1$s\".", + "ErrorXNotWhitelisted": "O valor para \"%1$s\" não é permitido; utilize um de: %2$s.", + "ErrorXTooHigh": "\"%1$s\" é demasiado alto, o valor máximo permitido é %2$s.", + "ErrorXTooLong": "\"%1$s\" é demasiado longo; pode utilizar um máximo de %2$s carateres.", + "ErrorXTooLow": "\"%1$s\" é demasiado baixo, o valor mínimo permitido é %2$s.", + "ExcludedElements": "Elementos excluídos", + "ExcludedElementsHelp": "Opcionalmente pode definir seletores CSS para excluir determinados elementos que não devem ser visíveis na pré-visualização do mapa térmico. Por exemplo, um popup que seja apresentado quando a página-alvo seja aberta. Pode separar múltiplos seletores com uma vírgula.", + "FieldIncludedTargetsHelp": "Os alvos permitem-lhe definir em que páginas as atividades de um visitante devem ser gravadas. Pode definir uma ou mais condições. Por exemplo, pode definir que a gravação de atividades ocorre sempre que o endereço ou caminho é igual a um determinado valor ou apenas se um determinado parâmetro do endereço estiver presente no mesmo. As atividades serão gravadas apenas quando todas as condições se verificarem, e não quando apenas uma se confirmar. Todas as condições serão avaliadas sem diferenciação entre maiúsculas e minúsculas. Quando seleciona 'igualdade simples', o protocolo, parâmetros da consulta e barra invertida do endereço serão ignorados.", + "FieldIncludedTargetsHelpSessions": "Os alvos permitem-lhe definir para apenas se iniciar a gravação de uma sessão assim que um visitante atingir uma determinada página. Isto permite, por exemplo, que apenas grave sessões que passem por um processo específico. Pode definir uma ou mais condições. Por exemplo, pode definir que a gravação da sessão ocorre sempre que o endereço ou caminho é igual a um determinado valor ou apenas se um determinado parâmetro do endereço estiver presente no mesmo. As sessões serão gravadas apenas quando todas as condições se verificarem na visualização de uma página, e não quando apenas uma se confirmar. Todas as condições serão avaliadas sem diferenciação entre maiúsculas e minúsculas. Quando seleciona 'igualdade simples', o protocolo, parâmetros da consulta e barra invertida do endereço serão ignorados.", + "FieldNamePlaceholder": "por exemplo, 'Página de registo'", + "FilesystemDirectory": "diretório", + "Filter": "Filtro", + "GettingStarted": "Começar", + "Heatmap": "Mapa térmico", + "HeatmapCreated": "O mapa térmico foi criado com sucesso.", + "HeatmapNameHelp": "Define o nome sob o qual o relatório para este mapa térmico ficará disponível.", + "HeatmapSampleLimit": "Número de visualizações de páginas", + "HeatmapSampleLimitHelp": "Define a quantidade de visualizações de páginas que pretende guardar no total.", + "HeatmapSampleRateHelp": "Também conhecido como 'tráfego'. Quando selecionar 100%%, todos os visitantes que visitem a página-alvo selecionada serão gravados. Quando selecionar, por exemplo 10%%, apenas 10 em cada 100 visitantes serão gravados. Quanto menor a percentagem que selecionar, mais tempo irá demorar a atingir o limite de amostras selecionado.", + "HeatmapUpdated": "O mapa térmico foi atualizado com sucesso.", + "HeatmapUsageBenefits": "Os mapas térmicos permitem-lhe gravar todos os cliques, movimentos do rato e atividades de deslocamento dos seus visitantes numa determinada página. Isto ajuda-o a perceber onde os utilizadores pensam que existe algo clicável mas que não é, se existem partes da página que raramente são vistas ou manipuladas, o que os utilizadores estão à procura, que parte da página é visível quando os utilizadores vêm a sua página, entre outras informações.", + "HeatmapWidth": "Largura do mapa térmico", + "HeatmapX": "Mapa térmico %s", + "HeatmapXRecordedSamplesSince": "Foram guardadas %1$s amostras desde %2$s.", + "Heatmaps": "Mapas térmicos", + "Manage": "Gerir", + "ManageHeatmaps": "Gerir mapas térmicos", + "ManageSessionRecordings": "Gerir gravações de sessões", + "MinSessionTime": "Tempo mínimo de sessão", + "MinSessionTimeHelp": "Uma sessão só será guardada quando o visitante despender numa página, pelo menos, o tempo especificado.", + "NHeatmaps": "%s mapas térmicos", + "NSessionRecordings": "%s gravações de sessões", + "NoHeatmapSamplesRecordedYet": "Ainda não foram guardadas visualizações de páginas para este mapa térmico. Se já era suposto ter gravações, talvez as páginas-alvo configuradas para este mapa térmico não coincidam com qualquer página no seu site. Também é recomendável fazer a \"Verificação de sistema\" sob \"Administração\" (apenas para super-utilizadores) para ver se o seu sistema está configurado para acompanhar automaticamente mapas térmicos.", + "NoHeatmapScreenshotRecordedYet": "Já foram gravadas %1$s amostras até ao momento. No entanto, ainda não foi feita qualquer captura de ecrã até ao momento. Se existe um \"%2$s\" para este mapa térmico, pode demorar um pouco para a captura de ecrã ficar disponível dado que um utilizador tem de primeiro abrir este endereço da captura de ecrã. Dependendo da taxa de amostragem, pode demorar um pouco.", + "NoHeatmapsConfiguredInfo": "Não existem mapas térmicos ativos. Para ver mapas térmicos, por favor, solicite a um utilizador com um acesso de administrador para criar um novo mapa térmico.", + "NoHeatmapsFound": "Não foram encontrados mapas térmicos", + "NoSessionRecordedYet": "Ainda não foram guardadas sessões. Se já era suposto ter sessões guardadas, talvez a página de entrada configurada não coincida com qualquer página no seu site. Também é recomendável fazer a \"Verificação de sistema\" sob \"Administração\" (apenas para super-utilizadores) para ver se o seu sistema está configurado para gravações automáticas de sessões.", + "NoSessionRecordingsConfiguredInfo": "Não existem gravações de sessões ativas. Para gravar novas sessões, por favor, solicite a um utilizador com um acesso de administrador para criar uma nova gravação.", + "NoSessionRecordingsFound": "Não foram encontradas gravações de sessão", + "NotSupportedBrowser": "Este navegador não é suportado. Por favor, utilize uma versão mais recente ou experimente um navegador diferente.", + "OnePageview": "1 visualização de página", + "PageRule": "Regra de página", + "PageviewXofY": "Visualização de página %1$s de %2$s", + "PageviewsInVisit": "Visualizações de páginas guardadas para esta sessão", + "PersonalInformationNote": "Por favor, note que um %1$s pode gravar informações pessoais ou sensíveis. Se quiser ocultar determinado conteúdo no seu site ou aplicação, pode etiquetar este conteúdo adicionando um atributo %2$sdata-matomo-mask%3$s na marcação HTML. %4$sSaber mais%5$s", + "PlayRecordedSession": "Reproduzir esta sessão gravada", + "PlayerDurationXofY": "%1$s de %2$s", + "PlayerForwardFast": "Saltar %1$s segundos (atalho %2$s)", + "PlayerPageViewNext": "Reproduzir a visualização de página seguinte para este visitante (%1$s) (atalho %2$s)", + "PlayerPageViewPrevious": "Reproduzir a visualização de página anterior para este visitante %1$s (atalho %2$s)", + "PlayerPause": "Pausar (atalho %s ou espaço)", + "PlayerPlay": "Reproduzir (atalho %s ou espaço)", + "PlayerReplay": "Reproduzir (atalho %s ou espaço)", + "PlayerRewindFast": "Retroceder %1$s segundos (atalho %2$s)", + "RecordedHeatmapDocStatusActive": "Este mapa térmico está ativo. Serão gravadas até %1$d visualizações de páginas com uma taxa de amostragem de %2$s.", + "RecordedHeatmapDocStatusEnded": "Este mapa térmico terminou. Não serão gravadas mais atividades.", + "RecordedSessions": "Sessões gravadas", + "RecordedSessionsDocStatusActive": "Esta gravação da sessão está ativa. Serão gravadas até %1$d sessões com uma taxa de amostragem de %2$s.", + "RecordedSessionsDocStatusEnded": "Esta gravação da sessão terminou e não serão gravadas novas sessões.", + "Recordings": "Gravações", + "ReplayRecordedSession": "Reproduzir sessão gravada", + "ReplayX": "Reproduzir %s", + "ReportRecordedSessionsDocumentation": "Uma lista de todas as sessões que foram guardadas. Pode reproduzir qualquer sessão guardada.", + "RequiresActivity": "Requer atividade", + "RequiresActivityHelp": "Se ativo, apenas sessões que tenham atividades de clique ou deslocamento numa visualização de página serão guardadas. Isto impede a gravação de sessões sem muita atividade.", + "Rule": "Regra", + "SampleLimit": "Limite de amostras", + "SampleRate": "Taxa de amostragem", + "ScreenshotUrl": "Endereço da captura de ecrã", + "ScreenshotUrlHelp": "Por predefinição, é feita uma captura de ecrã quando o primeiro visitante visualiza a página-alvo. Se vários endereços de páginas diferentes coincidem com a página-alvo, pode especificar o endereço que deve ser utilizado para capturar o ecrã. A visualização do mapa térmico só estará disponível quando pelo menos um utilizador visitar este endereço cujas atividades são gravadas. Se definido, o endereço tem de coincidir em absoluto. Para ignorar o protocolo, comece o endereço com duas barras invertidas (//example.com). Por favor, note que este endereço não poderá ser alterado a partir do momento que uma captura de ecrã tenha sido feita.", + "SessionNameHelp": "Define o nome sob o qual as gravações de sessões ficarão disponíveis.", + "SessionRecording": "Gravação de sessão", + "SessionRecordingCreated": "A gravação de sessão foi criada com sucesso.", + "SessionRecordingUpdated": "A gravação de sessão foi atualizada com sucesso.", + "SessionRecordingX": "Gravação da sessão %s", + "SessionRecordings": "Gravações de sessões", + "SessionRecordingsUsageBenefits": "As gravações de sessões permitem-lhe guardar todas as atividades de um utilizador em tempo-real durante a sua sessão (visita), tais como cliques, movimentos do rato, deslocamentos, dimensionamento de janelas, alterações na página e interações em formulários. Pode depois reproduzir estas interações para ver, exatamente, como um visitante interagiu com o seu site. Desta forma pode perceber as expetativas, problemas, padrões de utilização, etc., que estes têm.", + "SessionSampleLimit": "Número de sessões", + "SessionSampleLimitHelp": "Define a quantidade de sessões que pretende guardar no total.", + "SessionSampleRateHelp": "Também conhecido como 'tráfego'. Quando selecionar 100%%, todas as sessões serão gravadas assim que atinjam a página-alvo. Quando selecionar, por exemplo 10%%, apenas 10 em cada 100 sessões serão gravadas. Quanto menor a percentagem que selecionar, mais tempo irá demorar a atingir o limite de amostras selecionado.", + "StatusActive": "Ativo", + "StatusEnded": "Terminou", + "StopX": "Parar a gravação de quaisquer novas atividades para este %s.", + "TargetAttributePath": "Caminho", + "TargetAttributeUrl": "Endereço", + "TargetAttributeUrlParameter": "Parâmetro do endereço", + "TargetAttributeUrlParameterExample": "nomeDoParametroDoEndereco", + "TargetPage": "Página-alvo", + "TargetPageTestErrorInvalidUrl": "Introduza um endereço, incluindo o protocolo.", + "TargetPageTestLabel": "Introduza um endereço completo, incluindo o protocolo, para confirmar se as atividades serão gravadas neste endereço:", + "TargetPageTestTitle": "Validador do endereço", + "TargetPageTestUrlMatches": "As atividades serão gravadas para este endereço", + "TargetPageTestUrlNotMatches": "As atividades não serão gravadas para este endereço", + "TargetPages": "Páginas de entrada", + "TargetTypeContains": "contém", + "TargetTypeEqualsExactly": "exatamente igual", + "TargetTypeEqualsExactlyInfo": "O valor deve coincidir em absoluto, incluindo o protocolo do endereço, termos de pesquisa e ancora.", + "TargetTypeEqualsSimple": "igualdade simples", + "TargetTypeEqualsSimpleInfo": "O endereço irá coincidir qualquer protocolo (por exemplo, http e https) com e sem o subdomínio \"www.\". Qualquer barra transversal no caminho, bem como os parâmetros de consulta e âncora que sejam parte do endereço serão ignorados na comparação do endereço.", + "TargetTypeExists": "existe", + "TargetTypeIsAny": "é qualquer", + "TargetTypeIsNot": "não %s", + "TargetTypeRegExp": "coincide com a expressão regular", + "TargetTypeRegExpInfo": "Qualquer expressão regular, por exemplo, \"^(.*)test(.*)$\".", + "TargetTypeStartsWith": "começa com", + "TimeOnSite": "Tempo no site", + "UpdatingData": "A atualizar dados…", + "UrlParameterValueToMatchPlaceholder": "Valor a coincidir com o nome do parâmetro do endereço", + "UrlXDoesNotLookLikeUrl": "%s não parece ser um endereço. Confirme que contém um protocolo como http:// ou que começa com //", + "ViewReport": "Ver relatório", + "ViewportResolution": "Resolução da área de visualização (largura x altura)", + "XSamples": "%s amostras", + "disable": "desativar", + "enable": "ativar" + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/ro.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/ro.json new file mode 100644 index 0000000..c6ed30e --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/ro.json @@ -0,0 +1,6 @@ +{ + "HeatmapSessionRecording": { + "Heatmap": "Hartă termică", + "SessionRecording": "Înregistrare sesiune" + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/ru.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/ru.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/ru.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/si.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/si.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/si.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/sk.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/sk.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/sk.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/sl.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/sl.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/sl.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/sq.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/sq.json new file mode 100644 index 0000000..2f65a2e --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/sq.json @@ -0,0 +1,247 @@ +{ + "HeatmapSessionRecording": { + "Action": "Veprim", + "ActivityClick": "Klikim", + "ActivityFormChange": "Ndryshim Forme", + "ActivityFormText": "Ndryshim Teksti Formulari", + "ActivityFormValue": "Ndryshim Vlere Formulari", + "ActivityInitialDom": "Faqe Fillestare", + "ActivityMove": "Lëvizje", + "ActivityPageChange": "Ndryshoje Brenda Faqes", + "ActivityResize": "Ripërmasim", + "ActivityScroll": "Rrëshqitje", + "ActivityScrollElement": "Element Rrëshqitjeje", + "AdBlockerDetected": "%1$s mund të mos jetë e dukshme, kur përdoret një bllokues reklamash. Ju lutemi, çaktivizoni bllokuesin e reklamave në këtë sajt, për të garantuar se Matomo funksionon pa ndonjë problem.", + "AdvancedOptions": "Mundësi të mëtejshme", + "AutoPlayNextPageview": "Klikoni që të %1$s vetëluhet pamja e faqes pasuese (shkurtore %2$s)", + "AvgAboveFoldDescription": "Mesatarisht, përdoruesit shohin lëndën mbi këtë vijë pa rrëshqitje", + "AvgAboveFoldTitle": "Mes. Mbi Dosje %spx", + "BreakpointGeneralHelp": "Kur një vizitor sheh sajtin tuaj, lloji i pajisjes zbulohet vetvetiu. Por ndonjëherë mund të mos jetë e mundur të zbulohet lloji i pajisjes. Nëse sajti juaj është reagues dhe skema e tij shformohet në një pikë të caktuar, për harta të përpikta veprimesh ne do ta kalojmë përdoruesin nën një qartësi më të vogël për këtë lloj pajisjeje. Çfarëdo gjerësie më e vogël se kjo vlerë, do të kalohet në këtë lloj pajisjeje. Nëse sajti juaj s’është reagues, caktojeni zero.", + "BreakpointGeneralHelpManage": "Një përdorues me të drejta Superpërdoruesi mund të ndryshojë vlerat parazgjedhje për këto pika shformimi, që nga Administrim => Rregullime të Përgjithshme.", + "BreakpointX": "Pikë shformimi %s", + "CaptureDomInlineHelp": "Si parazgjedhje, fotografimet e hartës së veprimeve bëhen automatikisht gjatë ngarkimit të faqes. Nëse faqja juaj ngarkon elementë në mënyrë dinamike (b.f., “lazy loading”) fotografimi juaj mund të ishte i paplotë. Mundeni të fotografoni dorazi një pamje të plotë, duke aktivizuar këtë mundësi dhe duke ekzekutuar në shfletuesin tuaj, kodin Javascript: %1$s %2$sShënim:%3$s Kjo mundësi rikthehet automatikisht te parazgjedhja, pas bërjes së fotografimit.", + "CaptureDomTitle": "Fotografoni Dorazi Pamje Harte Veprimesh", + "CaptureKeystrokes": "Regjistro shtypje tastesh", + "CaptureKeystrokesHelp": "Në u aktivizoftë, çfarëdo teksti që jepet në fusha formularësh, regjistrohet. Teksa teksti regjistrohet, çfarëdo shenje që përdoruesi përdor zëvendësohet me një yll ('*'). Duke u caktuar atributin “data-matomo-unmask”, mund të lejoni që fusha formulari të regjistrohen si tekst i thjeshtë. Sido qoftë, fusha për fjalëkalime dhe mjaft fusha të tjera, të cilat mundet potencialisht të përfshijnë të dhëna personale (adresë, email, të dhëna karte krediti, emër përdoruesi, numër telefoni, …) do të maskohen automatikisht, në u zbulofshin prej nesh si të tilla (%1$smësoni më tepër%2$s). Ju lutemi, kini parasysh se kur aktivizoni këtë veçori, mund të regjistroni të dhëna personale, gjë që mund të prekë GDPR-në.", + "ChangeReplaySpeed": "Ndryshoni shpejtësi luajtjeje (shkurtore %s)", + "ClickRate": "Nivel klikimi:", + "ClickToSkipPauses": "Klikoni që të %1$s anashkalohen ndalesat (shkurtore %2$s)", + "Clicks": "Klikime:", + "ColumnActionsDocumentation": "Luani sesionin e regjistruar, ose hiqeni regjistrimin përgjithmonë.", + "ColumnBrowserDocumentation": "Shfletuesi që po përdorte vizitori, kur u regjistrua ky sesion.", + "ColumnDeviceDocumentation": "Pajisja që po përdorte vizitori, kur u regjistrua ky sesion.", + "ColumnLabelRecordedSessions": "URL Hyrjeje → URL Daljeje", + "ColumnLocationDocumentation": "Vendndodhja e vizitorit, kur u regjistrua ky sesion.", + "ColumnOperatingSystem": "OS", + "ColumnOperatingSystemDocumentation": "Sistemi operativ që po përdorte vizitori, kur u regjistrua ky sesion.", + "ColumnPageviewsDocumentation": "Numri i parjeve të faqeve që janë regjistruar gjatë këtij sesioni.", + "ColumnResolutionDocumentation": "Numri i pikselave të pjesës së dukshme në dritaren e shfletuesit, kur përdoruesi hapi këtë faqe.", + "ColumnTime": "Kohë", + "ColumnTimeDocumentation": "Koha kur nisi regjistrimi, sipas zonës kohore të sajtit.", + "ColumnTimeOnPageDocumentation": "Sa kohë shpenzoi vizitori në këtë faqe, teksa regjistrohej sesioni", + "ColumnTimeOnSiteDocumentation": "Sa kohë shpenzoi vizitori gjithsej, teksa regjistrohej sesioni", + "ColumnTotalEventsDocumentation": "Numri i akteve që qenë regjistruar gjatë këtij sesioni. Për shembull: akte klikimi, lëvizjeje, rrëshqitjeje, ripërmasimi, ndryshimi faqesh dhe ndërveprimi me formular.", + "ConfigsInternetDisabled": "S’kontrolluam dot nëse “%s” është i përdorshëm, ngaqë në këtë Matomo është e çaktivizuar lidhja në Internet.", + "ConfigsPhpErrorResult": "Si rrjedhojë, ndjekja e Hartave të Veprimeve dhe Regjistrimet e Sesionit mund të mos funksionojnë. Mund t’ju duhet të ndryshoni formësimet e shërbyesit tuaj, në mënyrë që të lejoni përdorimin e kësaj kartele që nga Interneti apo Intraneti.", + "ConfigsPhpManualCheck": "Ju lutemi, hapeni URL-në dorazi në një shfletues për të parë nëse përgjigja përmban “Piwik.HeatmapSessionRecording”. Nëse jo, mund t’ju duhet të ndryshoni formësimin e shërbyesit tuaj, ngaqë kjo kartelë lyp të jetë e përdorshme përmes një shfletuesi që nga Interneti ose Intraneti.", + "ConfigsPhpNotAccessible": "URL-ja “%s” duket se nuk është e kapshme prej Interneti apo Intraneti.", + "ConfigsPhpSelfSignedError": "Kërkesa për “%s” solli një gabim SSL. Mos vallë përdorni një dëshmi të vetënënshkruar?", + "ConfigsPhpSuccess": "“%s” është i kapshëm", + "ConfigsPhpUnknown": "S’kontrolluam dot nëse “%s” është i përdorshëm në Internet apo Intranet.", + "ConfigsPhpUnknownError": "Kërkimi i “%1$s” solli një gabim: %2$s.", + "Country": "Vend", + "CreateNewHeatmap": "Krijoni hartë të re veprimesh", + "CreateNewSessionRecording": "Krijoni regjistrim të ri sesioni", + "CreationDate": "Datë Krijimi", + "DeleteHeatmapConfirm": "Jeni i sigurt se doni të fshihet kjo hartë veprimesh? Raporte për harta veprimesh të prodhuara më herët s’do të jenë më të passhëm që nga UI e raporteve.", + "DeleteHeatmapScreenshotConfirm": "Jeni i sigurt se doni të fshihet kjo foto ekrani? Ribërja e fotos së ekranit mund të zgjasë ca.", + "DeleteRecordedPageview": "Fshije këtë parje të regjistruar faqeje (pa kërkuar ripohim)", + "DeleteRecordedSession": "Fshije këtë sesion të regjistruar (pa kërkuar ripohim)", + "DeleteScreenshot": "Fshije Foton e Ekranit", + "DeleteSessionRecordingConfirm": "Jeni i sigurt se doni të fshihet ky regjistrim sesioni? S’do të jenë më të passhëm që nga UI e raporteve.", + "DeleteX": "Fshije %s", + "DeviceType": "Lloj Pajisjeje", + "DisableHeatmapRecordingDescription": "Duke çaktivizuar këtë veçori, s’mund të formësohet më, apo regjistrohet ndonjë hartë veprimesh.", + "DisableHeatmapRecordingInlineHelp": "%1$sShënim: Çaktivizimi i kësaj veçorie do të fshijë krejt hartat e veprimeve, përfshi ato të ndjekura më parë. S’do të ketë ndryshime për regjistrim sesionesh, nëse aktivizoni, ose çaktivizoni këtë veçori.%2$s", + "DisableHeatmapRecordingTitle": "Çaktivizo Harta Veprimesh", + "DisableSessionRecordingDescription": "Duke çaktivizuar këtë veçori, s’mund të formësohen, apo ndiqen më regjistrime sesionesh.", + "DisableSessionRecordingInlineHelp": "%1$sShënim: Çaktivizimi i kësaj veçorie do të fshijë krejt regjistrimet e sesioneve, përfshi regjistrime sesionesh ndjekur më parë. S’do të ketë ndryshime për harta veprimesh, nëse aktivizoni, ose çaktivizoni këtë veçori.%2$s", + "DisableSessionRecordingTitle": "Çaktivizo Regjistrim Sesionesh", + "EditHeatmapX": "Përpunoni Hartë Veprimesh %s", + "EditSessionRecordingX": "Përpunoni Regjistrim Sesioni %s", + "EditX": "Përpunoni %s", + "EnableIncludeCountriesDescription": "Si parazgjedhje, regjistrohen krejt vizitat. Aktivizojeni këtë pjesë, kur doni të ndiqni vizita nga vende të caktuara.", + "EnableIncludeCountriesTitle": "Ndiq vetëm vizitorë prej vendesh të caktuara", + "EndHeatmapConfirm": "Jeni i sigurt se doni të ndalet regjistrimi i çfarëdo veprimtarish për këtë hartë veprimesh?", + "EndSessionRecordingConfirm": "Jeni i sigurt se doni të ndalet regjistrimi i çfarëdo sesionesh të reja?", + "ErrorArrayMissingKey": "Mungon kyç matrice “%1$s” te “%2$s” në pozicionin “%3$s”.", + "ErrorArrayMissingValue": "Mungon vlerë për kyç matrice “%1$s” te “%2$s” në pozicionin “%3$s”.", + "ErrorHeatmapDoesNotExist": "Harta e kërkuar e veprimeve s’ekziston", + "ErrorHeatmapNameDuplicate": "Emri i hartës së veprimeve është tashmë në përdorim nga një tjetër hartë veprimesh.", + "ErrorHeatmapRecordingDisabled": "Veçoria e regjistrimit për harta veprimesh është e çaktivizuar. Për të përdorur këtë veçori, lypset të aktivizohet nga një superpërdorues i Matomo-s, te rregullimet e përgjithshme.", + "ErrorInnerIsNotAnArray": "Çdo “%1$s” brenda “%2$s” duhet të jetë një matricë.", + "ErrorInvalidRegExp": "Shprehja e rregullt “%1$s” s’ka format të vlefshëm.", + "ErrorNotAnArray": "“%1$s” duhet të jetë një matricë.", + "ErrorPageRuleRequired": "Duhet caktuar të paktën një rregull faqeje.", + "ErrorSessionRecordingDisabled": "Veçoria e regjistrimit të sesionit është e çaktivizuar. Për të përdorur këtë veçori, lypset të aktivizohet nga një superpërdorues i Matomo-s, te rregullimet e përgjithshme.", + "ErrorSessionRecordingDoesNotExist": "Regjistrimi i sesioneve i kërkuar s’ekziston", + "ErrorXContainsWhitespace": "“%1$s” s’lejohet të përmbajë hapësirë të zbrazët.", + "ErrorXNotANumber": "“%1$s” duhet të jetë një numër.", + "ErrorXNotProvided": "Ju lutemi, jepni një vlerë për “%1$s”.", + "ErrorXNotWhitelisted": "Vlera për “%1$s” s’lejohet, përdorni një nga: %2$s.", + "ErrorXTooHigh": "“%1$s” është shumë i lartë, vlera maksimum e lejuar është %2$s.", + "ErrorXTooLong": "“%1$s” është shumë i gjatë, lejohen maksimumi %2$s shenja.", + "ErrorXTooLow": "“%1$s” është shumë i ulët, vlera minimum e lejuar është %2$s.", + "ExcludedElements": "Elementë të Përjashtuar", + "ExcludedElementsHelp": "Nëse doni, mund të përkufizoni përzgjedhës CSS për të përjashtuar disa elementë të cilët s’duhet të jenë të dukshëm gjatë paraparjes së hartës së veprimeve. Për shembull, një flluskë që shfaqet kur hapet faqja e synuar. Përzgjedhës të shumtë mund t’i ndani me presje.", + "FieldIncludedTargetsHelp": "Objektivat ju lejojnë të përkufizoni se në cilat faqe duhet regjistruar veprimtaria e përdoruesit. Mund të përkufizoni një ose më shumë kushte. Për shembull, mund të përcaktoni të regjistrohet veprimtari kurdo që URL-ja ose shtegu është baras me një vlerë të dhënë dhe vetëm nëse në të është i pranishëm një parametër URL i dhënë. Veprimtaritë do të regjistrohen vetëm kur plotësohen të tëra kushtet, jo kur plotësohet vetëm një prej tyre. Krejt kushtet do peshohen pa marrë parasysh shkrimin me të mëdha apo me të vogla. Kur përzgjidhni “është e barabartë thjesht me”, protokolli URL, parametrat e kërkimit dhe një pjerrake paraprijëse do të shpërfillen.", + "FieldIncludedTargetsHelpSessions": "Objektivat ju lejojnë të formësoni nisjen e regjistrimit të një sesioni vetëm sapo një vizitor të ketë mbërritur në një faqe të caktuar. Kjo ju lejon, për shembull, të regjistroni vetëm sesione që kalojnë nëpër procesin e përfundimit të blerjes. Mund të përcaktoni një ose më shumë kushte. Për shembull, mund të përcaktoni të regjistroni një sesion, vetëm kur URL-ja ose shtegu është baras me një vlerë të dhënë dhe vetëm nëse te URL-ja është i pranishëm një parametër URL i dhënë. Sesionet do të regjistrohen vetëm kur gjatë parjes së një faqeje plotësohen të tëra kushtet, jo kur plotësohet vetëm një prej tyre. Krejt kushtet do peshohen pa marrë parasysh shkrimin me të mëdha apo me të vogla. Kur përzgjidhni “është e barabartë thjesht me”, protokolli URL, parametrat e kërkimit dhe një pjerrake paraprijëse do të shpërfillen.", + "FieldNamePlaceholder": "p.sh., “Faqe Regjistrimi”", + "FilesystemDirectory": "drejtori", + "Filter": "Filtroni", + "GettingStarted": "Si T’ia Fillohet", + "Heatmap": "Hartë veprimesh", + "HeatmapAddedActivity": "u shtua hartë veprimesh “%1$s” për sajtin “%2$s”", + "HeatmapCopied": "Harta e veprimeve u kopjua me sukses.", + "HeatmapCreated": "Harta e veprimeve u krijuar me sukses.", + "HeatmapDeletedActivity": "u fshi hartë veprimesh “%1$s” për sajtin “%2$s”", + "HeatmapDuplicationError": "Proces i paplotësuar. Harta e veprimeve s’u kopjua dot te krejt sajtet e përzgjedhur. Sajte me dështim: %1$s. Sajte me sukses: %2$s.", + "HeatmapDuplicationSiteTypeError": "Veçoria e kopjimit nuk mbulohet për sajte: %1$s.", + "HeatmapEndedActivity": "u përfundua harta e veprimeve “%1$s” për sajtin “%2$s”", + "HeatmapInfoTrackVisitsFromCountries": "Harta e veprimeve është formësuar të ndjekë vetëm vizita nga %1$s.", + "HeatmapNameHelp": "Përcakton emrin nën të cilin do të jetë i gatshëm raporti për këtë hartë veprimesh.", + "HeatmapPausedActivity": "u ndal harta e veprimeve “%1$s” për sajtin “%2$s”", + "HeatmapResumedActivity": "u kthye në punë harta e veprimeve “%1$s” për sajtin “%2$s”", + "HeatmapSampleLimit": "Numër parjesh faqesh", + "HeatmapSampleLimitHelp": "Përcakton sasinë e parjeve të faqes që doni të regjistrohen gjithsej.", + "HeatmapSampleRateHelp": "E njohur edhe si “trafik”. Kur përzgjidhni 100%%, do të regjistrohen krejt vizitorët që vizitojnë faqet e synuar të përzgjedhur. Kur përzgjidhni, për shembull 10%%, do të regjistrohet vetëm çdo vizitor i dhjetë. Sa më e vogël përqindja që përzgjidhni, aq më shumë kohë do të duhet për të mbërritur në shpejtësinë e përzgjedhur të kampionizimit.", + "HeatmapScreenshotDeletedActivity": "u fshi foto ekrani harte veprimesh “%1$s” për sajtin “%2$s”", + "HeatmapTroubleshoot": "Keni probleme? %1$sMësoni më tepër%2$s.", + "HeatmapUpdated": "Harta e veprimeve u përditësua me sukses.", + "HeatmapUpdatedActivity": "u përditësua harta e veprimeve “%1$s” për sajtin “%2$s”", + "HeatmapUsageBenefits": "Hartat e veprimeve ju lejojnë të regjistroni krejt klikimet, lëvizjet e kursorit dhe akte rrëshqitjesh në faqe të vizitorëve tuaj në një faqe të dhënë. Kjo ju ndihmon të gjeni se ku mendojnë përdoruesit se diçka është e klikueshme, por në fakt s’është, nëse pjesë të faqes thjesht po shihen apo ka ndërveprim me to, për çfarë po shohin vizitorët tuaj, ç’përqindje e faqes është e dukshme kur vizitorët shohin një faqe tuajën, etj.", + "HeatmapWidth": "Gjerësi harte veprimesh", + "HeatmapX": "Hartë veprimesh %s", + "HeatmapXRecordedSamplesSince": "Janë regjistruar %1$s kampione që prej %2$s.", + "Heatmaps": "Harta veprimesh", + "Manage": "Administroni", + "ManageHeatmapSubcategoryHelp": "Hartat e veprimeve ju lejojnë të regjistroni krejt klikimet, lëvizjet e kursorit dhe akte rrëshqitjesh në faqe të vizitorëve tuaj për një faqe specifike. Kjo ndarje ju jep mundësinë të krijoni dhe administroni ndjekje Hartash Veprimesh për sajtin tuaj.", + "ManageHeatmaps": "Administroni Harta Veprimesh", + "ManageSessionRecordingSubcategoryHelp": "Regjistrimet e sesioneve ju lejojnë të regjistroni krejt aktet e një vizitori të njëmendtë gjatë sesionit (vizitës) së tij, fjala vjen, klikime, lëvizje të kursorit, rrëshqitje në faqe, ripërmasime dritareje, ndryshime faqeje dhe ndërveprime me formularë. Kjo ndarje ju jep mundësinë të krijoni dhe administroni Regjistrime Sesionesh për sajtin tuaj.", + "ManageSessionRecordings": "Administroni Regjistrime Sesionesh", + "MatomoJSNotWritableErrorMessage": "HeatmapSessionRecording: %1$s s’janë në gjendje të ndjekin vizita, ngaqë kartela matomo.js është vetëm-për-lexim. Se si ta bëni kartelën të shkrueshme, ju lutemi, shihni %2$sdokumentimin%3$s.", + "MinSessionTime": "Kohë Minimum Sesioni", + "MinSessionTimeHelp": "Një sesion do të regjistrohet vetëm kur një përdorues ka shpenzuar në faqe të paktën kohën e përcaktuar.", + "MoveRate": "Nivel lëvizjeje:", + "Moves": "Lëvizje:", + "NHeatmaps": "%s harta veprimesh", + "NSessionRecordings": "Regjistrime sesioni %s", + "NoHeatmapSamplesRecordedYet": "S’është regjistruar ende ndonjë parje faqeje për këtë hartë veprimesh. Nëse duhej të kishte sesione të regjistruar tashmë deri këtu, ndoshta faqet e synuara për këtë hartë veprimesh nuk kanë përputhje me ndonjë faqe në sajtin tuaj. Këshillohet gjithashtu të kontrolloni “Kontroll Sistemi” nën “Administrim” (vetëm Superpërdoruesit), për të parë nëse sistemi juaj është formësuar të ndjekë vetvetiu Harta veprimesh.", + "NoHeatmapSamplesRecordedYetWithoutSystemConfiguration": "Për këtë hartë veprimesh, s’është regjistruar parje faqesh. Nëse pritej që të kishte të regjistruara deri tani, ndoshta objektivat faqe të formësuar për këtë hartë veprimesh nuk kanë përputhje me ndonjë faqe në sajtin tuaj.", + "NoHeatmapScreenshotRecordedYet": "Deri këtu janë regjistruar %1$s kampione. Por ende nuk është bërë ndonjë foto ekrani. Nëse ka një “%2$s” për këtë hartë veprimesh, mund të duhet ca kohë që të ketë një foto ekrani, ngaqë përdoruesi së pari duhet ta hapë URL-në e kësaj fotoje ekrani. Në varësi të shkallës së kampionizimit, kjo mund të dojë ca kohë.", + "NoHeatmapsConfiguredInfo": "S’ka harta aktive veprimesh. Që të shihni harta veprimesh, ju lutemi, kërkojini një përdoruesi me të paktën leje përgjegjësi të krijojë një hartë të re veprimesh.", + "NoHeatmapsFound": "S’u gjetën harta veprimesh", + "NoSessionRecordedYet": "S’është regjistruar ende ndonjë sesion. Nëse duhej të kishte sesione të regjistruar tashmë deri këtu, ndoshta faqja e zërit të formësuar nuk ka përputhje me ndonjë faqe në sajtin tuaj. Këshillohet gjithashtu të kontrolloni “Kontroll Sistemi” nën “Administrim” (vetëm Superpërdoruesit), për të parë nëse sistemi juaj është formësuar të regjistrojë vetvetiu sesione.", + "NoSessionRecordedYetWithoutSystemConfiguration": "S’është regjistruar ende ndonjë sesion. Nëse pritej që të kishte sesione të regjistruar deri tani, ndoshta faqja e formësuar si hyrje s’përputhet me ndonjë faqe në sajtin tuaj.", + "NoSessionRecordingsConfiguredInfo": "S’ka regjistrime aktive sesioni. Që të regjistroni sesione të reja, ju lutemi, kërkojini një përdoruesi me, të paktën, leje përgjegjësi të krijojë një regjistrim të ri.", + "NoSessionRecordingsFound": "S’u gjetën regjistrime sesioni", + "None": "Asnjë", + "NotSupportedBrowser": "Ky shfletues nuk mbulohet, ju lutemi përdorni një version të mëvonshëm, ose provoni me një tjetër shfletues.", + "OnePageview": "1 parje faqeje", + "PageRule": "Rregull Faqeje", + "PageviewXofY": "Parje faqesh %1$s nga %2$s", + "PageviewsInVisit": "Parje të regjistruara faqesh në këtë sesion", + "PauseReason": "%1$s u ndal, për shkak të plotësimit të kuotave tuaja, për hollësi të mëtejshme, ju lutemi, lidhuni me asistencën.", + "PeriodDisabledErrorMessage": "Periudha “%1$s” është e çaktivizuar, por e domosdoshme që kjo veçori të funksionojë. Përzgjidhni një periudhë tjetër, ose kërkojini një përgjegjësi të sistemit të ndryshojë kartelën “config/config.ini.php” të formësimit të Matomo-s, për të lejuar periudhën “%1$s” te rregullimi “enabled_periods_API”.", + "PersonalInformationNote": "Ju lutemi, kini parasysh se një %1$s mund të regjistrojë të dhëna personale ose të rezervuara. Nëse dëshironi të fshini një lëndë të caktuar në sajtin apo aplikacionin tuaj, mund ta etiketoni këtë lëndë duke shtuar një atribut %2$sdata-matomo-mask%3$s te pjesa HTML. %4$sMësoni më tepër%5$s", + "PlayRecordedSession": "Luaj këtë sesion të regjistruar", + "PlayerDurationXofY": "%1$s nga %2$s", + "PlayerForwardFast": "Kapërce %1$s sekonda (shkurtore %2$s)", + "PlayerPageViewNext": "Luaj parjen pasuese të faqes nga ky vizitor (%1$s) (shkurtore %2$s)", + "PlayerPageViewPrevious": "Luaj parjen e mëparshme të faqes nga ky vizitor %1$s (shkurtore %2$s)", + "PlayerPause": "Ndalesë (shkurtore %s ose hapësirë)", + "PlayerPlay": "Luaje (shkurtore %s ose hapësirë)", + "PlayerReplay": "Riluaje (shkurtore %s ose hapësirë)", + "PlayerRewindFast": "Prapaktheje %1$s sekonda (shkurtore %2$s)", + "PolicyHeatmapRequirementNote": "Regjistrimi i hartave të veprimeve te shtojca HeatmapSessionRecording duhet çaktivizuar", + "PolicyHeatmapTitle": "Harta veprimesh - Çaktivizoni Regjistrim Hartash Veprimesh", + "PolicySessionRequirementNote": "Regjistrimi i sesioneve te shtojca HeatmapSessionRecording duhet çaktivizuar", + "PolicySessionTitle": "Harta veprimesh - Çaktivizoni Regjistrim Sesionesh", + "QuotaReachedForX": "Keni mbërritur në kufirin e kuotës tuaj. S’arrihet të kopjohet %1$s, për shkak tejkalimi përdorimi. Shihni mundësinë e fshirjes së %2$s të papërdorura, ose më pak kritike, që të administroni kuotën tuaj.", + "RecordedHeatmapDocStatusActive": "Kjo hartë veprimesh është aktive. Do të regjistrohen deri në %1$d parje faqesh me një shpejtësi kampionizimi prej %2$s.", + "RecordedHeatmapDocStatusEnded": "Kjo hartë veprimesh ka përfunduar. S’do të regjistrohet veprimtari e re.", + "RecordedPageviewDeletedActivity": "u fshi një parje e regjistruar faqeje për regjistrimin e sesionit “%1$s” për sajtin “%2$s”", + "RecordedSessionDeletedActivity": "u fshi një sesion i regjistruar për regjistrimin e sesionit “%1$s” për sajtin “%2$s”", + "RecordedSessions": "Sesione të Regjistruar", + "RecordedSessionsDocStatusActive": "Ky regjistrim sesion është aktiv. Do të regjistrohen deri në %1$d sesione me një shpejtësi kampionizimi prej %2$s.", + "RecordedSessionsDocStatusEnded": "Ky regjistrim sesioni ka përfunduar dhe s’do të regjistrohen sesione të reja.", + "Recordings": "Regjistrime", + "ReplayRecordedSession": "Riluaj sesion të regjistruar", + "ReplayX": "Riluaje %s", + "ReportRecordedSessionsDocumentation": "Listë e krejt sesioneve që janë regjistruar. Mund të riluani cilindo sesion të regjistruar.", + "RequiresActivity": "Lyp veprimtari", + "RequiresActivityHelp": "Në u aktivizoftë, do të regjistrohen vetëm sesione që përmbajnë rrëshqitje dhe akte klikimi në një parje faqeje. Kjo pengon regjistrim sesionesh me jo shumë veprimtari.", + "Rule": "Rregull", + "SampleLimit": "Kufi Kampionizimi", + "SampleRate": "Shpejtësi Kampionizimi", + "ScreenshotUrl": "URL Fotoje Ekrani", + "ScreenshotUrlHelp": "Si parazgjedhje, një foto ekrani bëhet kur vizitori i parë sheh faqen e synuar. Nëse me faqen tuaj të synuar kanë përputhje URL të ndryshme faqesh, mund të përcaktoni një URL të dhënë që do të duhej përdorur për të bërë foton e ekrani. Vizualizimi i hartës së veprimeve do të jetë i mundshëm sapo të paktën një vizitor të ketë vizituar këtë URL, veprimtaritë e të cilit regjistrohen. Në u përcaktoftë, përputhja me URL-në duhet të jetë e saktë. Për të shpërfillur protokollin, fillojeni URL-në me dy pjerrake (//example.com). Ju lutemi, kini parasysh se, pas bërjes së fotos së ekranit, kjo URL s’mund të ndryshohet.", + "SessionNameHelp": "Përcakton emrin nën të cilin do të jetë i gatshëm regjistrimi i sesionit.", + "SessionRecording": "Regjistrim Sesioni", + "SessionRecordingAddedActivity": "u shtua një regjistrim sesioni “%1$s” për sajtin “%2$s”", + "SessionRecordingCreated": "Regjistrimi i sesionit u krijua me sukses.", + "SessionRecordingDeletedActivity": "u fshi regjistrimi i sesionit “%1$s” për sajtin “%2$s”", + "SessionRecordingEndedActivity": "mbaroi regjistrimi i sesionit “%1$s” për sajtin “%2$s”", + "SessionRecordingInfoTrackVisitsFromCountries": "Regjistrimi i sesioneve është formësuar të ndjekë vetëm vizita nga %1$s.", + "SessionRecordingPausedActivity": "u ndal regjistrim sesioni “%1$s” për sajtin “%2$s”", + "SessionRecordingResumedActivity": "u kthye në punë regjistrimi i sesionit “%1$s” për sajtin “%2$s”", + "SessionRecordingUpdated": "Regjistrimi i sesionit u përditësua me sukses.", + "SessionRecordingUpdatedActivity": "u përditësua regjistrimi i sesionit “%1$s” për sajtin “%2$s”", + "SessionRecordingX": "Regjistrim Sesioni %s", + "SessionRecordings": "Regjistrime Sesionesh", + "SessionRecordingsUsageBenefits": "Regjistrimet e sesioneve ju lejojnë të regjistroni krejt aktet e një vizitori të njëmendtë gjatë sesionit (vizitës) së tij, të tilla si klikime, lëvizje të kursorit, rrëshqitje në faqe, ripërmasime dritareje, ndryshime faqeje dhe ndërveprime me formularë. Mandej mundeni t’i rikryeni këto ndërveprime për të parë saktësisht se si ndërvepron me sajtin tuaj një vizitor. Në këtë mënyrë kuptoni se ç’prisnin, probleme që mund të kenë, rregullsi përdorimi, etj.", + "SessionSampleLimit": "Numër sesionesh", + "SessionSampleLimitHelp": "Përcakton sasinë e sesioneve që doni të regjistrohen gjithsej.", + "SessionSampleRateHelp": "E njohur edhe si “trafik”. Kur përzgjidhni 100%%, do të regjistrohen krejt sesionet, sapo të jetë mbërritur te faqja e synuar. Kur përzgjidhni, për shembull 10%%, do të regjistrohet vetëm çdo sesion i dhjetë. Sa më e vogël përqindja që përzgjidhni, aq më shumë kohë do të duhet për të mbërritur në shpejtësinë e përzgjedhur të kampionizimit.", + "SourceHeatmapLookupError": "Pati një gabim të papritur teksa kërkohej për hartë burim veprimesh. Ju lutemi, riprovoni. Nëse problemi vazhdon, lidhuni me përgjegjësin tuaj, ose asistencën.", + "StatusActive": "Aktiv", + "StatusEnded": "I përfunduar", + "StatusPaused": "Ndalur", + "StopX": "Resht së regjistruari çfarëdo veprimtarish të reja për këtë %s.", + "TargetAttributePath": "Shteg", + "TargetAttributeUrl": "URL", + "TargetAttributeUrlParameter": "Parametër URL-je", + "TargetAttributeUrlParameterExample": "emërParametriURL-je", + "TargetPage": "Faqe e Synuar", + "TargetPageTestErrorInvalidUrl": "Jepni një URL, përfshi protokollin.", + "TargetPageTestLabel": "Për të kontrolluar nëse veprimtaritë do të regjistrohen në këtë URL, jepni një URL të plotë, përfshi protokollin:", + "TargetPageTestTitle": "Vleftësues URL-sh", + "TargetPageTestUrlMatches": "Veprimtaritë do të regjistrohen në këtë URL", + "TargetPageTestUrlNotMatches": "Veprimtaritë s’do të regjistrohen në këtë URL", + "TargetPages": "Faqe Hyrjesh", + "TargetTypeContains": "përmban", + "TargetTypeEqualsExactly": "është saktësisht i barabartë me", + "TargetTypeEqualsExactlyInfo": "Vlera s’ka përputhje saktësisht, përfshi protokollin URL, kërkesë dhe hash kërkimi.", + "TargetTypeEqualsSimple": "është e barabartë thjesht me", + "TargetTypeEqualsSimpleInfo": "URL-ja do të krahasohet për përputhje me çfarëdo protokolli (p.sh., http dhe https) me ose pa nënpërkatësi “www.”. Çfarëdo shenje / në shteg, si dhe termi i kërkimit dhe pjesa hash e URL-së do të shpërfillen gjatë kërkimi përputhjesh të URL-së.", + "TargetTypeExists": "ekziston", + "TargetTypeIsAny": "është çfarëdo", + "TargetTypeIsNot": "jo %s", + "TargetTypeRegExp": "ka përputhje me shprehjen e rregullt", + "TargetTypeRegExpInfo": "Çfarëdo shprehje të rregullt, për shembull, “^(.*)test(.*)$”.", + "TargetTypeStartsWith": "fillon me", + "TimeOnSite": "Koha në sajt", + "TotalEvents": "Akte", + "TrackingDisabledDefaultSettingDescription": "Kjo veçori mund të jetë e dobishme nëse instanca juaj Matomo ka shumë sajte dhe doni të përdorni veçorinë vetëm me pak sajte specifikë. Kur ndjekja çaktivizohet si parazgjedhje, veçoria Hartë Veprimesh dhe Regjistrim Sesioni s’do të përdoret në ndonjë sajt, veç në e aktivizofshi ju posaçërisht, si pjesë e kodit tuaj të ndjekjes, për shembull duke thirrur '_paq.push(['HeatmapSessionRecording::enable']);'. Kjo shmang kërkesa të panevojshme ndaj rrjetit për kartelën 'configs.php', për të përcaktuar nëse është formësuar për sajtin në fjalë një hartë veprimesh se regjistrim sesioni. Mundet gjithashtu të jetë e dobishme për arsye privatësie, nëse doni të garantoni që veçoria do të përdoret vetëm në disa sajte.", + "TrackingDisabledDefaultSettingTitle": "Çaktivizoje ndjekjen, si parazgjedhje", + "UpdatingData": "Po përditësohen të dhëna…", + "UrlParameterValueToMatchPlaceholder": "Vlerë për përputhje për emër parametri URL-je", + "UrlXDoesNotLookLikeUrl": "%s s’duket si URL. Sigurohuni që përmban një protokoll të tillë si http:// ose se fillon me //", + "ViewReport": "Shiheni raportin", + "ViewportResolution": "Qartësi Zone Parjeje (gjerësi x lartësi)", + "Width": "Gjerësi", + "XSamples": "%s kampione", + "disable": "çaktivizoje", + "enable": "aktivizoje" + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/sr.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/sr.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/sr.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/sv.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/sv.json new file mode 100644 index 0000000..c961647 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/sv.json @@ -0,0 +1,80 @@ +{ + "HeatmapSessionRecording": { + "ActivityClick": "Klick", + "ActivityFormChange": "Formulärändring", + "ActivityFormText": "Formulärtext ändrad", + "ActivityFormValue": "Formulärvärde ändrat", + "ActivityInitialDom": "Initial sida", + "ActivityMove": "Flytta", + "ActivityPageChange": "Ändring inom sida", + "ActivityResize": "Ändra storlek", + "ActivityScroll": "Skroll", + "ActivityScrollElement": "Skrollelement", + "AutoPlayNextPageview": "Klicka för %1$s autospela nästa sidvisning (genväg %2$s)", + "BreakpointGeneralHelp": "Enhetstypen upptäcks automatiskt när en besökare besöker din webbplats. Men ibland kan det vara omöjligt att upptäcka enhetstypen. Om din webbplats är responsiv och layouten bryts vid en viss punkt, kommer vi att placera desktop-användare med en liten upplösning i denna enhetstyp för mer precisa värmekartor. Alla breddvärden lägre än detta värde kommer att placeras i denna enhetstyp. Sätt det till noll om din webbplats inte är responsiv.", + "CaptureDomInlineHelp": "Som standard tas värmekartors skärmdumpar automatiskt vid sidladdning. Om din sida laddar element dynamiskt (t.ex. med lazy loading) kan din snapshot vara ofullständig. Du kan manuellt fånga en komplett skärmdump genom att aktivera det här alternativet och köra följande Javascript-kod i din webbläsare: %1$s %2$sNotera:%3$s Det här alternativet återställs automatiskt efter att skärmdumpen har fångats.", + "CaptureKeystrokesHelp": "Vid aktivering kommer all text som matas in i textformulärfält att spelas in. Medan texten spelas in, ersätts varje tecken som en användare matar in med en stjärna ('*'). Du kan lägga till vissa fält på en lista för att spelas in i klartext genom att specificera ett 'data-matomo-unmask'-attribut på ett formulärfält. Däremot kommer lösenordsfält och många andra fält som potentiellt kan innehålla personlig information (adress, e-post, kreditkortsinformation, användarnamn, telefonnummer, ...) alltid att maskeras automatiskt om de upptäcks av oss som sådana (%1$släs mer%2$s). Vänligen notera att när du aktiverar denna funktion kan du spela in personlig data, vilket kan påverka GDPR.", + "ChangeReplaySpeed": "Ändra uppspelningshastighet (genväg %s)", + "ClickToSkipPauses": "Klicka för %1$s skippa pauser (genväg %2$s)", + "CreationDate": "Skapandedatum", + "DeleteRecordedPageview": "Radera denna inspelade sidvisning (ingen bekräftelse begärs)", + "DeleteRecordedSession": "Radera denna inspelade session (ingen bekräftelsefråga ställs)", + "DeleteScreenshot": "Radera skärmklipp", + "ErrorXNotANumber": "\"%1$s\" behöver vara ett nummer.", + "ExcludedElements": "Exkluderade element", + "ExcludedElementsHelp": "Du kan valfritt definiera CSS-selektorer för att utesluta vissa element som inte ska vara synliga i förhandsgranskningen av värmekartan. Till exempel ett popup-fönster som visas när målsidan öppnas. Du kan separera flera selektorer med ett komma.", + "FieldIncludedTargetsHelp": "Mål låter dig definiera på vilka sidor användaraktiviteter ska spelas in. Du kan definiera ett eller flera villkor. Till exempel kan du definiera att aktiviteter ska spelas in när URL:en eller sökvägen är lika med ett visst värde och endast om en viss URL-parameter finns i URL:en. Aktiviteter kommer endast att spelas in när alla villkor är uppfyllda, inte om endast ett av dem är uppfyllt. Alla villkor utvärderas oberoende av versaler och gemener. När du väljer 'equals simple' kommer URL-protokollet, sökparametrar och en avslutande snedstreck att ignoreras.", + "FieldIncludedTargetsHelpSessions": "Mål låter dig konfigurera så att inspelningen av en session endast startar när en besökare har nått en viss sida. Detta gör att du till exempel endast spelar in sessioner som går igenom kassa-processen. Du kan definiera ett eller flera villkor. Till exempel kan du definiera att en session endast ska spelas in när URL:en eller sökvägen är lika med ett visst värde och endast om en viss URL-parameter är närvarande i URL:en. Sessioner kommer endast att spelas in när alla villkor är uppfyllda vid en sidvisning, inte om bara ett av dem är uppfyllt. Alla villkor utvärderas oberoende av versaler och gemener. När du väljer 'equals simple' kommer URL-protokollet, sökparametrar och en avslutande snedstreck att ignoreras.", + "Heatmap": "Värmekarta", + "HeatmapSampleLimit": "Antal sidvisningar", + "HeatmapSampleLimitHelp": "Definierar antalet sidvisningar du totalt vill spela in.", + "HeatmapUsageBenefits": "Värmekartor låter dig registrera alla klick, musrörelser och skrollaktiviteter från dina besökare på en viss sida. Detta hjälper dig att ta reda på var användare tror att något är klickbart men inte är det, om det finns delar av sidan som knappt ses eller interageras med, vad dina besökare letar efter, hur mycket av sidan som är synligt när användare ser din sida, och mer.", + "HeatmapX": "Värmekarta %s", + "HeatmapXRecordedSamplesSince": "%1$s prover har spelats in sedan %2$s.", + "Heatmaps": "Värmekartor", + "Manage": "Hantera", + "NHeatmaps": "%s värmekartor", + "NSessionRecordings": "%s sessioninspelningar", + "NoHeatmapSamplesRecordedYet": "Ingen sidvisning har spelats in ännu för denna värmekarta. Om det borde finnas inspelningar vid det här laget, kanske de målsidor som konfigurerats för denna värmekarta inte matchar någon sida på din webbplats. Det rekommenderas också att kontrollera \"Systemkontroll\" under \"Administration\" (endast superanvändare) för att se om ditt system är konfigurerat att automatiskt spåra värmekartor.", + "NoHeatmapScreenshotRecordedYet": "Det har registrerats %1$s sidvisningar hittills. Dock har ingen skärmdump tagits än. Om det finns en \"%2$s\" för denna värmekarta, kan det dröja ett tag innan skärmdumpen blir tillgänglig, eftersom en användare först behöver öppna denna skärmdumps-URL. Beroende på urvalsfrekvensen kan detta ta ett tag.", + "NoHeatmapsFound": "Inga värmekartor hittade", + "NoSessionRecordedYet": "Ingen session har spelats in ännu. Om det borde finnas inspelade sessioner vid det här laget, kanske den konfigurerade ingångssidan inte matchar någon sida på din webbplats. Det rekommenderas också att kontrollera \"Systemkontroll\" under \"Administration\" (endast superanvändare) för att se om ditt system är konfigurerat att automatiskt spela in sessioner.", + "NoSessionRecordingsFound": "Inga sessionsinspelningar hittade", + "OnePageview": "1 sidvisning", + "PageviewXofY": "Sidvisning %1$s av %2$s", + "PageviewsInVisit": "Inspelade sidvisningar under denna session", + "PlayRecordedSession": "Spela upp inspelad session", + "PlayerForwardFast": "Hoppa över %1$s sekunder (genväg %2$s)", + "PlayerRewindFast": "Spola tillbaka %1$s sekunder (genväg %2$s)", + "RecordedHeatmapDocStatusActive": "Denna värmekarta är aktiv. Upp till %1$d sidvisningar kommer att spelas in med en urvalsfrekvens på %2$s.", + "RecordedHeatmapDocStatusEnded": "Värmekarta avslutad. Ingen ny aktivitet kommer spelas in.", + "RecordedSessions": "Inspelade sessioner", + "RecordedSessionsDocStatusActive": "Denna sessioninspelning är aktiv. Upp till %1$d sessioner kommer att spelas in med en urvalsfrekvens på %2$s.", + "RecordedSessionsDocStatusEnded": "Denna sessioninspelning har avslutats och inga nya sessioner kommer att spelas in.", + "Recordings": "Inspelningar", + "ReplayRecordedSession": "Spela upp inspelad session", + "ReplayX": "Spela upp %s", + "Rule": "Regel", + "SampleLimit": "Urvalsgräns", + "ScreenshotUrlHelp": "Som standard tas en skärmdump när den första besökaren visar målsidan. Om flera sid-URL:er matchar din målsida kan du specificera en specifik URL som ska användas för att fånga skärmdumpen. Värmekartans visualisering kommer endast att vara tillgänglig när minst en besökare har besökt denna URL vars aktiviteter är inspelade. Vid definiering måste URL:en matcha exakt. För att ignorera protokollet, börja URL:en med två snedstreck (//example.com). Vänligen notera att denna URL inte kan ändras när en skärmdump har tagits.", + "SessionRecording": "Sessionsinspelning", + "SessionRecordingX": "Sessioninspelning %s", + "SessionRecordings": "Sessionsinspelningar", + "SessionRecordingsUsageBenefits": "Sessioninspelningar låter dig spela in alla aktiviteter från en besökare under deras session (besök), såsom klick, musrörelser, skrollningar, fönsterändringar, sidförändringar och formulärinteraktioner. Du kan sedan spela upp dessa interaktioner för att se exakt hur en besökare interagerade med din webbplats. På så sätt förstår du deras förväntningar, eventuella problem de kan ha, användningsmönster och mer.", + "SessionSampleLimit": "Antal sessioner", + "SessionSampleLimitHelp": "Definierar antalet sessioner du totalt vill spela in.", + "SessionSampleRateHelp": "Även känd som 'trafik'. När du väljer 100%% kommer alla sessioner att spelas in så snart de har nått målsidan. När du exempelvis väljer 10%%, kommer endast var tionde session att spelas in. Ju lägre procentandel du väljer, desto längre tid kommer det att ta att nå den valda urvalsgränsen", + "StatusActive": "Aktiv", + "StatusEnded": "Avslutad", + "StatusPaused": "Pausad", + "TargetPage": "Målsida", + "TargetPages": "Ingångssidor", + "TimeOnSite": "Tid på webbplats", + "TrackingDisabledDefaultSettingDescription": "Denna funktion kan vara användbar om din Matomo har flera webbplatser och du vill använda funktionen endast på några specifika webbplatser. När spårning är inaktiverad som standard, kommer funktionerna för värmekarta och sessioninspelning inte att köras på någon webbplats om du inte specifikt aktiverar dem som en del av din spårningskod, till exempel genom att anropa '_paq.push(['HeatmapSessionRecording::enable']);'. Detta undviker onödiga nätverksförfrågningar till 'configs.php'-filen för att avgöra om en värmekarta eller sessioninspelning är konfigurerad för den aktuella webbplatsen. Det kan också vara användbart av integritetsskäl om du vill säkerställa att funktionen endast används på vissa webbplatser.", + "TrackingDisabledDefaultSettingTitle": "Inaktivera spårning som standardinställning", + "ViewReport": "Visa rapport", + "ViewportResolution": "Viewports upplösning (bredd x höjd)", + "disable": "inaktivera", + "enable": "aktivera" + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/ta.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/ta.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/ta.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/te.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/te.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/te.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/th.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/th.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/th.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/tl.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/tl.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/tl.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/tr.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/tr.json new file mode 100644 index 0000000..c580c4a --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/tr.json @@ -0,0 +1,247 @@ +{ + "HeatmapSessionRecording": { + "Action": "İşlem", + "ActivityClick": "Tıklama", + "ActivityFormChange": "Form değiştirme", + "ActivityFormText": "Form metni değiştirme", + "ActivityFormValue": "Form değeri değiştirme", + "ActivityInitialDom": "Giriş sayfası", + "ActivityMove": "Hareket etme", + "ActivityPageChange": "Sayfa içindeki değişiklik", + "ActivityResize": "Yeniden boyutlandırma", + "ActivityScroll": "Kaydırma", + "ActivityScrollElement": "Kaydırma bileşeni", + "AdBlockerDetected": "Bir reklam engelleyici kullanıldığında %1$s görüntülenemeyebilir. Matomo'nun sorunsuz çalışacağından emin olmak için lütfen reklam engelleyicinizi bu site için kapatın.", + "AdvancedOptions": "Gelişmiş ayarlar", + "AutoPlayNextPageview": "Sonraki sayfa gösteriminin otomatik olarak oynatılması için %1$s üzerine tıklayın (%2$s kısayolu)", + "AvgAboveFoldDescription": "Ortalama olarak ziyaretçiler kaydırmadan bu çizginin üzerindeki içeriği görür", + "AvgAboveFoldTitle": "Ortalama yukarı katlanma %s piksel", + "BreakpointGeneralHelp": "Bir ziyaretçi siteyi ziyaret ettiğinde aygıt türü otomatik olarak algılanır. Bununla birlikte bazen aygıt türünün belirlenemediği olur. Site uyumlu yapıda ise ve görünümü belirli boyut değişikliği noktalarında değişiyorsa, daha ayrıntılı ısı haritaları oluşturmak için küçük çözünürlük kullanan masaüstü ziyaretçileri bu aygıt türüne konulur. Bu değerden daha küçük genişlik değerleri bu aygıt türüne konulur. Site uyumlu yapıda değilse sıfır olarak ayarlayın.", + "BreakpointGeneralHelpManage": "Süper kullanıcı izinlerine sahip bir kullanıcı Yönetim => Genel ayarlar bölümünden bu boyut değişikliği noktalarının varsayılan değerlerini değiştirebilir.", + "BreakpointX": "%s boyut değişikliği noktası", + "CaptureDomInlineHelp": "Varsayılan olarak, anlık sıcaklık haritası görüntüleri sayfa yüklemesinde otomatik olarak kaydedilir. Sayfanız dinamik olarak bileşenler yüklüyorsa (örneğin yavaş yükleme ile) anlık görüntünüz eksik olabilir. Bu seçeneği açtığınızda ve tarayıcınızda aşağıdaki JavaScript kodunu çalıştırarak tam bir anlık görüntü elde edebilirsiniz: %1$s %2$sNot:%3$s Bu seçenek, anlık görüntüyü kaydettikten sonra otomatik olarak sıfırlanır.", + "CaptureDomTitle": "Anlık sıcaklık haritası görüntüsünü el ile kaydet", + "CaptureKeystrokes": "Tuş basmaları kaydedilsin", + "CaptureKeystrokesHelp": "Açıldığında, form metin alanlarına yazılan metinler kaydedilir. Metin kaydedilirken ziyaretçinin yazdığı karakterler yıldız ('*') ile değiştirilir. Form alanında bir 'data-matomo-unmask' özniteliği belirterek istediğiniz alanları beyaz listeye alabilir ve içeriklerinin düz metin biçiminde kaydedilmesini sağlayabilirsiniz. Bununla birlikte parola ve kişisel verilerin (adres, e-posta, kredi kartı bilgileri, kullanıcı adı, telefon numarası gibi) bulunabileceği pek çok diğer alan algılandığında her zaman otomatik olarak gizlenir (%1$sayrıntılı bilgi alın%2$s). Açıldığında, KVKK ile ilgili kişisel verilerin kaydedebileceğini unutmayın.", + "ChangeReplaySpeed": "Yeniden oynatma hızını değiştir (%s kısayolu)", + "ClickRate": "Tıklama oranı:", + "ClickToSkipPauses": "Duraklamaları atlamak için %1$s üzerine tıklayın (%2$s kısayolu)", + "Clicks": "Tıklamalar:", + "ColumnActionsDocumentation": "Kaydedilmiş oturumu oynatın ya da kayıtları kalıcı olarak silin.", + "ColumnBrowserDocumentation": "Bu oturum kaydedilirken ziyaretçinin kullandığı tarayıcı.", + "ColumnDeviceDocumentation": "Bu oturum kaydedilirken ziyaretçinin kullandığı aygıt.", + "ColumnLabelRecordedSessions": "Giriş adresi → Çıkış adresi", + "ColumnLocationDocumentation": "Bu oturum kaydedilirken ziyaretçinin bulunduğu konum.", + "ColumnOperatingSystem": "İşletim sistemi", + "ColumnOperatingSystemDocumentation": "Bu oturum kaydedilirken ziyaretçinin kullandığı işletim sistemi.", + "ColumnPageviewsDocumentation": "Bu oturum boyunca kaydedilen sayfa gösterimlerinin sayısı.", + "ColumnResolutionDocumentation": "Ziyaretçi bu sayfayı açtığında, tarayıcı penceresinde görüntülenen bakış açısının piksel sayısı.", + "ColumnTime": "Zaman", + "ColumnTimeDocumentation": "Sitenin saat dilimine göre kayıdın başladığı zaman.", + "ColumnTimeOnPageDocumentation": "Oturum kaydedilirken ziyaretçinin bu sayfada geçirdiği süre", + "ColumnTimeOnSiteDocumentation": "Oturum kaydedilirken ziyaretçinin toplam geçirdiği süre", + "ColumnTotalEventsDocumentation": "Bu oturum sırasında kaydedilen olayların sayısı. Örneğin: tıklama, hareket etme, kaydırma, yeniden boyutlandırma, sayfa değişiklikleri ve form etkileşimi olayları.", + "ConfigsInternetDisabled": "Bu Matomo kopyası için İnternet bağlantısı kapatılmış olduğundan '%s' erişimi denetlenemedi.", + "ConfigsPhpErrorResult": "Sonuç olarak ısı haritaları ve oturum kayıtları çalışmayabilir. Bu dosyaya Internet ya da Intranet üzerinden erişilmesine izin vermek için site sunucunuzun yapılandırmasını değiştirmeniz gerekebilir.", + "ConfigsPhpManualCheck": "Lütfen adresi el ile açarak yanıtta 'Piwik.HeatmapSessionRecording' olup olmadığına bakın. Göremiyorsanız, bu dosyaya İnternet ya da İntranet üzerinden erişilebilmesi için sunucu yapılandırmanızı değiştirmeniz gerekebilir.", + "ConfigsPhpNotAccessible": "'%s' adresine İnternet ya da Intranet üzerinden erişilemiyor gibi görünüyor.", + "ConfigsPhpSelfSignedError": "'%s' isteği yapıldığında bir SSL sorunu çıktı. Kendinden imzalı bir sertifika kullanıyor olabilir misiniz?", + "ConfigsPhpSuccess": "'%s' erişilebilir", + "ConfigsPhpUnknown": "'%s' için İnternet ya da İntranet erişimi denetlenemedi.", + "ConfigsPhpUnknownError": "'%1$s' isteğinde sorun çıktı: %2$s.", + "Country": "Ülke", + "CreateNewHeatmap": "Yeni ısı haritası ekle", + "CreateNewSessionRecording": "Yeni oturum kaydı ekle", + "CreationDate": "Eklenme tarihi", + "DeleteHeatmapConfirm": "Bu ısı haritasını silmek istediğinize emin misiniz? Daha önce üretilmiş raporlar kullanıcı arayüzündeki raporlar bölümünde görüntülenmeyecek.", + "DeleteHeatmapScreenshotConfirm": "Bu ekran görüntüsünü silmek istediğinize emin misiniz? Ekran görüntüsünün yeniden alınması biraz zaman alabilir.", + "DeleteRecordedPageview": "Bu sayfa gösterimi kaydını sil (onay istenmez)", + "DeleteRecordedSession": "Bu oturum kaydını sil (onay istenmez)", + "DeleteScreenshot": "Ekran görüntüsünü sil", + "DeleteSessionRecordingConfirm": "Bu oturum kaydını silmek istediğinize emin misiniz? Daha önce üretilmiş raporlar kullanıcı arayüzündeki raporlar bölümünde görüntülenmeyecek.", + "DeleteX": "%s sil", + "DeviceType": "Aygıt türü", + "DisableHeatmapRecordingDescription": "Açıldığında, herhangi bir sıcaklık haritası yapılandırılamaz ya da kaydedilemez.", + "DisableHeatmapRecordingInlineHelp": "%1$sNot: Bu özellik kapatıldığında, önceden izlenen sıcaklık haritaları ile birlikte tüm sıcaklık haritaları silinir. Bu özelliği açtığınızda ya da kapattığınızda, oturum kaydında herhangi bir değişiklik olmaz.%2$s", + "DisableHeatmapRecordingTitle": "Sıcaklık haritaları kapatılsın", + "DisableSessionRecordingDescription": "Bu özellik kapatıldığında hiçbir oturum kaydı yapılandırılamaz ya da izlenemez.", + "DisableSessionRecordingInlineHelp": "%1$s Not: Bu özellik kapatıldığında, daha önce izlenen oturum kayıtları ile birlikte tüm oturum kayıtları silinir. Bu özelliği açar ya da kapatırsanız sıcaklık haritalarında herhangi bir değişiklik olmaz. %2$s", + "DisableSessionRecordingTitle": "Oturum kaydını kapat", + "EditHeatmapX": "%s ısı haritasını düzenle", + "EditSessionRecordingX": "%s oturum kaydını düzenle", + "EditX": "%s düzenle", + "EnableIncludeCountriesDescription": "Varsayılan olarak, tüm ziyaretler kaydedilir. Belirli ülkelerden ziyaretleri izlemek istediğinizde bu bölümü kullanıma alın.", + "EnableIncludeCountriesTitle": "Yalnızca belirli ülkelerden gelen ziyaretçiler izlensin", + "EndHeatmapConfirm": "Bu ısı haritası için yeni işlemlerin kaydedilmesini durdurmak istediğinize emin misiniz?", + "EndSessionRecordingConfirm": "Yeni oturumların kaydedilmesini durdurmak istediğinize emin misiniz?", + "ErrorArrayMissingKey": "\"%2$s\" içinde \"%3$s\" konumundaki \"%1$s\" dizi anahtarı eksik.", + "ErrorArrayMissingValue": "\"%2$s\" içinde \"%3$s\" konumundaki \"%1$s\" dizi anahtarının değeri eksik.", + "ErrorHeatmapDoesNotExist": "İstenilen ısı haritası bulunamadı", + "ErrorHeatmapNameDuplicate": "Aynı adı kullanan başka bir ısı haritası zaten var.", + "ErrorHeatmapRecordingDisabled": "Sıcaklık haritası kaydetme özelliği kapatılmış. Bu özelliği kullanmak istiyorsanız, bir Matomo süper kullanıcısının genel ayarlar bölümünden açılması gerekir.", + "ErrorInnerIsNotAnArray": "\"%2$s\" içindeki her \"%1$s\" bir dizi olmalıdır.", + "ErrorInvalidRegExp": "\"%1$s\" kurallı ifadesinin biçimi geçersiz.", + "ErrorNotAnArray": "\"%1$s\" bir dizi olmalıdır.", + "ErrorPageRuleRequired": "En az bir sayfa kuralı ayarlanmış olmalıdır.", + "ErrorSessionRecordingDisabled": "Oturum kaydetme özelliği kapatıldı. Kullanılabilmesi için bu özelliğin genel ayarlar bölümünden süper kullanıcı düzeyinde bir Matomo kullanıcısı tarafından açılması gerekir.", + "ErrorSessionRecordingDoesNotExist": "İstenilen oturum kaydı bulunamadı", + "ErrorXContainsWhitespace": "\"%1$s\" içinde boşluk bulunamaz.", + "ErrorXNotANumber": "\"%1$s\" bir sayı olmalıdır.", + "ErrorXNotProvided": "Lütfen \"%1$s\" için bir değer yazın.", + "ErrorXNotWhitelisted": "\"%1$s\" değeri kullanılamaz. Şunlardan birini kullanın: %2$s.", + "ErrorXTooHigh": "\"%1$s\" çok büyük. İzin verilen en büyük değer %2$s.", + "ErrorXTooLong": "\"%1$s\" çok uzun. İzin verilen en fazla karakter sayısı %2$s.", + "ErrorXTooLow": "\"%1$s\" çok küçük. İzin verilen en küçük değer %2$s.", + "ExcludedElements": "Katılmayan bileşenler", + "ExcludedElementsHelp": "İsteğe bağlı olarak, görünmesini istemediğiniz bileşenlerin ısı haritası ön izlemesine katılmaması için CSS seçicileri tanımlayabilirsiniz. Örnek: Hedef sayfa açıldığında görüntülenen bir açılan pencere. Virgül ile ayırarak birden fazla seçici yazabilirsiniz.", + "FieldIncludedTargetsHelp": "Hedefler hangi sayfalardaki kullanıcı işlemlerinin kaydedileceğini belirler. Bir ya da bir kaç koşul tanımlayabilirsiniz. Örneğin, adres ya da yol belirli bir değer olduğunda ya da adreste yalnızca belirli adres parametreleri bulunduğunda işlemlerin kaydedilmesini sağlayabilirsiniz. İşlemler bazı koşullar değil yalnızca tüm koşullar karşılandığında kaydedilir. Tüm koşullar büyük-küçük harfe duyarlı olarak değerlendirilir. 'basit olarak eşleşen' koşulu seçildiğinde adres iletişim kuralı, arama parametreleri ve sondaki bölü karakteri yok sayılır.", + "FieldIncludedTargetsHelpSessions": "Hedefler hangi sayfalara kullanıcılar eriştiğinde oturumların kaydedileceğini belirler. Böylece örneğin yalnızca ödeme işlemlerinin yapıldığı oturumları kaydedebilirsiniz. Bir ya da bir kaç koşul tanımlayabilirsiniz. Örneğin, adres ya da yol belirli bir değer olduğunda ya da adreste yalnızca belirli adres parametreleri bulunduğunda oturumların kaydedilmesini sağlayabilirsiniz. Oturumlar bazı koşullar değil yalnızca tüm koşullar karşılandığında kaydedilir. Tüm koşullar büyük-küçük harfe duyarlı olarak değerlendirilir. 'basit olarak eşleşen' koşulu seçildiğinde adres iletişim kuralı, arama parametreleri ve sondaki bölü karakteri yok sayılır.", + "FieldNamePlaceholder": "Örnek: 'Kayıt sayfası'", + "FilesystemDirectory": "klasör", + "Filter": "Süzgeç", + "GettingStarted": "Başlarken", + "Heatmap": "Isı haritası", + "HeatmapAddedActivity": "\"%2$s\" sitesi için \"%1$s\" sıcaklık haritasını ekledi", + "HeatmapCopied": "Sıcaklık haritası kopyalandı.", + "HeatmapCreated": "Isı haritası eklendi.", + "HeatmapDeletedActivity": "\"%2$s\" sitesi için \"%1$s\" sıcaklık haritasını sildi", + "HeatmapDuplicationError": "İşlem tamamlanmadı. Sıcaklık haritası seçilmiş tüm sitelere kopyalanamadı. Kopyalanamayan siteler: %1$s. Kopyalanan siteler: %2$s.", + "HeatmapDuplicationSiteTypeError": "Kopyalama özelliği sitelerde desteklenmiyor: %1$s.", + "HeatmapEndedActivity": "\"%2$s\" sitesi için \"%1$s\" sıcaklık haritasını sonlandırdı", + "HeatmapInfoTrackVisitsFromCountries": "Sıcaklık haritası yalnızca %1$s ziyaretlerini izlemek için yapılandırılmış.", + "HeatmapNameHelp": "Bu ısı haritası raporu için kullanılacak adı belirler.", + "HeatmapPausedActivity": "\"%2$s\" sitesi için \"%1$s\" sıcaklık haritasını duraklattı", + "HeatmapResumedActivity": "\"%2$s\" sitesi için \"%1$s\" sıcaklık haritasını sürdürdü", + "HeatmapSampleLimit": "Sayfa gösterimi sayısı", + "HeatmapSampleLimitHelp": "Kaydedilmesini istediğiniz toplam sayfa gösterimi sayısı.", + "HeatmapSampleRateHelp": "'Trafik' olarak da bilinir. %%100 seçildiğinde, seçilmiş hedef sayfayı ziyaret eden tüm ziyaretçiler kaydedilir. Örneğin %%10 seçildiğinde, her 10 ziyaretçiden biri kaydedilir. Yüzde değerini azalttığınızda seçilmiş örnek sınırına erişilmesi daha uzun zaman alır.", + "HeatmapScreenshotDeletedActivity": "\"%2$s\" sitesi için \"%1$s\" sıcaklık haritası ekran görüntüsünü sildi", + "HeatmapTroubleshoot": "Sorun mu yaşıyorsunuz? %1$sAyrıntılı bilgi alın%2$s.", + "HeatmapUpdated": "Isı haritası güncellendi.", + "HeatmapUpdatedActivity": "\"%2$s\" sitesi için \"%1$s\" sıcaklık haritasını güncelledi", + "HeatmapUsageBenefits": "Isı haritaları, belirli bir sayfa için ziyaretçilerin tüm tıklama, fare hareketi ve kaydırma işlemlerinin kaydedilmesini sağlar. Böylece ziyaretçilerin, tıklanabilir olduğunu düşündükleri halde tıklanamayan bileşenleri, sayfada görmedikleri ya da etkileşim içinde oldukları bölümleri, neye baktıklarını, sayfanın ne kadarını gördükleri gibi bilgileri öğrenebilirsiniz.", + "HeatmapWidth": "Isı haritası genişliği", + "HeatmapX": "%sısı haritası", + "HeatmapXRecordedSamplesSince": "%2$s zamanından beri %1$s örnek kaydedilmiş.", + "Heatmaps": "Isı haritaları", + "Manage": "Yönetim", + "ManageHeatmapSubcategoryHelp": "Isı haritaları, ziyaretçilerin belirli bir sayfadaki tüm tıklamalarını, fare hareketlerini ve kaydırma işlemlerinin kaydedilmesini sağlar. Site için ısı haritası izlemeleri bu bölümden eklenip yönetilebilir.", + "ManageHeatmaps": "Isı haritaları yönetimi", + "ManageSessionRecordingSubcategoryHelp": "Oturum kayıtları, gerçek bir ziyaretçinin oturumu sırasında yaptığı tıklama, fare hareketi, kaydırma, pencereyi yeniden boyutlandırma, sayfa değiştirme ve form etkileşimi gibi işlemlerin kaydedilmesini sağlar. Site için oturum kayıtları bu bölümden eklenip yönetilebilir.", + "ManageSessionRecordings": "Oturum kayıtları yönetimi", + "MatomoJSNotWritableErrorMessage": "HeatmapSessionRecording: %1$s, matomo.js dosyası salt okunur olduğu için şu anda ziyaretleri izleyemiyor. Dosyayı yazılabilir yapmak için lütfen %2$sbelgelere%3$s bakın.", + "MinSessionTime": "En kısa oturum süresi", + "MinSessionTimeHelp": "Bir sayfada yalnızca belirtilen değerden daha uzun süre geçirilen oturumlar kaydedilir.", + "MoveRate": "Hareket oranı:", + "Moves": "Hareketler:", + "NHeatmaps": "%s ısı haritası", + "NSessionRecordings": "%s oturum kayıtları", + "NoHeatmapSamplesRecordedYet": "Bu ısı haritası için henüz bir sayfa gösterimi kaydedilmemiş. Burada kaydedilmiş sayfa gösterimlerinin olması gerekiyorsa, bu ısı haritası için yapılandırılmış sayfa hedefleri sitenizdeki hiç bir sayfa ile eşleşmiyor olabilir. Ayrıca \"Yönetim\" başlığı altından \"Sistem denetimi\" bölümüne (yalnızca süper kullanıcılar görebilir) bakarak sisteminizin ısı haritalarını otomatik olarak izleyecek şekilde yapılandırıldığından emin olmanız önerilir.", + "NoHeatmapSamplesRecordedYetWithoutSystemConfiguration": "Bu sıcaklık haritası için henüz bir sayfa görünümü kaydedilmemiş. Şimdiye kadar bazı kayıtlar olması gerektiğini düşünüyorsanız, bu sıcaklık haritası için yapılandırılmış sayfa hedefleri sitenizdeki hiçbir sayfayla eşleşmiyor olabilir.", + "NoHeatmapScreenshotRecordedYet": "Şu ana kadar %1$s örnek kaydedilmiş. Bununla birlikte henüz bir ekran görüntüsü kaydedilmemiş. Bu ısı haritası için bir \"%2$s\" varsa, bir ziyaretçinin önce bu ekran görüntüsü adresini açması gerektiğinden ekran görüntüsünün kullanılabilmesi zaman alabilir. Örnekleme hızına göre bu işlem biraz zaman alabilir.", + "NoHeatmapsConfiguredInfo": "Şu anda etkin bir ısı haritası yok. Isı haritalarını görüntüleyebilmek için, en az yönetici izinlerine sahip bir kullanıcıdan yeni bir ısı haritası eklemesini isteyin.", + "NoHeatmapsFound": "Herhangi bir ısı haritası bulunamadı", + "NoSessionRecordedYet": "Henüz bir oturum kaydedilmemiş. Burada kaydedilmiş bir oturum olması gerekiyorsa, yapılandırılmış giriş sayfası sitenizdeki hiç bir sayfa ile eşleşmiyor olabilir. Ayrıca \"Yönetim\" başlığı altından \"Sistem denetimi\" bölümüne (yalnızca süper kullanıcılar görebilir) bakarak sisteminizin oturumları otomatik olarak kaydedecek şekilde yapılandırıldığından emin olmanız önerilir.", + "NoSessionRecordedYetWithoutSystemConfiguration": "Henüz herhangi bir oturum kaydedilmemiş. Şimdiye kadar bazı kaydedilmiş oturumlar olması gerektiğini düşünüyorsanız, ayarlanmış kayıt sayfası sitenizdeki hiçbir sayfayla eşleşmiyor olabilir.", + "NoSessionRecordingsConfiguredInfo": "Şu anda etkin bir oturum kaydı yok. Yeni oturumları kaydedebilmek için, en az yönetici izinlerine sahip bir kullanıcıdan yeni bir kayıt işlemi eklemesini isteyin.", + "NoSessionRecordingsFound": "Herhangi bir oturum kaydı bulunamadı", + "None": "Yok", + "NotSupportedBrowser": "Bu tarayıcı desteklenmiyor. Lütfen daha yeni bir sürümü kullanın ya da farklı bir tarayıcı deneyin.", + "OnePageview": "1 sayfa gösterimi", + "PageRule": "Sayfa kuralı", + "PageviewXofY": "%1$s / %2$s sayfa gösterimi", + "PageviewsInVisit": "Bu oturumda kaydedilmiş sayfa gösterimleri", + "PauseReason": "Kotanızın dolması nedeniyle %1$s duraklatıldı. Lütfen ayrıntılı bilgi almak için destek ekibi ile görüşün.", + "PeriodDisabledErrorMessage": "\"%1$s\" dönemi kullanımdan kaldırılmış. Ancak bu özelliğin çalışması için gerekli. Başka bir dönem seçin ya da sistem yöneticisinden 'config/config.ini.php' Matomo yapılandırma dosyasındaki \"enabled_periods_API\" seçeneğini \"%1$s\" dönemine izin verecek şekilde değiştirmesini isteyin.", + "PersonalInformationNote": "Lütfen bir %1$s tarafından kişisel verilerin kaydedilebileceğini unutmayın. Sitenizdeki ya da uygulamanızdaki belirli içeriklerin gizlenmesini istiyorsanız, HTML kodunda bu içeriklere %2$sdata-matomo-mask%3$s özniteliğini ekleyebilirsiniz. %4$sAyrıntılı bilgi alın%5$s", + "PlayRecordedSession": "Bu oturum kaydını oynat", + "PlayerDurationXofY": "%1$s / %2$s", + "PlayerForwardFast": "%1$s saniye ileri git (%2$s kısayolu)", + "PlayerPageViewNext": "Bu ziyaretçinin sonraki sayfa gösterimini oynat %1$s (%2$s kısayolu)", + "PlayerPageViewPrevious": "Bu ziyaretçinin önceki sayfa gösterimini oynat %1$s (%2$s kısayolu)", + "PlayerPause": "Duraklat (%s kısayolu ya da boşluk)", + "PlayerPlay": "Oynat (%s kısayolu ya da boşluk)", + "PlayerReplay": "Yeniden oynat (%s kısayolu ya da boşluk)", + "PlayerRewindFast": "%1$s saniye geri git (%2$s kısayolu)", + "PolicyHeatmapRequirementNote": "HeatmapSessionRecording eklentisinde sıcaklık haritası kaydının kapalı olmalıdır", + "PolicyHeatmapTitle": "Sıcaklık haritaları - Sıcaklık haritası kaydını kapat", + "PolicySessionRequirementNote": "HeatmapSessionRecording eklentisinde oturum kaydı kapalı olmalıdır", + "PolicySessionTitle": "Sıcaklık haritaları - Oturum kaydını kapat", + "QuotaReachedForX": "Kota sınırınıza ulaştınız. Kullanım aşımı nedeniyle %1$s kopyalanamıyor. Kotanızı yönetmek için kullanılmayan veya daha az önemli %2$s ögesini silmeyi değerlendirin.", + "RecordedHeatmapDocStatusActive": "Bu ısı haritası etkin. %2$s örnekleme hızı ile en çok %1$d sayfa gösterimi kaydedilebilir.", + "RecordedHeatmapDocStatusEnded": "Bu ısı haritası sona ermiş. Yeni işlemler kaydedilmeyecek.", + "RecordedPageviewDeletedActivity": "\"%2$s\" sitesi için \"%1$s\" oturum kaydının kaydedilmiş bir sayfa görünümünü sildi", + "RecordedSessionDeletedActivity": "\"%2$s\" sitesi için \"%1$s\" oturum kaydının bir oturum kaydını sildi", + "RecordedSessions": "Kaydedilmiş oturumlar", + "RecordedSessionsDocStatusActive": "Bu oturum kaydı etkin. %2$s örnekleme hızı ile en çok %1$d oturum kaydedilebilir.", + "RecordedSessionsDocStatusEnded": "Bu oturum kaydı sona erdi ve yeni bir oturum kaydedilmeyecek.", + "Recordings": "Kayıtlar", + "ReplayRecordedSession": "Kaydedilmiş oturumu yeniden oynat", + "ReplayX": "%s yeniden oynatılsın", + "ReportRecordedSessionsDocumentation": "Kaydedilen tüm oturumların listesi. Kaydedilen herhangi bir oturumu yeniden oynatabilirsiniz.", + "RequiresActivity": "İşlem yapılması gereksin", + "RequiresActivityHelp": "Açıldığında, bir sayfa gösteriminde yalnızca bir kaydırma ve tıklama işlemi yapılan oturumlar kaydedilir. Böylece çok fazla işlem yapılmayan oturumların kaydedilmesi engellenir.", + "Rule": "Kural", + "SampleLimit": "Örnek sınırı", + "SampleRate": "Örnekleme hızı", + "ScreenshotUrl": "Ekran görüntüsü adresi", + "ScreenshotUrlHelp": "Varsayılan olarak, bir ziyaretçi bir hedef sayfasını ilk kez görüntülediğinde bir ekran görüntüsü kaydedilir. Hedef sayfanız ile birden çok sayfa adresi eşleşiyorsa ekran görüntüsünün kaydedileceği belirli bir adres yazabilirsiniz. Isı haritası görselleştirmesi yalnızca işlemleri kaydedilen bir ziyaretçinin bu adrese erişmesinden sonra oluşturulur. Belirtildiğinde adres tam olarak eşleşmelidir. İletişim kuralının yok sayılması için adresin başına iki tane bölü karakteri ekleyin (//ornek.com gibi). Bir ekran görüntüsü kaydedildikten sonra bu adresin değiştirilemeyeceğini unutmayın.", + "SessionNameHelp": "Oturum kayıtları için kullanılacak adı belirler.", + "SessionRecording": "Oturum kaydı", + "SessionRecordingAddedActivity": "\"%2$s\" sitesi için \"%1$s\" oturum kaydını ekledi", + "SessionRecordingCreated": "Oturum kaydı eklendi.", + "SessionRecordingDeletedActivity": "\"%2$s\" sitesi için \"%1$s\" oturum kaydını sildi", + "SessionRecordingEndedActivity": "\"%2$s\" sitesi için \"%1$s\" oturum kaydını sonlandırdı", + "SessionRecordingInfoTrackVisitsFromCountries": "Oturum kaydı yalnızca %1$s ziyaretlerini izlemek için yapılandırılmış.", + "SessionRecordingPausedActivity": "\"%2$s\" sitesi için \"%1$s\" oturum kaydını duraklattı", + "SessionRecordingResumedActivity": "\"%2$s\" sitesi için \"%1$s\" oturum kaydını sürdürdü", + "SessionRecordingUpdated": "Oturum kaydı güncellendi.", + "SessionRecordingUpdatedActivity": "\"%2$s\" sitesi için \"%1$s\" oturum kaydını güncelledi", + "SessionRecordingX": "%s oturum kaydı", + "SessionRecordings": "Oturum kayıtları", + "SessionRecordingsUsageBenefits": "Oturum kayıtları, gerçek bir ziyaretçinin oturumu sırasında yaptığı tıklama, fare hareketi, kaydırma, pencereyi yeniden boyutlandırma, sayfa değiştirme ve form etkileşimi gibi işlemlerin kaydedilmesini sağlar. Bir ziyaretçinin sitenizle nasıl etkileşime girdiğini tam olarak görmek için bu etkileşimleri daha sonra yeniden oynatabilirsiniz. Böylece ziyaretçilerin beklentileri, yaşamış olabilecekleri sorunlar, kullanım alışkanlıkları gibi pek çok şeyi öğrenebilirsiniz.", + "SessionSampleLimit": "Oturum sayısı", + "SessionSampleLimitHelp": "Kaydedilmesini istediğiniz toplam oturum sayısı.", + "SessionSampleRateHelp": "'Trafik' olarak da bilinir. %%100 seçildiğinde, seçilmiş hedef sayfaya erişen tüm oturumlar kaydedilir. Örneğin %%10 seçildiğinde, her 10 oturumdan biri kaydedilir. Yüzde değerini azalttığınızda seçilmiş örnek sınırına erişilmesi daha uzun zaman alır.", + "SourceHeatmapLookupError": "Kaynak sıcaklık haritasına bakılırken beklenmeyen bir sorun çıktı. Lütfen yeniden deneyin. Sorun sürerse yöneticinizle veya destek ekibinizle görüşün.", + "StatusActive": "Etkin", + "StatusEnded": "Sona ermiş", + "StatusPaused": "Duraklatıldı", + "StopX": "Bu %s için yeni işlemleri kaydetmeyi durdurur.", + "TargetAttributePath": "Yol", + "TargetAttributeUrl": "Adres", + "TargetAttributeUrlParameter": "Adres parametresi", + "TargetAttributeUrlParameterExample": "AdresParametresininAdı", + "TargetPage": "Hedef sayfa", + "TargetPageTestErrorInvalidUrl": "Başında iletişim kuralı olan bir adres yazın.", + "TargetPageTestLabel": "İşlem kaydını denetlemek için başında iletişim kuralı olan tam adresi yazın:", + "TargetPageTestTitle": "Adres doğrulayıcı", + "TargetPageTestUrlMatches": "Bu adresteki işlemler kaydediliyor", + "TargetPageTestUrlNotMatches": "Bu adresteki işlemler kaydedilmiyor", + "TargetPages": "Kayıt sayfaları", + "TargetTypeContains": "şunu içeren", + "TargetTypeEqualsExactly": "tam olarak eşleşen", + "TargetTypeEqualsExactlyInfo": "Değer, adres iletişim kuralı, arama sorgusu ve karma ile tam olarak eşleşmelidir.", + "TargetTypeEqualsSimple": "basit olarak eşleşen", + "TargetTypeEqualsSimpleInfo": "Adres başında \"www.\" alt etki alanı olarak ya da olmayarak tüm iletişim kuralları (http ve https gibi) ile eşleşir. Eşleştirilirken adresin sonundaki bölü karakteri, arama sorgusu ve karma bölümü yok sayılır.", + "TargetTypeExists": "var olan", + "TargetTypeIsAny": "şunların tümü", + "TargetTypeIsNot": "%s olmayan", + "TargetTypeRegExp": "şu kurallı ifadeye uyan", + "TargetTypeRegExpInfo": "Herhangi bir kurallı ifade. Örnek: \"^(.*)test(.*)$\".", + "TargetTypeStartsWith": "şununla başlayan", + "TimeOnSite": "Sitedeki zaman", + "TotalEvents": "Olaylar", + "TrackingDisabledDefaultSettingDescription": "Bu özellik, Matomo üzerinde birden fazla site varsa ve yalnızca belirli birkaç sitenin izlenmesi isteniyorsa yararlı olabilir. İzleme özelliği varsayılan olarak kapatıldığında, izleme kodu içine özellikle '_paq.push(['HeatmapSessionRecording::enable']);' gibi bir satır eklenerek açılmadıkça, hiç bir site izlenmez. Böylece geçerli site için bir ısı haritası veya oturum kaydının yapılıp yapılmayacağını belirlemek amacıyla 'configs.php' dosyasına yapılacak gereksiz ağ istekleri önlenebilir. Gizlilik nedeniyle izleme özelliğinin yalnızca bazı sitelerde kullanılacağından emin olmak istiyorsanız da yararlı olabilir.", + "TrackingDisabledDefaultSettingTitle": "Varsayılan olarak izleme kullanılmasın", + "UpdatingData": "Veriler güncelleniyor…", + "UrlParameterValueToMatchPlaceholder": "Adres parametresi adı için eşleştirilecek değer", + "UrlXDoesNotLookLikeUrl": "%s bir adres gibi görünmüyor. Adresin başında http:// gibi bir iletişim kuralı ya da // bulunduğundan emin olun", + "ViewReport": "Raporu görüntüle", + "ViewportResolution": "Bakış açısı çözünürlüğü (genişlik x yükseklik)", + "Width": "Genişlik", + "XSamples": "%s örnek", + "disable": "kullanımdan kaldır", + "enable": "aç" + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/tzm.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/tzm.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/tzm.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/uk.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/uk.json new file mode 100644 index 0000000..a71dbe5 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/uk.json @@ -0,0 +1,6 @@ +{ + "HeatmapSessionRecording": { + "Heatmap": "Теплова карта", + "SessionRecording": "Запис сеансу" + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/ur.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/ur.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/ur.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/vi.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/vi.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/vi.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/zh-cn.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/zh-cn.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/zh-cn.json @@ -0,0 +1 @@ +{} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/lang/zh-tw.json b/files/plugin-HeatmapSessionRecording-5.6.0/lang/zh-tw.json new file mode 100644 index 0000000..2085aab --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/lang/zh-tw.json @@ -0,0 +1,185 @@ +{ + "HeatmapSessionRecording": { + "ActivityClick": "點擊", + "ActivityFormChange": "表單變更", + "ActivityFormText": "表單文字變更", + "ActivityFormValue": "表單值變更", + "ActivityInitialDom": "初始網頁", + "ActivityMove": "移動", + "ActivityResize": "縮放", + "ActivityScroll": "捲動", + "ActivityScrollElement": "捲動元素", + "AdvancedOptions": "進階選項", + "AutoPlayNextPageview": "點擊以%1$s自動播放下個瀏覽頁面(快捷鍵 %2$s)", + "AvgAboveFoldDescription": "訪客不捲動時平均能看到這條線以上的內容", + "AvgAboveFoldTitle": "平均涵蓋 %s 像素之上", + "BreakpointGeneralHelp": "裝置類型在訪客訪問你的網站時就會自動偵測。然而,裝置類型有時候無法偵測。如果你的網站是響應式設計並且佈局在某個寬度下會變更,我們會將螢幕解析度較低的電腦版使用者納入這個裝置類型以增加更準確的熱度圖。任何比這個值還低的寬度將會被納入這個裝置類型。如果你的網站不是響應式設計,就設定為 0。", + "BreakpointGeneralHelpManage": "擁有超級使用者權限的使用者可以在管理中心 => 一般設定中變更這些斷點的預設值", + "BreakpointX": "%s斷點", + "CaptureKeystrokes": "捕捉按鍵", + "CaptureKeystrokesHelp": "若啟用,任何輸入於文字表單的文字將被紀錄。當文字被紀錄時,任何使用者輸入的文字將被取代為星號(*)。你可以將特定表單欄位加入屬性「data-matomo-unmask」來添加到白名單以錄製純文字。然而,密碼欄位和其他可能包含個人資訊(地址、Email、信用卡資訊、使用者名稱、電話號碼等等)欄位若被我們偵測到時,將會被自動隱藏(%1$s了解更多%2$s)。請注意如果你啟用了這項功能,你可能錄製到個人資料並影響 GDPR。", + "ChangeReplaySpeed": "變更重播速度(快捷鍵 %s)", + "ClickToSkipPauses": "點擊以%1$s略過暫停(快捷鍵 %2$s)", + "ColumnActionsDocumentation": "播放錄製的行為或永久刪除紀錄。", + "ColumnBrowserDocumentation": "錄製行為時,訪客使用的瀏覽器。", + "ColumnDeviceDocumentation": "錄製行為時,訪客使用的裝置。", + "ColumnLabelRecordedSessions": "到達網頁 → 離開網頁", + "ColumnLocationDocumentation": "錄製行為時,訪客所在的地點。", + "ColumnOperatingSystem": "作業系統", + "ColumnOperatingSystemDocumentation": "錄製行為時,訪客使用的作業系統。", + "ColumnPageviewsDocumentation": "這段行為錄製的過程中訪問的頁面數量。", + "ColumnResolutionDocumentation": "使用者開啟這個網頁時瀏覽器視窗內可見區域的像素大小。", + "ColumnTime": "時間", + "ColumnTimeDocumentation": "開始錄製的時間點,以網站的時區顯示。", + "ColumnTimeOnPageDocumentation": "錄製行為時,訪客在此網頁中花了多少時間。", + "ColumnTimeOnSiteDocumentation": "錄製行為時,訪客一共花了多少時間。", + "ConfigsInternetDisabled": "由於這個 Matomo 被禁止了網路連線,因此我們無法檢查「%s」是否能訪問。", + "ConfigsPhpErrorResult": "結果顯示,追蹤熱度圖和行為錄製可能無法作用。你可能需要變更你的伺服器設定來允許網際網路或內部網路訪問這個文件。", + "ConfigsPhpManualCheck": "請手動在瀏覽器中開啟網址來查看回應內容是否包含「Piwik.HeatmapSessionRecording」。如果沒有,你可能需要變更伺服器設定,因為這個檔案必須要能經由網際網路或內部網路在瀏覽器中訪問。", + "ConfigsPhpNotAccessible": "網址「%s」似乎無法經由網際網路或內部網路訪問。", + "ConfigsPhpSelfSignedError": "請求的「%s」出現 SSL 錯誤。或許你使用的自行簽署的憑證?", + "ConfigsPhpSuccess": "「%s」可以訪問", + "ConfigsPhpUnknown": "我們無法檢查「%s」是否能在網際網路或內部網路中訪問。", + "ConfigsPhpUnknownError": "請求的「%1$s」出現錯誤:%2$s。", + "CreateNewHeatmap": "建立新的熱度圖", + "CreateNewSessionRecording": "建立新的行為錄製", + "CreationDate": "建立日期", + "DeleteHeatmapConfirm": "你確定要刪除這個熱度圖?之前產生的熱度圖報表將不再顯示於報表 UI 中。", + "DeleteHeatmapScreenshotConfirm": "你確定要刪除這個截圖嗎?重新產生截圖可能需要花上一些時間。", + "DeleteRecordedPageview": "刪除這個網頁瀏覽紀錄(不會詢問確認)", + "DeleteRecordedSession": "刪除這個行為錄製(不會詢問確認)", + "DeleteScreenshot": "刪除截圖", + "DeleteSessionRecordingConfirm": "你確定要刪除這個行為錄製?之前產生的錄製將不再顯示於報表 UI 中。", + "DeleteX": "刪除%s", + "EditHeatmapX": "編輯熱度圖 %s", + "EditSessionRecordingX": "編輯行為錄製 %s", + "EditX": "編輯%s", + "EndHeatmapConfirm": "你確定要停止這個熱度圖捕捉新的活動?", + "EndSessionRecordingConfirm": "你確定要停止錄製新的行為?", + "ErrorArrayMissingKey": "陣列 %2$s 中位置 %3$s 的鍵「%1$s」遺失。", + "ErrorArrayMissingValue": "陣列 %2$s 中位置 %3$s 的鍵「%1$s」遺失值。", + "ErrorHeatmapDoesNotExist": "請求的熱度圖不存在", + "ErrorHeatmapNameDuplicate": "熱度圖的名稱已經被使用。", + "ErrorInnerIsNotAnArray": "「%2$s」中的每個「%1$s」必須是陣列。", + "ErrorInvalidRegExp": "正規表示式「%1$s」不含有效格式。", + "ErrorNotAnArray": "「%1$s」必須是陣列。", + "ErrorPageRuleRequired": "至少需要設定一條網頁規則。", + "ErrorSessionRecordingDoesNotExist": "請求的行為錄製不存在", + "ErrorXContainsWhitespace": "「%1$s」不允許包含任何空格。", + "ErrorXNotANumber": "「%1$s」必須是數字。", + "ErrorXNotProvided": "請輸入「%1$s」的值。", + "ErrorXNotWhitelisted": "「%1$s」的值不被允許,使用以下其一:%2$s。", + "ErrorXTooHigh": "「%1$s」過高,最大允許值為 %2$s。", + "ErrorXTooLong": "「%1$s」過長,最多允許 %2$s 個字元。", + "ErrorXTooLow": "「%1$s」過低,最小允許值為 %2$s。", + "ExcludedElements": "排除元素", + "ExcludedElementsHelp": "你可以選填定義 CSS 選擇器來排除不該在熱度圖預覽出現的特定元素。例如一個在網頁載入完成會出現的彈跳視窗。可以使用半形逗號來分隔多個選擇器。", + "FieldIncludedTargetsHelp": "目標讓你定義要在哪些網頁中紀錄使用者活動。你可以定義一個或多個條件。例如,你可以定義當網址或路徑等於某個特定值,並且只有當包含網址某個參數時才紀錄活動。活動只會在符合全部條件,而非符合某一個條件時才開始紀錄。所有的條件都不區分大小寫。當你選擇「簡易符合」時,網址的協議、搜尋參數和結尾斜線會被忽略。", + "FieldIncludedTargetsHelpSessions": "目標讓你設定只有在訪客到達指定網頁時才開始錄製行為。這可以讓你紀錄例如前往結帳前的過程。你可以定義一個或多個條件。例如,你可以定義當網址或路徑等於某個特定值,並且只有當包含網址某個參數時才錄製行為。行為只會在符合全部條件,而非符合某一個條件時才開始錄製。所有的條件都不區分大小寫。當你選擇「簡易符合」時,網址的協議、搜尋參數和結尾斜線會被忽略。", + "FieldNamePlaceholder": "例如「註冊網頁」", + "FilesystemDirectory": "資料夾", + "Filter": "過濾", + "GettingStarted": "開始使用", + "Heatmap": "熱度圖", + "HeatmapCreated": "已成功建立新的熱度圖。", + "HeatmapNameHelp": "定義這個熱度圖的報表要顯示的名稱。", + "HeatmapSampleLimit": "網頁瀏覽數", + "HeatmapSampleLimitHelp": "定義你想要錄製的網頁瀏覽總量。", + "HeatmapSampleRateHelp": "也被稱作「流量」。當你選擇 100%%,所有訪問了指定頁面的訪客都會被紀錄。當你選擇 10%%,只有每第 10 個訪客才會被紀錄。選擇的比率越低,達到選擇的採樣率時間會越長。", + "HeatmapUpdated": "熱度圖已成功更新。", + "HeatmapUsageBenefits": "熱度圖讓你紀錄你的使用者在特定網頁中的所有點擊、滑鼠移動和捲動紀錄。這將能幫助你知道使用者點擊了哪些不能點擊的東西、網頁中是否有幾乎不會被查看或互動的部分、你的訪客想尋找的東西和當使用者瀏覽你的網頁時能見度是多少等等。", + "HeatmapWidth": "熱度圖寬度", + "HeatmapX": "熱度圖 %s", + "HeatmapXRecordedSamplesSince": "自 %2$s 起已紀錄了 %1$s 個樣本。", + "Heatmaps": "熱度圖", + "Manage": "管理", + "ManageHeatmaps": "管理熱度圖", + "ManageSessionRecordings": "管理行為錄製", + "MinSessionTime": "最低行為時間", + "MinSessionTimeHelp": "只有在訪客至少在網頁中停留了指定時間才會開始紀錄行為。", + "NHeatmaps": "%s 個熱度圖", + "NSessionRecordings": "%s 個行為錄製", + "NoHeatmapSamplesRecordedYet": "這個熱度圖還沒有紀錄到任何網頁瀏覽。如果現在應該要有紀錄到東西,可能是這個熱度圖的網頁目標設定不符合你網站中的任何頁面。建議確認「管理中心」(僅限超級使用者)內的「系統檢查」來查看你的系統是否已設定自動追蹤熱度圖。", + "NoHeatmapScreenshotRecordedYet": "至目前為止已經錄製了 %1$s 個樣本。然而,還沒有擷取任何截圖。如果這個熱度圖有「%2$s」,可能會在第一個使用者首次開啟後一段時間才能顯示。根據採樣率可能會花上一些時間。", + "NoHeatmapsConfiguredInfo": "目前沒有啟用中的熱度圖。要查看熱度圖,請向權限管理員以上的使用者要求建立一個新的熱度圖。", + "NoHeatmapsFound": "找不到熱度圖", + "NoSessionRecordedYet": "還沒有錄製到任何行為。如果現在應該要有錄製到東西,可能是到達網頁的設定不符合你網站中的任何頁面。建議確認「管理中心」(僅限超級使用者)內的「系統檢查」來查看你的系統是否已設定自動錄製行為。", + "NoSessionRecordingsConfiguredInfo": "目前沒有啟用中的行為錄製。要錄製新的行為,請向權限管理員以上的使用者要求建立一個新的行為錄製。", + "NoSessionRecordingsFound": "找不到行為錄製", + "NotSupportedBrowser": "不支援這個瀏覽器,請使用較新的版本或常是其他瀏覽器。", + "OnePageview": "1 個瀏覽數", + "PageRule": "網頁規則", + "PageviewXofY": "瀏覽頁面 %1$s / %2$s", + "PageviewsInVisit": "這段錄製行為內紀錄到的網頁", + "PersonalInformationNote": "請注意%1$s可能紀錄到個人或敏感資訊。如果你想要隱藏網站或應用程式中特定的內容,你可以在這個內容的 HTML 標籤上加入 %2$sdata-matomo-mask%3$s 屬性。%4$s了解更多%5$s", + "PlayRecordedSession": "播放此錄製的行為", + "PlayerDurationXofY": "%1$s / %2$s", + "PlayerForwardFast": "快轉 %1$s 秒(快捷鍵 %2$s)", + "PlayerPageViewNext": "播放此訪客下一個瀏覽的頁面(%1$s)(快捷鍵 %2$s)", + "PlayerPageViewPrevious": "播放此訪客上一個瀏覽的頁面(%1$s)(快捷鍵 %2$s)", + "PlayerPause": "暫停(快捷鍵 %s 或空白鍵)", + "PlayerPlay": "播放(快捷鍵 %s 或空白鍵)", + "PlayerReplay": "重播(快捷鍵 %s 或空白鍵)", + "PlayerRewindFast": "倒帶 %1$s 秒(快捷鍵 %2$s)", + "RecordedHeatmapDocStatusActive": "這個熱度圖已啟用。最多會錄製 %1$d 個瀏覽數中的 %2$s。", + "RecordedHeatmapDocStatusEnded": "這個熱度圖已結束,因此不會再紀錄新的活動。", + "RecordedSessions": "已錄製的行為", + "RecordedSessionsDocStatusActive": "這個行為錄製已啟用。最多會錄製 %1$d 個行為中的 %2$s。", + "RecordedSessionsDocStatusEnded": "這個行為錄製已結束,因此不會再紀錄新的行為。", + "Recordings": "行為錄製", + "ReplayRecordedSession": "重播錄製的行為", + "ReplayX": "重播 %s", + "ReportRecordedSessionsDocumentation": "已錄製行為的列表。你可以重播任何錄製的行為。", + "RequiresActivity": "需要活動", + "RequiresActivityHelp": "若啟用,只有在包含捲動或點擊活動的瀏覽會被錄製行為。這將預防錄製到沒有太多活動的行為。", + "Rule": "規則", + "SampleLimit": "採樣限制", + "SampleRate": "取樣率", + "ScreenshotUrl": "截圖網址", + "ScreenshotUrlHelp": "預設狀態下,當訪客首次訪問目標網頁時會拍攝網頁截圖。如果有符合你的目標網頁的不同網址,你可以指定要用來擷取截圖的網址。可視化熱度圖只有在當至少有一個訪客訪問了這個網址並且紀錄了活動才可用。如果輸入了,網址必須完全相符。若要忽略協議,將網址以雙斜線開頭(//example.com)。請注意當截圖已擷取後,這個網址就無法變更。", + "SessionNameHelp": "定義這個行為錄製要顯示的名稱。", + "SessionRecording": "行為錄製", + "SessionRecordingCreated": "已成功建立新的行為錄製。", + "SessionRecordingUpdated": "行為錄製已成功更新。", + "SessionRecordingX": "行為錄製 %s", + "SessionRecordings": "行為錄製", + "SessionRecordingsUsageBenefits": "行為錄製讓你能紀錄一個真實的訪客在訪問過程中的所有活動,如點擊、滑鼠移動、捲動、視窗縮放、網頁變更和表單互動。接著你可以重播這些互動內容來觀察訪客是怎麼和你的網站互動的。這將讓你了解他們的期望、可能遇到的問題和使用模式等等。", + "SessionSampleLimit": "行為數量", + "SessionSampleLimitHelp": "定義你想要錄製的行為總量。", + "SessionSampleRateHelp": "也被稱作「流量」。當你選擇 100%%,所有訪問了目標網頁的行為都會被錄製。當你選擇 10%%,只有每第 10 個訪客才會被紀錄。選擇的比率越低,達到選擇的採樣率時間會越長。", + "StatusActive": "啟用中", + "StatusEnded": "已結束", + "StopX": "停止這個%s繼續紀錄新的活動。", + "TargetAttributePath": "路徑", + "TargetAttributeUrl": "網址", + "TargetAttributeUrlParameter": "網址參數", + "TargetAttributeUrlParameterExample": "nameOfUrlParameter", + "TargetPage": "目標網頁", + "TargetPageTestErrorInvalidUrl": "輸入包含協議的網址。", + "TargetPageTestLabel": "輸入包含協議的完整網址來檢查活動是否會在這個網址上進行紀錄:", + "TargetPageTestTitle": "網址驗證器", + "TargetPageTestUrlMatches": "這個網址上的活動會被紀錄", + "TargetPageTestUrlNotMatches": "這個網址上的活動不會被紀錄", + "TargetPages": "到達網頁", + "TargetTypeContains": "包含", + "TargetTypeEqualsExactly": "完全相符", + "TargetTypeEqualsExactlyInfo": "值必須完全相符,包含網址協議、搜尋參數和 hash。", + "TargetTypeEqualsSimple": "簡易相符", + "TargetTypeEqualsSimpleInfo": "這個網址將符合任何協議(例如 http 和 https)並忽略是否包含「www.」子域名。網址路徑內任何結尾斜線和搜尋參數以及 hash 部分都會被忽略。", + "TargetTypeExists": "存在", + "TargetTypeIsAny": "任何", + "TargetTypeIsNot": "不%s", + "TargetTypeRegExp": "符合正規表示式", + "TargetTypeRegExpInfo": "任何正規表示式,例如「^(.*)test(.*)$」。", + "TargetTypeStartsWith": "開始於", + "TimeOnSite": "停留時間", + "UpdatingData": "數據更新中…", + "UrlParameterValueToMatchPlaceholder": "符合網址參數名稱的值", + "UrlXDoesNotLookLikeUrl": "%s 看起來不像網址。請確定它包含了協議如 http:// 或起始於 //", + "ViewReport": "查看報表", + "ViewportResolution": "可見區域解析度(寬 x 高)", + "XSamples": "%s 個樣本", + "disable": "停用", + "enable": "啟用" + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/libs/MutationObserver.js/MutationObserver.js b/files/plugin-HeatmapSessionRecording-5.6.0/libs/MutationObserver.js/MutationObserver.js new file mode 100644 index 0000000..2393f42 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/libs/MutationObserver.js/MutationObserver.js @@ -0,0 +1,624 @@ +/*! + * Shim for MutationObserver interface + * Author: Graeme Yeates (github.com/megawac) + * Repository: https://github.com/megawac/MutationObserver.js + * License: WTFPL V2, 2004 (wtfpl.net). + * Though credit and staring the repo will make me feel pretty, you can modify and redistribute as you please. + * Attempts to follow spec (https://www.w3.org/TR/dom/#mutation-observers) as closely as possible for native javascript + * See https://github.com/WebKit/webkit/blob/master/Source/WebCore/dom/MutationObserver.cpp for current webkit source c++ implementation + */ + +/** + * prefix bugs: + - https://bugs.webkit.org/show_bug.cgi?id=85161 + - https://bugzilla.mozilla.org/show_bug.cgi?id=749920 + * Don't use WebKitMutationObserver as Safari (6.0.5-6.1) use a buggy implementation +*/ + +/* + * Modified by InnoCraft to also fallback to WebKitMutationObserver and MozMutationObserver + */ +window.MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver || (function(undefined) { + "use strict"; + /** + * @param {function(Array., MutationObserver)} listener + * @constructor + */ + function MutationObserver(listener) { + /** + * @type {Array.} + * @private + */ + this._watched = []; + /** @private */ + this._listener = listener; + } + + /** + * Start a recursive timeout function to check all items being observed for mutations + * @type {MutationObserver} observer + * @private + */ + function startMutationChecker(observer) { + (function check() { + var mutations = observer.takeRecords(); + + if (mutations.length) { // fire away + // calling the listener with context is not spec but currently consistent with FF and WebKit + observer._listener(mutations, observer); + } + /** @private */ + observer._timeout = setTimeout(check, MutationObserver._period); + })(); + } + + /** + * Period to check for mutations (~32 times/sec) + * @type {number} + * @expose + */ + MutationObserver._period = 30 /*ms+runtime*/ ; + + /** + * Exposed API + * @expose + * @final + */ + MutationObserver.prototype = { + /** + * see https://dom.spec.whatwg.org/#dom-mutationobserver-observe + * not going to throw here but going to follow the current spec config sets + * @param {Node|null} $target + * @param {Object|null} config : MutationObserverInit configuration dictionary + * @expose + * @return undefined + */ + observe: function($target, config) { + /** + * Using slightly different names so closure can go ham + * @type {!Object} : A custom mutation config + */ + var settings = { + attr: !! (config.attributes || config.attributeFilter || config.attributeOldValue), + + // some browsers enforce that subtree must be set with childList, attributes or characterData. + // We don't care as spec doesn't specify this rule. + kids: !! config.childList, + descendents: !! config.subtree, + charData: !! (config.characterData || config.characterDataOldValue) + }; + + var watched = this._watched; + + // remove already observed target element from pool + for (var i = 0; i < watched.length; i++) { + if (watched[i].tar === $target) watched.splice(i, 1); + } + + if (config.attributeFilter) { + /** + * converts to a {key: true} dict for faster lookup + * @type {Object.} + */ + settings.afilter = reduce(config.attributeFilter, function(a, b) { + a[b] = true; + return a; + }, {}); + } + + watched.push({ + tar: $target, + fn: createMutationSearcher($target, settings) + }); + + // reconnect if not connected + if (!this._timeout) { + startMutationChecker(this); + } + }, + + /** + * Finds mutations since last check and empties the "record queue" i.e. mutations will only be found once + * @expose + * @return {Array.} + */ + takeRecords: function() { + var mutations = []; + var watched = this._watched; + + for (var i = 0; i < watched.length; i++) { + watched[i].fn(mutations); + } + + return mutations; + }, + + /** + * @expose + * @return undefined + */ + disconnect: function() { + this._watched = []; // clear the stuff being observed + clearTimeout(this._timeout); // ready for garbage collection + /** @private */ + this._timeout = null; + } + }; + + /** + * Simple MutationRecord pseudoclass. No longer exposing as its not fully compliant + * @param {Object} data + * @return {Object} a MutationRecord + */ + function MutationRecord(data) { + var settings = { // technically these should be on proto so hasOwnProperty will return false for non explicitly props + type: null, + target: null, + addedNodes: [], + removedNodes: [], + previousSibling: null, + nextSibling: null, + attributeName: null, + attributeNamespace: null, + oldValue: null + }; + for (var prop in data) { + if (has(settings, prop) && data[prop] !== undefined) settings[prop] = data[prop]; + } + return settings; + } + + /** + * Creates a func to find all the mutations + * + * @param {Node} $target + * @param {!Object} config : A custom mutation config + */ + function createMutationSearcher($target, config) { + /** type {Elestuct} */ + var $oldstate = clone($target, config); // create the cloned datastructure + + /** + * consumes array of mutations we can push to + * + * @param {Array.} mutations + */ + return function(mutations) { + var olen = mutations.length, dirty; + + if (config.charData && $target.nodeType === 3 && $target.nodeValue !== $oldstate.charData) { + mutations.push(new MutationRecord({ + type: "characterData", + target: $target, + oldValue: $oldstate.charData + })); + } + + // Alright we check base level changes in attributes... easy + if (config.attr && $oldstate.attr) { + findAttributeMutations(mutations, $target, $oldstate.attr, config.afilter); + } + + // check childlist or subtree for mutations + if (config.kids || config.descendents) { + dirty = searchSubtree(mutations, $target, $oldstate, config); + } + + // reclone data structure if theres changes + if (dirty || mutations.length !== olen) { + /** type {Elestuct} */ + $oldstate = clone($target, config); + } + }; + } + + /* attributes + attributeFilter helpers */ + + // Check if the environment has the attribute bug (#4) which cause + // element.attributes.style to always be null. + var hasAttributeBug = document.createElement("i"); + hasAttributeBug.style.top = 0; + hasAttributeBug = hasAttributeBug.attributes.style.value != "null"; + + /** + * Gets an attribute value in an environment without attribute bug + * + * @param {Node} el + * @param {Attr} attr + * @return {String} an attribute value + */ + function getAttributeSimple(el, attr) { + // There is a potential for a warning to occur here if the attribute is a + // custom attribute in IE<9 with a custom .toString() method. This is + // just a warning and doesn't affect execution (see #21) + return attr.value; + } + + /** + * Gets an attribute value with special hack for style attribute (see #4) + * + * @param {Node} el + * @param {Attr} attr + * @return {String} an attribute value + */ + function getAttributeWithStyleHack(el, attr) { + // As with getAttributeSimple there is a potential warning for custom attribtues in IE7. + return attr.name !== "style" ? attr.value : el.style.cssText; + } + + var getAttributeValue = hasAttributeBug ? getAttributeSimple : getAttributeWithStyleHack; + + /** + * fast helper to check to see if attributes object of an element has changed + * doesnt handle the textnode case + * + * @param {Array.} mutations + * @param {Node} $target + * @param {Object.} $oldstate : Custom attribute clone data structure from clone + * @param {Object} filter + */ + function findAttributeMutations(mutations, $target, $oldstate, filter) { + var checked = {}; + var attributes = $target.attributes; + var attr; + var name; + var i = attributes.length; + while (i--) { + attr = attributes[i]; + name = attr.name; + if (!filter || has(filter, name)) { + if (getAttributeValue($target, attr) !== $oldstate[name]) { + // The pushing is redundant but gzips very nicely + mutations.push(MutationRecord({ + type: "attributes", + target: $target, + attributeName: name, + oldValue: $oldstate[name], + attributeNamespace: attr.namespaceURI // in ie<8 it incorrectly will return undefined + })); + } + checked[name] = true; + } + } + for (name in $oldstate) { + if (!(checked[name])) { + mutations.push(MutationRecord({ + target: $target, + type: "attributes", + attributeName: name, + oldValue: $oldstate[name] + })); + } + } + } + + /** + * searchSubtree: array of mutations so far, element, element clone, bool + * synchronous dfs comparision of two nodes + * This function is applied to any observed element with childList or subtree specified + * Sorry this is kind of confusing as shit, tried to comment it a bit... + * codereview.stackexchange.com/questions/38351 discussion of an earlier version of this func + * + * @param {Array} mutations + * @param {Node} $target + * @param {!Object} $oldstate : A custom cloned node from clone() + * @param {!Object} config : A custom mutation config + */ + function searchSubtree(mutations, $target, $oldstate, config) { + // Track if the tree is dirty and has to be recomputed (#14). + var dirty; + /* + * Helper to identify node rearrangment and stuff... + * There is no gaurentee that the same node will be identified for both added and removed nodes + * if the positions have been shuffled. + * conflicts array will be emptied by end of operation + */ + function resolveConflicts(conflicts, node, $kids, $oldkids, numAddedNodes) { + // the distance between the first conflicting node and the last + var distance = conflicts.length - 1; + // prevents same conflict being resolved twice consider when two nodes switch places. + // only one should be given a mutation event (note -~ is used as a math.ceil shorthand) + var counter = -~((distance - numAddedNodes) / 2); + var $cur; + var oldstruct; + var conflict; + while ((conflict = conflicts.pop())) { + $cur = $kids[conflict.i]; + oldstruct = $oldkids[conflict.j]; + + // attempt to determine if there was node rearrangement... won't gaurentee all matches + // also handles case where added/removed nodes cause nodes to be identified as conflicts + if (config.kids && counter && Math.abs(conflict.i - conflict.j) >= distance) { + mutations.push(MutationRecord({ + type: "childList", + target: node, + addedNodes: [$cur], + removedNodes: [$cur], + // haha don't rely on this please + nextSibling: $cur.nextSibling, + previousSibling: $cur.previousSibling + })); + counter--; // found conflict + } + + // Alright we found the resorted nodes now check for other types of mutations + if (config.attr && oldstruct.attr) findAttributeMutations(mutations, $cur, oldstruct.attr, config.afilter); + if (config.charData && $cur.nodeType === 3 && $cur.nodeValue !== oldstruct.charData) { + mutations.push(MutationRecord({ + type: "characterData", + target: $cur, + oldValue: oldstruct.charData + })); + } + // now look @ subtree + if (config.descendents) findMutations($cur, oldstruct); + } + } + + /** + * Main worker. Finds and adds mutations if there are any + * @param {Node} node + * @param {!Object} old : A cloned data structure using internal clone + */ + function findMutations(node, old) { + var $kids = node.childNodes; + var $oldkids = old.kids; + var klen = $kids.length; + // $oldkids will be undefined for text and comment nodes + var olen = $oldkids ? $oldkids.length : 0; + // if (!olen && !klen) return; // both empty; clearly no changes + + // we delay the intialization of these for marginal performance in the expected case (actually quite signficant on large subtrees when these would be otherwise unused) + // map of checked element of ids to prevent registering the same conflict twice + var map; + // array of potential conflicts (ie nodes that may have been re arranged) + var conflicts; + var id; // element id from getElementId helper + var idx; // index of a moved or inserted element + + var oldstruct; + // current and old nodes + var $cur; + var $old; + // track the number of added nodes so we can resolve conflicts more accurately + var numAddedNodes = 0; + + // iterate over both old and current child nodes at the same time + var i = 0, j = 0; + // while there is still anything left in $kids or $oldkids (same as i < $kids.length || j < $oldkids.length;) + while( i < klen || j < olen ) { + // current and old nodes at the indexs + $cur = $kids[i]; + oldstruct = $oldkids[j]; + $old = oldstruct && oldstruct.node; + + if ($cur === $old) { // expected case - optimized for this case + // check attributes as specified by config + if (config.attr && oldstruct.attr) /* oldstruct.attr instead of textnode check */findAttributeMutations(mutations, $cur, oldstruct.attr, config.afilter); + // check character data if node is a comment or textNode and it's being observed + if (config.charData && oldstruct.charData !== undefined && $cur.nodeValue !== oldstruct.charData) { + mutations.push(MutationRecord({ + type: "characterData", + target: $cur, + oldValue: oldstruct.charData + })); + } + + // resolve conflicts; it will be undefined if there are no conflicts - otherwise an array + if (conflicts) resolveConflicts(conflicts, node, $kids, $oldkids, numAddedNodes); + + // recurse on next level of children. Avoids the recursive call when there are no children left to iterate + if (config.descendents && ($cur.childNodes.length || oldstruct.kids && oldstruct.kids.length)) findMutations($cur, oldstruct); + + i++; + j++; + } else { // (uncommon case) lookahead until they are the same again or the end of children + dirty = true; + if (!map) { // delayed initalization (big perf benefit) + map = {}; + conflicts = []; + } + if ($cur) { + // check id is in the location map otherwise do a indexOf search + if (!(map[id = getElementId($cur)])) { // to prevent double checking + // mark id as found + map[id] = true; + // custom indexOf using comparitor checking oldkids[i].node === $cur + if ((idx = indexOfCustomNode($oldkids, $cur, j)) === -1) { + if (config.kids) { + mutations.push(MutationRecord({ + type: "childList", + target: node, + addedNodes: [$cur], // $cur is a new node + nextSibling: $cur.nextSibling, + previousSibling: $cur.previousSibling + })); + numAddedNodes++; + } + } else { + conflicts.push({ // add conflict + i: i, + j: idx + }); + } + } + i++; + } + + if ($old && + // special case: the changes may have been resolved: i and j appear congurent so we can continue using the expected case + $old !== $kids[i] + ) { + if (!(map[id = getElementId($old)])) { + map[id] = true; + if ((idx = indexOf($kids, $old, i)) === -1) { + if (config.kids) { + mutations.push(MutationRecord({ + type: "childList", + target: old.node, + removedNodes: [$old], + nextSibling: $oldkids[j + 1], // praise no indexoutofbounds exception + previousSibling: $oldkids[j - 1] + })); + numAddedNodes--; + } + } else { + conflicts.push({ + i: idx, + j: j + }); + } + } + j++; + } + }// end uncommon case + }// end loop + + // resolve any remaining conflicts + if (conflicts) resolveConflicts(conflicts, node, $kids, $oldkids, numAddedNodes); + } + findMutations($target, $oldstate); + return dirty; + } + + /** + * Utility + * Cones a element into a custom data structure designed for comparision. https://gist.github.com/megawac/8201012 + * + * @param {Node} $target + * @param {!Object} config : A custom mutation config + * @return {!Object} : Cloned data structure + */ + function clone($target, config) { + var recurse = true; // set true so childList we'll always check the first level + return (function copy($target) { + var elestruct = { + /** @type {Node} */ + node: $target + }; + + // Store current character data of target text or comment node if the config requests + // those properties to be observed. + if (config.charData && ($target.nodeType === 3 || $target.nodeType === 8)) { + elestruct.charData = $target.nodeValue; + } + // its either a element, comment, doc frag or document node + else { + // Add attr only if subtree is specified or top level and avoid if + // attributes is a document object (#13). + if (config.attr && recurse && $target.nodeType === 1) { + /** + * clone live attribute list to an object structure {name: val} + * @type {Object.} + */ + elestruct.attr = reduce($target.attributes, function(memo, attr) { + if (!config.afilter || config.afilter[attr.name]) { + memo[attr.name] = getAttributeValue($target, attr); + } + return memo; + }, {}); + } + + // whether we should iterate the children of $target node + if (recurse && ((config.kids || config.charData) || (config.attr && config.descendents)) ) { + /** @type {Array.} : Array of custom clone */ + elestruct.kids = map($target.childNodes, copy); + } + + recurse = config.descendents; + } + return elestruct; + })($target); + } + + /** + * indexOf an element in a collection of custom nodes + * + * @param {NodeList} set + * @param {!Object} $node : A custom cloned node + * @param {number} idx : index to start the loop + * @return {number} + */ + function indexOfCustomNode(set, $node, idx) { + return indexOf(set, $node, idx, JSCompiler_renameProperty("node")); + } + + // using a non id (eg outerHTML or nodeValue) is extremely naive and will run into issues with nodes that may appear the same like
  • + var counter = 1; // don't use 0 as id (falsy) + /** @const */ + var expando = "mo_id"; + + /** + * Attempt to uniquely id an element for hashing. We could optimize this for legacy browsers but it hopefully wont be called enough to be a concern + * + * @param {Node} $ele + * @return {(string|number)} + */ + function getElementId($ele) { + try { + return $ele.id || ($ele[expando] = $ele[expando] || counter++); + } catch (o_O) { // ie <8 will throw if you set an unknown property on a text node + try { + return $ele.nodeValue; // naive + } catch (shitie) { // when text node is removed: https://gist.github.com/megawac/8355978 :( + return counter++; + } + } + } + + /** + * **map** Apply a mapping function to each item of a set + * @param {Array|NodeList} set + * @param {Function} iterator + */ + function map(set, iterator) { + var results = []; + for (var index = 0; index < set.length; index++) { + results[index] = iterator(set[index], index, set); + } + return results; + } + + /** + * **Reduce** builds up a single result from a list of values + * @param {Array|NodeList|NamedNodeMap} set + * @param {Function} iterator + * @param {*} [memo] Initial value of the memo. + */ + function reduce(set, iterator, memo) { + for (var index = 0; index < set.length; index++) { + memo = iterator(memo, set[index], index, set); + } + return memo; + } + + /** + * **indexOf** find index of item in collection. + * @param {Array|NodeList} set + * @param {Object} item + * @param {number} idx + * @param {string} [prop] Property on set item to compare to item + */ + function indexOf(set, item, idx, prop) { + for (/*idx = ~~idx*/; idx < set.length; idx++) {// start idx is always given as this is internal + if ((prop ? set[idx][prop] : set[idx]) === item) return idx; + } + return -1; + } + + /** + * @param {Object} obj + * @param {(string|number)} prop + * @return {boolean} + */ + function has(obj, prop) { + return obj[prop] !== undefined; // will be nicely inlined by gcc + } + + // GCC hack see https://stackoverflow.com/a/23202438/1517919 + function JSCompiler_renameProperty(a) { + return a; + } + + return MutationObserver; +})(void 0); diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/libs/MutationObserver.js/README.md b/files/plugin-HeatmapSessionRecording-5.6.0/libs/MutationObserver.js/README.md new file mode 100644 index 0000000..a2112ad --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/libs/MutationObserver.js/README.md @@ -0,0 +1,68 @@ +MutationObserver +======================== + +[![Browser Test Status](https://saucelabs.com/browser-matrix/mutationobserver.svg)](https://saucelabs.com/u/mutationobserver) +Note: the svg swapped the working browsers; IE8 works while IE7 fails 1 test + +A polyfill for the [MutationObserver API](http://www.w3.org/TR/2013/WD-dom-20131107/#mutation-observers) ([can I use?](http://caniuse.com/mutationobserver)). The polyfill is more cause we can than should (with subtree at any rate)... It's async and uses a recursive timeout fallback (default checks changes every 30ms + runtime) instead of using the deprecated [DOM3 MutationEvents](http://www.w3.org/TR/DOM-Level-3-Events/#events-mutationevents) so theoretically can support virtually any environment. + +```sh +$ npm install mutationobserver-shim +$ bower install MutationObserver-shim +``` + +```html + +``` + +### Polyfill differences from standard interface + +#### MutationObserver + +* Implemented using a recursive `setTimeout` (every ~30 ms) rather than using a `setImmediate` polyfill; so calls will be made less frequently and likely with more data than the standard MutationObserver. In addition, it can miss changes that occur and then are lost in the interval window. +* Setting an observed elements html using `innerHTML` will call `childList` observer listeners with several mutations with only 1 addedNode or removed node per mutation. With the standard you would have 1 call with multiple nodes in addedNodes and removedNodes node lists. +* With `childList` and `subtree` changes in node order (eg first element gets swapped with last) should fire a `addedNode` and `removedNode` mutation but the correct node may not always be identified. + +#### MutationRecord + +* `addedNodes` and `removedNodes` are arrays instead of `NodeList`s +* `oldValue` is always called with attribute changes +* `nextSibling` and `previousSibling` correctfullness is questionable (hard to know if the order of appended items). I'd suggest not relying on them anyway (my tests are extremely permissive with these attributes) + +### Supported MutationObserverInit properties + +Currently supports the following [MutationObserverInit properties](https://developer.mozilla.org/en/docs/Web/API/MutationObserver#MutationObserverInit): + +* **childList**: Set to truthy if mutations to target's immediate children are to be observed. +* **subtree**: Set to truthy to do deep scans on a target's children. +* **attributes**: Set to truthy if mutations to target's children are to be observed. As explained in #4, the `style` attribute may not be matched in ie<8. +* **attributeFilter**: Set to an array of attribute local names (without namespace) if not all attribute mutations need to be observed. +* **attributeOldValue**: doesn't do anything attributes are always called with old value +* **characterData**: currently follows Mozilla's implementation in that it will only watch `textNodes` values and not, like in webkit, where setting .innerHTML will add a characterData mutation. + +### Performance + +By default, the polyfill will check observed nodes about 25 times per second (30 ms interval) for mutations. Try running [these jsperf.com tests](http://jsperf.com/mutationobserver-shim) and the JSLitmus tests in the test suite for usage performance tests. It may be worthwile to adapt `MutationObserver._period` based on UA or heuristics (todo). + +From my tests observing any size element without `subtree` enabled is relatively cheap. Although I've optimized the subtree check to the best of my abilities it can be costly on large trees. You can draw your own conclusions based on the JSLitmus and jsperf tests noting that you can expect the `mo` to do its check 28+ times a second (by default). + +Although supported, I'd recommend against watching `attributes` on the `subtree` on large structures, as the check is complex and expensive on terrible hardware like my phone :( + +The included minified file has been tuned for performance. + +### Compatibility + +I've tested and verified compatibility in the following browsers + [these Sauce browsers](https://saucelabs.com/u/mutationobserver) + +* Internet Explorer 8 (emulated), 9, 10 in win7 and win8 +* Firefox 4, 21, 24, 26 in OSX, win7 and win8 +* Opera 11.8, 12.16 in win7 +* "Internet" on Android HTC One V +* Blackberry 6.0.16 + +Try [running the test suite](https://rawgithub.com/megawac/MutationObserver.js/master/test/index.html) and see some simple example usage: + +* http://jsbin.com/suqewogone listen to images being appended dynamically +* http://jsbin.com/bapohopuwi autoscroll an element as new content is added + +See http://dev.opera.com/articles/view/mutation-observers-tutorial/ for some sample usage. diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/libs/MutationObserver.js/dist/README.md b/files/plugin-HeatmapSessionRecording-5.6.0/libs/MutationObserver.js/dist/README.md new file mode 100644 index 0000000..a3e83b2 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/libs/MutationObserver.js/dist/README.md @@ -0,0 +1,7 @@ +###Compiled files + +*Compiled by Google closure compiler in `ADVANCED_OPTIMIZATIONS`* + +- Original: 25 kB +- Minified: 3.7 kB +- Gzipped: 1.6 kB diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/libs/MutationObserver.js/dist/mutationobserver.min.js b/files/plugin-HeatmapSessionRecording-5.6.0/libs/MutationObserver.js/dist/mutationobserver.min.js new file mode 100644 index 0000000..94e8949 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/libs/MutationObserver.js/dist/mutationobserver.min.js @@ -0,0 +1,10 @@ +// mutationobserver-shim v0.3.2 (github.com/megawac/MutationObserver.js) +// Authors: Graeme Yeates (github.com/megawac) +window.MutationObserver=window.MutationObserver||function(w){function v(a){this.i=[];this.m=a}function I(a){(function c(){var d=a.takeRecords();d.length&&a.m(d,a);a.h=setTimeout(c,v._period)})()}function p(a){var b={type:null,target:null,addedNodes:[],removedNodes:[],previousSibling:null,nextSibling:null,attributeName:null,attributeNamespace:null,oldValue:null},c;for(c in a)b[c]!==w&&a[c]!==w&&(b[c]=a[c]);return b}function J(a,b){var c=C(a,b);return function(d){var f=d.length,n;b.a&&3===a.nodeType&& +a.nodeValue!==c.a&&d.push(new p({type:"characterData",target:a,oldValue:c.a}));b.b&&c.b&&A(d,a,c.b,b.f);if(b.c||b.g)n=K(d,a,c,b);if(n||d.length!==f)c=C(a,b)}}function L(a,b){return b.value}function M(a,b){return"style"!==b.name?b.value:a.style.cssText}function A(a,b,c,d){for(var f={},n=b.attributes,k,g,x=n.length;x--;)k=n[x],g=k.name,d&&d[g]===w||(D(b,k)!==c[g]&&a.push(p({type:"attributes",target:b,attributeName:g,oldValue:c[g],attributeNamespace:k.namespaceURI})),f[g]=!0);for(g in c)f[g]||a.push(p({target:b, +type:"attributes",attributeName:g,oldValue:c[g]}))}function K(a,b,c,d){function f(b,c,f,k,y){var g=b.length-1;y=-~((g-y)/2);for(var h,l,e;e=b.pop();)h=f[e.j],l=k[e.l],d.c&&y&&Math.abs(e.j-e.l)>=g&&(a.push(p({type:"childList",target:c,addedNodes:[h],removedNodes:[h],nextSibling:h.nextSibling,previousSibling:h.previousSibling})),y--),d.b&&l.b&&A(a,h,l.b,d.f),d.a&&3===h.nodeType&&h.nodeValue!==l.a&&a.push(p({type:"characterData",target:h,oldValue:l.a})),d.g&&n(h,l)}function n(b,c){for(var g=b.childNodes, +q=c.c,x=g.length,v=q?q.length:0,h,l,e,m,t,z=0,u=0,r=0;u + +This work is free. You can redistribute it and/or modify it under the +terms of the Do What The Fuck You Want To Public License, Version 2, +as published by Sam Hocevar. See http://www.wtfpl.net/ for more details. + +This program is free software. It comes without any warranty, to +the extent permitted by applicable law. You can redistribute it +and/or modify it under the terms of the Do What The Fuck You Want +To Public License, Version 2, as published by Sam Hocevar. See +http://www.wtfpl.net/ for more details. \ No newline at end of file diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/libs/MutationObserver.js/package.json b/files/plugin-HeatmapSessionRecording-5.6.0/libs/MutationObserver.js/package.json new file mode 100644 index 0000000..f99c9bc --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/libs/MutationObserver.js/package.json @@ -0,0 +1,49 @@ +{ + "name": "mutationobserver-shim", + "short name": "mutationobserver", + "description": "MutationObserver shim for ES3 environments", + "version": "0.3.2", + "keywords": [ + "DOM", + "observer", + "mutation observer", + "MutationObserver" + ], + "authors": [ + { + "name": "Graeme Yeates", + "email": "github.com/megawac" + } + ], + "repository": { + "type": "git", + "git": "git@github.com:megawac/MutationObserver.js.git", + "url": "github.com/megawac/MutationObserver.js" + }, + "main": "dist/mutationobserver.min.js", + "scripts": { + "test": "grunt test --verbose" + }, + "files": [ + "MutationObserver.js", + "dist/mutationobserver.min.js" + ], + "license": { + "type": "WTFPL", + "version": "v2 2004", + "url": "http://www.wtfpl.net/" + }, + "devDependencies": { + "grunt": "~0.4.2", + "grunt-bumpup": "~0.5.0", + "grunt-closurecompiler": "0.9", + "grunt-contrib-connect": "0.7", + "grunt-contrib-jshint": ">= 0.8", + "grunt-contrib-qunit": "~0.5.0", + "grunt-file-info": "~1.0.14", + "grunt-saucelabs": "~4.1.2", + "grunt-tagrelease": "~0.3.1", + "matchdep": "~0.3.0", + "phantomjs": "1.9.x" + } +} \ No newline at end of file diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/libs/mutation-summary/COPYING b/files/plugin-HeatmapSessionRecording-5.6.0/libs/mutation-summary/COPYING new file mode 100644 index 0000000..65ee1c1 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/libs/mutation-summary/COPYING @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/libs/mutation-summary/README.md b/files/plugin-HeatmapSessionRecording-5.6.0/libs/mutation-summary/README.md new file mode 100644 index 0000000..5eac04f --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/libs/mutation-summary/README.md @@ -0,0 +1,59 @@ +# What is this? # + +Mutation Summary is a JavaScript library that makes observing changes to the DOM fast, easy and safe. + +It's built on top of (and requires) a new browser API called [DOM Mutation Observers](http://dom.spec.whatwg.org/#mutation-observers). + + * [Browsers which currently implement DOM Mutation Observers](DOMMutationObservers.md#browser-availability). + * [DOM Mutation Observers API and its relationship to this library and (the deprecated) DOM Mutation Events](DOMMutationObservers.md). + + + +# Why do I need it? # + +Mutation Summary does five main things for you: + + * **It tells you how the document is different now from how it was.** As its name suggests, it summarizes what’s happened. It’s as if it takes a picture of the document when you first create it, and then again after each time it calls you back. When things have changed, it calls you with a concise description of exactly what’s different now from the last picture it took for you. + * **It handles any and all changes, no matter how complex.** All kinds of things can happen to the DOM: values can change and but put back to what they were, large parts can be pulled out, changed, rearranged, put back. Mutation Summary can take any crazy thing you throw at it. Go ahead, tear the document to shreds, Mutation Summary won’t even blink. + * **It lets you express what kinds of things you’re interested in.** It presents a query API that lets you tell it exactly what kinds of changes you’re interested in. This includes support for simple CSS-like selector descriptions of elements you care about. + * **It’s fast.** The time and memory it takes is dependant on number of changes that occurred (which typically involves only a few nodes) -- not the size of your document (which is commonly thousands of nodes). + * **It can automatically ignore changes you make during your callback.** Mutation Summary is going to call you back when changes have occurred. If you need to react to those changes by making more changes -- won’t you hear about those changes the next time it calls you back? Not unless you [ask for that](APIReference.md#configuration-options). By default, it stops watching the document immediately before it calls you back and resumes watching as soon as your callback finishes. + +# What is it useful for? # + +Lots of things, here are some examples: + + * **Browser extensions.** Want to make a browser extension that creates a link to your mapping application whenever an address appears in a page? You’ll need to know when those addresses appear (and disappear). + * **Implement missing HTML capabilities.** Think building web apps is too darn hard and you know what’s missing from HTML that would make it a snap? Writing the code for the desired behavior is only half the battle--you’ll also need to know when those elements and attributes show up and what happens to them. In fact, there’s already two widely used classes of libraries which do exactly this, but don’t currently have a good way to observe changes to the DOM. + * **UI Widget** libraries, e.g. Dojo Widgets + * **Templating** and/or **Databinding** libraries, e.g. Angular or KnockoutJS + * **Text Editors.** HTML Text editors often want to observe what’s being input and “fix it up” so that they can maintain a consistent WYSWIG UI. + +# What is this _not_ useful for? # + +The intent here isn't to be all things to all use-cases. Mutation Summary is not meant to: + + * **Use the DOM as some sort of state-transition machine.** It won't report transient states that the DOM moved through. It will only tell you what the difference is between the previous state and the present one. + * **Observing complex selectors.** It offers support for a simple [subset of CSS selectors](APIReference.md#supported-selector-syntax). Want to observe all elements that match `“div[foo] span.bar > p:first-child”`? Unfortunately, efficiently computing that is much harder and currently outside the scope of this library. + +Note that both of the above use cases are possible given the data that the underlying Mutation Observers API provides -- we simply judged them to be outside the "80% use case" that we targeted with this particular library. + +# Where can Mutation Summary be used? # + +The Mutation Summary library depends on the presence of the Mutation Observer DOM API. Mutation Observers are available in + + * [Google Chrome](https://www.google.com/chrome) + * [Firefox](http://www.mozilla.org/en-US/firefox/new/) + * [Safari](http://www.apple.com/safari/) + * [Opera](http://www.opera.com/) + * [IE11](http://www.microsoft.com/ie) + +Mutation Observers is the work of the [W3C WebApps working group](http://www.w3.org/2008/webapps/). In the future it will be implemented in other browsers (we’ll keep the above list of supporting browsers as up-to-date as possible). + +# Great. I want to get started. What’s next? # + + * Check out the [tutorial](Tutorial.md) and the [API reference](APIReference.md). + +# Google groups discussion list # + + * [mutation-summary-discuss@googlegroups.com](https://groups.google.com/group/mutation-summary-discuss) diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/libs/mutation-summary/package.json b/files/plugin-HeatmapSessionRecording-5.6.0/libs/mutation-summary/package.json new file mode 100644 index 0000000..3029ee6 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/libs/mutation-summary/package.json @@ -0,0 +1,30 @@ +{ + "name": "mutation-summary", + "version": "0.0.0", + "description": "Makes observing the DOM fast and easy", + "main": "src/mutation-summary.js", + "directories": { + "example": "examples", + "test": "tests" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "https://code.google.com/p/mutation-summary/" + }, + "author": "", + "license": "Apache 2.0", + "devDependencies": { + "chai": "*", + "mocha": "*" + } +} + + + + + + + diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/libs/mutation-summary/src/mutation-summary.js b/files/plugin-HeatmapSessionRecording-5.6.0/libs/mutation-summary/src/mutation-summary.js new file mode 100644 index 0000000..feef885 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/libs/mutation-summary/src/mutation-summary.js @@ -0,0 +1,1406 @@ +// Copyright 2011 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +var __extends = (this && this.__extends) || (function () { + var extendStatics = function (d, b) { + extendStatics = Object.setPrototypeOf || + ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || + function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; }; + return extendStatics(d, b); + }; + return function (d, b) { + if (typeof b !== "function" && b !== null) + throw new TypeError("Class extends value " + String(b) + " is not a constructor or null"); + extendStatics(d, b); + function __() { this.constructor = d; } + d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); + }; +})(); +var MutationObserverCtor; +if (typeof WebKitMutationObserver !== 'undefined') + MutationObserverCtor = WebKitMutationObserver; +else + MutationObserverCtor = MutationObserver; +if (MutationObserverCtor === undefined) { + console.error('DOM Mutation Observers are required.'); + console.error('https://developer.mozilla.org/en-US/docs/DOM/MutationObserver'); + throw Error('DOM Mutation Observers are required'); +} +var NodeMap = /** @class */ (function () { + function NodeMap() { + this.nodes = []; + this.values = []; + } + NodeMap.prototype.isIndex = function (s) { + return +s === s >>> 0; + }; + NodeMap.prototype.nodeId = function (node) { + var id = node[NodeMap.ID_PROP]; + if (!id) + id = node[NodeMap.ID_PROP] = NodeMap.nextId_++; + return id; + }; + NodeMap.prototype.set = function (node, value) { + var id = this.nodeId(node); + this.nodes[id] = node; + this.values[id] = value; + }; + NodeMap.prototype.get = function (node) { + var id = this.nodeId(node); + return this.values[id]; + }; + NodeMap.prototype.has = function (node) { + return this.nodeId(node) in this.nodes; + }; + NodeMap.prototype["delete"] = function (node) { + var id = this.nodeId(node); + delete this.nodes[id]; + this.values[id] = undefined; + }; + NodeMap.prototype.keys = function () { + var nodes = []; + for (var id in this.nodes) { + if (!this.isIndex(id)) + continue; + nodes.push(this.nodes[id]); + } + return nodes; + }; + NodeMap.ID_PROP = '__mutation_summary_node_map_id__'; + NodeMap.nextId_ = 1; + return NodeMap; +}()); +/** + * var reachableMatchableProduct = [ + * // STAYED_OUT, ENTERED, STAYED_IN, EXITED + * [ STAYED_OUT, STAYED_OUT, STAYED_OUT, STAYED_OUT ], // STAYED_OUT + * [ STAYED_OUT, ENTERED, ENTERED, STAYED_OUT ], // ENTERED + * [ STAYED_OUT, ENTERED, STAYED_IN, EXITED ], // STAYED_IN + * [ STAYED_OUT, STAYED_OUT, EXITED, EXITED ] // EXITED + * ]; + */ +var Movement; +(function (Movement) { + Movement[Movement["STAYED_OUT"] = 0] = "STAYED_OUT"; + Movement[Movement["ENTERED"] = 1] = "ENTERED"; + Movement[Movement["STAYED_IN"] = 2] = "STAYED_IN"; + Movement[Movement["REPARENTED"] = 3] = "REPARENTED"; + Movement[Movement["REORDERED"] = 4] = "REORDERED"; + Movement[Movement["EXITED"] = 5] = "EXITED"; +})(Movement || (Movement = {})); +function enteredOrExited(changeType) { + return changeType === Movement.ENTERED || changeType === Movement.EXITED; +} +var NodeChange = /** @class */ (function () { + function NodeChange(node, childList, attributes, characterData, oldParentNode, added, attributeOldValues, characterDataOldValue) { + if (childList === void 0) { childList = false; } + if (attributes === void 0) { attributes = false; } + if (characterData === void 0) { characterData = false; } + if (oldParentNode === void 0) { oldParentNode = null; } + if (added === void 0) { added = false; } + if (attributeOldValues === void 0) { attributeOldValues = null; } + if (characterDataOldValue === void 0) { characterDataOldValue = null; } + this.node = node; + this.childList = childList; + this.attributes = attributes; + this.characterData = characterData; + this.oldParentNode = oldParentNode; + this.added = added; + this.attributeOldValues = attributeOldValues; + this.characterDataOldValue = characterDataOldValue; + this.isCaseInsensitive = + this.node.nodeType === Node.ELEMENT_NODE && + this.node instanceof HTMLElement && + this.node.ownerDocument instanceof HTMLDocument; + } + NodeChange.prototype.getAttributeOldValue = function (name) { + if (!this.attributeOldValues) + return undefined; + if (this.isCaseInsensitive) + name = name.toLowerCase(); + return this.attributeOldValues[name]; + }; + NodeChange.prototype.getAttributeNamesMutated = function () { + var names = []; + if (!this.attributeOldValues) + return names; + for (var name in this.attributeOldValues) { + names.push(name); + } + return names; + }; + NodeChange.prototype.attributeMutated = function (name, oldValue) { + this.attributes = true; + this.attributeOldValues = this.attributeOldValues || {}; + if (name in this.attributeOldValues) + return; + this.attributeOldValues[name] = oldValue; + }; + NodeChange.prototype.characterDataMutated = function (oldValue) { + if (this.characterData) + return; + this.characterData = true; + this.characterDataOldValue = oldValue; + }; + // Note: is it possible to receive a removal followed by a removal. This + // can occur if the removed node is added to an non-observed node, that + // node is added to the observed area, and then the node removed from + // it. + NodeChange.prototype.removedFromParent = function (parent) { + this.childList = true; + if (this.added || this.oldParentNode) + this.added = false; + else + this.oldParentNode = parent; + }; + NodeChange.prototype.insertedIntoParent = function () { + this.childList = true; + this.added = true; + }; + // An node's oldParent is + // -its present parent, if its parentNode was not changed. + // -null if the first thing that happened to it was an add. + // -the node it was removed from if the first thing that happened to it + // was a remove. + NodeChange.prototype.getOldParent = function () { + if (this.childList) { + if (this.oldParentNode) + return this.oldParentNode; + if (this.added) + return null; + } + return this.node.parentNode; + }; + return NodeChange; +}()); +var ChildListChange = /** @class */ (function () { + function ChildListChange() { + this.added = new NodeMap(); + this.removed = new NodeMap(); + this.maybeMoved = new NodeMap(); + this.oldPrevious = new NodeMap(); + this.moved = undefined; + } + return ChildListChange; +}()); +var TreeChanges = /** @class */ (function (_super) { + __extends(TreeChanges, _super); + function TreeChanges(rootNode, mutations) { + var _this = _super.call(this) || this; + _this.rootNode = rootNode; + _this.reachableCache = undefined; + _this.wasReachableCache = undefined; + _this.anyParentsChanged = false; + _this.anyAttributesChanged = false; + _this.anyCharacterDataChanged = false; + for (var m = 0; m < mutations.length; m++) { + var mutation = mutations[m]; + switch (mutation.type) { + case 'childList': + _this.anyParentsChanged = true; + for (var i = 0; i < mutation.removedNodes.length; i++) { + var node = mutation.removedNodes[i]; + _this.getChange(node).removedFromParent(mutation.target); + } + for (var i = 0; i < mutation.addedNodes.length; i++) { + var node = mutation.addedNodes[i]; + _this.getChange(node).insertedIntoParent(); + } + break; + case 'attributes': + _this.anyAttributesChanged = true; + var change = _this.getChange(mutation.target); + change.attributeMutated(mutation.attributeName, mutation.oldValue); + break; + case 'characterData': + _this.anyCharacterDataChanged = true; + var change = _this.getChange(mutation.target); + change.characterDataMutated(mutation.oldValue); + break; + } + } + return _this; + } + TreeChanges.prototype.getChange = function (node) { + var change = this.get(node); + if (!change) { + change = new NodeChange(node); + this.set(node, change); + } + return change; + }; + TreeChanges.prototype.getOldParent = function (node) { + var change = this.get(node); + return change ? change.getOldParent() : node.parentNode; + }; + TreeChanges.prototype.getIsReachable = function (node) { + if (node === this.rootNode) + return true; + if (!node) + return false; + this.reachableCache = this.reachableCache || new NodeMap(); + var isReachable = this.reachableCache.get(node); + if (isReachable === undefined) { + isReachable = this.getIsReachable(node.parentNode); + this.reachableCache.set(node, isReachable); + } + return isReachable; + }; + // A node wasReachable if its oldParent wasReachable. + TreeChanges.prototype.getWasReachable = function (node) { + if (node === this.rootNode) + return true; + if (!node) + return false; + this.wasReachableCache = this.wasReachableCache || new NodeMap(); + var wasReachable = this.wasReachableCache.get(node); + if (wasReachable === undefined) { + wasReachable = this.getWasReachable(this.getOldParent(node)); + this.wasReachableCache.set(node, wasReachable); + } + return wasReachable; + }; + TreeChanges.prototype.reachabilityChange = function (node) { + if (this.getIsReachable(node)) { + return this.getWasReachable(node) ? + Movement.STAYED_IN : Movement.ENTERED; + } + return this.getWasReachable(node) ? + Movement.EXITED : Movement.STAYED_OUT; + }; + return TreeChanges; +}(NodeMap)); +var MutationProjection = /** @class */ (function () { + // TOOD(any) + function MutationProjection(rootNode, mutations, selectors, calcReordered, calcOldPreviousSibling) { + this.rootNode = rootNode; + this.mutations = mutations; + this.selectors = selectors; + this.calcReordered = calcReordered; + this.calcOldPreviousSibling = calcOldPreviousSibling; + this.treeChanges = new TreeChanges(rootNode, mutations); + this.entered = []; + this.exited = []; + this.stayedIn = new NodeMap(); + this.visited = new NodeMap(); + this.childListChangeMap = undefined; + this.characterDataOnly = undefined; + this.matchCache = undefined; + this.processMutations(); + } + MutationProjection.prototype.processMutations = function () { + if (!this.treeChanges.anyParentsChanged && + !this.treeChanges.anyAttributesChanged) + return; + var changedNodes = this.treeChanges.keys(); + for (var i = 0; i < changedNodes.length; i++) { + this.visitNode(changedNodes[i], undefined); + } + }; + MutationProjection.prototype.visitNode = function (node, parentReachable) { + if (this.visited.has(node)) + return; + this.visited.set(node, true); + var change = this.treeChanges.get(node); + var reachable = parentReachable; + // node inherits its parent's reachability change unless + // its parentNode was mutated. + if ((change && change.childList) || reachable == undefined) + reachable = this.treeChanges.reachabilityChange(node); + if (reachable === Movement.STAYED_OUT) + return; + // Cache match results for sub-patterns. + this.matchabilityChange(node); + if (reachable === Movement.ENTERED) { + this.entered.push(node); + } + else if (reachable === Movement.EXITED) { + this.exited.push(node); + this.ensureHasOldPreviousSiblingIfNeeded(node); + } + else if (reachable === Movement.STAYED_IN) { + var movement = Movement.STAYED_IN; + if (change && change.childList) { + if (change.oldParentNode !== node.parentNode) { + movement = Movement.REPARENTED; + this.ensureHasOldPreviousSiblingIfNeeded(node); + } + else if (this.calcReordered && this.wasReordered(node)) { + movement = Movement.REORDERED; + } + } + this.stayedIn.set(node, movement); + } + if (reachable === Movement.STAYED_IN) + return; + // reachable === ENTERED || reachable === EXITED. + for (var child = node.firstChild; child; child = child.nextSibling) { + this.visitNode(child, reachable); + } + }; + MutationProjection.prototype.ensureHasOldPreviousSiblingIfNeeded = function (node) { + if (!this.calcOldPreviousSibling) + return; + this.processChildlistChanges(); + var parentNode = node.parentNode; + var nodeChange = this.treeChanges.get(node); + if (nodeChange && nodeChange.oldParentNode) + parentNode = nodeChange.oldParentNode; + var change = this.childListChangeMap.get(parentNode); + if (!change) { + change = new ChildListChange(); + this.childListChangeMap.set(parentNode, change); + } + if (!change.oldPrevious.has(node)) { + change.oldPrevious.set(node, node.previousSibling); + } + }; + MutationProjection.prototype.getChanged = function (summary, selectors, characterDataOnly) { + this.selectors = selectors; + this.characterDataOnly = characterDataOnly; + for (var i = 0; i < this.entered.length; i++) { + var node = this.entered[i]; + var matchable = this.matchabilityChange(node); + if (matchable === Movement.ENTERED || matchable === Movement.STAYED_IN) + summary.added.push(node); + } + var stayedInNodes = this.stayedIn.keys(); + for (var i = 0; i < stayedInNodes.length; i++) { + var node = stayedInNodes[i]; + var matchable = this.matchabilityChange(node); + if (matchable === Movement.ENTERED) { + summary.added.push(node); + } + else if (matchable === Movement.EXITED) { + summary.removed.push(node); + } + else if (matchable === Movement.STAYED_IN && (summary.reparented || summary.reordered)) { + var movement = this.stayedIn.get(node); + if (summary.reparented && movement === Movement.REPARENTED) + summary.reparented.push(node); + else if (summary.reordered && movement === Movement.REORDERED) + summary.reordered.push(node); + } + } + for (var i = 0; i < this.exited.length; i++) { + var node = this.exited[i]; + var matchable = this.matchabilityChange(node); + if (matchable === Movement.EXITED || matchable === Movement.STAYED_IN) + summary.removed.push(node); + } + }; + MutationProjection.prototype.getOldParentNode = function (node) { + var change = this.treeChanges.get(node); + if (change && change.childList) + return change.oldParentNode ? change.oldParentNode : null; + var reachabilityChange = this.treeChanges.reachabilityChange(node); + if (reachabilityChange === Movement.STAYED_OUT || reachabilityChange === Movement.ENTERED) + throw Error('getOldParentNode requested on invalid node.'); + return node.parentNode; + }; + MutationProjection.prototype.getOldPreviousSibling = function (node) { + var parentNode = node.parentNode; + var nodeChange = this.treeChanges.get(node); + if (nodeChange && nodeChange.oldParentNode) + parentNode = nodeChange.oldParentNode; + var change = this.childListChangeMap.get(parentNode); + if (!change) + throw Error('getOldPreviousSibling requested on invalid node.'); + return change.oldPrevious.get(node); + }; + MutationProjection.prototype.getOldAttribute = function (element, attrName) { + var change = this.treeChanges.get(element); + if (!change || !change.attributes) + throw Error('getOldAttribute requested on invalid node.'); + var value = change.getAttributeOldValue(attrName); + if (value === undefined) + throw Error('getOldAttribute requested for unchanged attribute name.'); + return value; + }; + MutationProjection.prototype.attributeChangedNodes = function (includeAttributes) { + if (!this.treeChanges.anyAttributesChanged) + return {}; // No attributes mutations occurred. + var attributeFilter; + var caseInsensitiveFilter; + if (includeAttributes) { + attributeFilter = {}; + caseInsensitiveFilter = {}; + for (var i = 0; i < includeAttributes.length; i++) { + var attrName = includeAttributes[i]; + attributeFilter[attrName] = true; + caseInsensitiveFilter[attrName.toLowerCase()] = attrName; + } + } + var result = {}; + var nodes = this.treeChanges.keys(); + for (var i = 0; i < nodes.length; i++) { + var node = nodes[i]; + var change = this.treeChanges.get(node); + if (!change.attributes) + continue; + if (Movement.STAYED_IN !== this.treeChanges.reachabilityChange(node) || + Movement.STAYED_IN !== this.matchabilityChange(node)) { + continue; + } + var element = node; + var changedAttrNames = change.getAttributeNamesMutated(); + for (var j = 0; j < changedAttrNames.length; j++) { + var attrName = changedAttrNames[j]; + if (attributeFilter && + !attributeFilter[attrName] && + !(change.isCaseInsensitive && caseInsensitiveFilter[attrName])) { + continue; + } + var oldValue = change.getAttributeOldValue(attrName); + if (oldValue === element.getAttribute(attrName)) + continue; + if (caseInsensitiveFilter && change.isCaseInsensitive) + attrName = caseInsensitiveFilter[attrName]; + result[attrName] = result[attrName] || []; + result[attrName].push(element); + } + } + return result; + }; + MutationProjection.prototype.getOldCharacterData = function (node) { + var change = this.treeChanges.get(node); + if (!change || !change.characterData) + throw Error('getOldCharacterData requested on invalid node.'); + return change.characterDataOldValue; + }; + MutationProjection.prototype.getCharacterDataChanged = function () { + if (!this.treeChanges.anyCharacterDataChanged) + return []; // No characterData mutations occurred. + var nodes = this.treeChanges.keys(); + var result = []; + for (var i = 0; i < nodes.length; i++) { + var target = nodes[i]; + if (Movement.STAYED_IN !== this.treeChanges.reachabilityChange(target)) + continue; + var change = this.treeChanges.get(target); + if (!change.characterData || + target.textContent == change.characterDataOldValue) + continue; + result.push(target); + } + return result; + }; + MutationProjection.prototype.computeMatchabilityChange = function (selector, el) { + if (!this.matchCache) + this.matchCache = []; + if (!this.matchCache[selector.uid]) + this.matchCache[selector.uid] = new NodeMap(); + var cache = this.matchCache[selector.uid]; + var result = cache.get(el); + if (result === undefined) { + result = selector.matchabilityChange(el, this.treeChanges.get(el)); + cache.set(el, result); + } + return result; + }; + MutationProjection.prototype.matchabilityChange = function (node) { + var _this = this; + // TODO(rafaelw): Include PI, CDATA? + // Only include text nodes. + if (this.characterDataOnly) { + switch (node.nodeType) { + case Node.COMMENT_NODE: + case Node.TEXT_NODE: + return Movement.STAYED_IN; + default: + return Movement.STAYED_OUT; + } + } + // No element filter. Include all nodes. + if (!this.selectors) + return Movement.STAYED_IN; + // Element filter. Exclude non-elements. + if (node.nodeType !== Node.ELEMENT_NODE) + return Movement.STAYED_OUT; + var el = node; + var matchChanges = this.selectors.map(function (selector) { + return _this.computeMatchabilityChange(selector, el); + }); + var accum = Movement.STAYED_OUT; + var i = 0; + while (accum !== Movement.STAYED_IN && i < matchChanges.length) { + switch (matchChanges[i]) { + case Movement.STAYED_IN: + accum = Movement.STAYED_IN; + break; + case Movement.ENTERED: + if (accum === Movement.EXITED) + accum = Movement.STAYED_IN; + else + accum = Movement.ENTERED; + break; + case Movement.EXITED: + if (accum === Movement.ENTERED) + accum = Movement.STAYED_IN; + else + accum = Movement.EXITED; + break; + } + i++; + } + return accum; + }; + MutationProjection.prototype.getChildlistChange = function (el) { + var change = this.childListChangeMap.get(el); + if (!change) { + change = new ChildListChange(); + this.childListChangeMap.set(el, change); + } + return change; + }; + MutationProjection.prototype.processChildlistChanges = function () { + if (this.childListChangeMap) + return; + this.childListChangeMap = new NodeMap(); + for (var i = 0; i < this.mutations.length; i++) { + var mutation = this.mutations[i]; + if (mutation.type != 'childList') + continue; + if (this.treeChanges.reachabilityChange(mutation.target) !== Movement.STAYED_IN && + !this.calcOldPreviousSibling) + continue; + var change = this.getChildlistChange(mutation.target); + var oldPrevious = mutation.previousSibling; + function recordOldPrevious(node, previous) { + if (!node || + change.oldPrevious.has(node) || + change.added.has(node) || + change.maybeMoved.has(node)) + return; + if (previous && + (change.added.has(previous) || + change.maybeMoved.has(previous))) + return; + change.oldPrevious.set(node, previous); + } + for (var j = 0; j < mutation.removedNodes.length; j++) { + var node = mutation.removedNodes[j]; + recordOldPrevious(node, oldPrevious); + if (change.added.has(node)) { + change.added["delete"](node); + } + else { + change.removed.set(node, true); + change.maybeMoved["delete"](node); + } + oldPrevious = node; + } + recordOldPrevious(mutation.nextSibling, oldPrevious); + for (var j = 0; j < mutation.addedNodes.length; j++) { + var node = mutation.addedNodes[j]; + if (change.removed.has(node)) { + change.removed["delete"](node); + change.maybeMoved.set(node, true); + } + else { + change.added.set(node, true); + } + } + } + }; + MutationProjection.prototype.wasReordered = function (node) { + if (!this.treeChanges.anyParentsChanged) + return false; + this.processChildlistChanges(); + var parentNode = node.parentNode; + var nodeChange = this.treeChanges.get(node); + if (nodeChange && nodeChange.oldParentNode) + parentNode = nodeChange.oldParentNode; + var change = this.childListChangeMap.get(parentNode); + if (!change) + return false; + if (change.moved) + return change.moved.get(node); + change.moved = new NodeMap(); + var pendingMoveDecision = new NodeMap(); + function isMoved(node) { + if (!node) + return false; + if (!change.maybeMoved.has(node)) + return false; + var didMove = change.moved.get(node); + if (didMove !== undefined) + return didMove; + if (pendingMoveDecision.has(node)) { + didMove = true; + } + else { + pendingMoveDecision.set(node, true); + didMove = getPrevious(node) !== getOldPrevious(node); + } + if (pendingMoveDecision.has(node)) { + pendingMoveDecision["delete"](node); + change.moved.set(node, didMove); + } + else { + didMove = change.moved.get(node); + } + return didMove; + } + var oldPreviousCache = new NodeMap(); + function getOldPrevious(node) { + var oldPrevious = oldPreviousCache.get(node); + if (oldPrevious !== undefined) + return oldPrevious; + oldPrevious = change.oldPrevious.get(node); + while (oldPrevious && + (change.removed.has(oldPrevious) || isMoved(oldPrevious))) { + oldPrevious = getOldPrevious(oldPrevious); + } + if (oldPrevious === undefined) + oldPrevious = node.previousSibling; + oldPreviousCache.set(node, oldPrevious); + return oldPrevious; + } + var previousCache = new NodeMap(); + function getPrevious(node) { + if (previousCache.has(node)) + return previousCache.get(node); + var previous = node.previousSibling; + while (previous && (change.added.has(previous) || isMoved(previous))) + previous = previous.previousSibling; + previousCache.set(node, previous); + return previous; + } + change.maybeMoved.keys().forEach(isMoved); + return change.moved.get(node); + }; + return MutationProjection; +}()); +var Summary = /** @class */ (function () { + function Summary(projection, query) { + var _this = this; + this.projection = projection; + this.added = []; + this.removed = []; + this.reparented = query.all || query.element || query.characterData ? [] : undefined; + this.reordered = query.all ? [] : undefined; + projection.getChanged(this, query.elementFilter, query.characterData); + if (query.all || query.attribute || query.attributeList) { + var filter = query.attribute ? [query.attribute] : query.attributeList; + var attributeChanged = projection.attributeChangedNodes(filter); + if (query.attribute) { + this.valueChanged = attributeChanged[query.attribute] || []; + } + else { + this.attributeChanged = attributeChanged; + if (query.attributeList) { + query.attributeList.forEach(function (attrName) { + if (!_this.attributeChanged.hasOwnProperty(attrName)) + _this.attributeChanged[attrName] = []; + }); + } + } + } + if (query.all || query.characterData) { + var characterDataChanged = projection.getCharacterDataChanged(); + if (query.characterData) + this.valueChanged = characterDataChanged; + else + this.characterDataChanged = characterDataChanged; + } + if (this.reordered) + this.getOldPreviousSibling = projection.getOldPreviousSibling.bind(projection); + } + Summary.prototype.getOldParentNode = function (node) { + return this.projection.getOldParentNode(node); + }; + Summary.prototype.getOldAttribute = function (node, name) { + return this.projection.getOldAttribute(node, name); + }; + Summary.prototype.getOldCharacterData = function (node) { + return this.projection.getOldCharacterData(node); + }; + Summary.prototype.getOldPreviousSibling = function (node) { + return this.projection.getOldPreviousSibling(node); + }; + return Summary; +}()); +// TODO(rafaelw): Allow ':' and '.' as valid name characters. +var validNameInitialChar = /[a-zA-Z_]+/; +var validNameNonInitialChar = /[a-zA-Z0-9_\-]+/; +// TODO(rafaelw): Consider allowing backslash in the attrValue. +// TODO(rafaelw): There's got a to be way to represent this state machine +// more compactly??? +function escapeQuotes(value) { + return '"' + value.replace(/"/, '\\\"') + '"'; +} +var Qualifier = /** @class */ (function () { + function Qualifier() { + } + Qualifier.prototype.matches = function (oldValue) { + if (oldValue === null) + return false; + if (this.attrValue === undefined) + return true; + if (!this.contains) + return this.attrValue == oldValue; + var tokens = oldValue.split(' '); + for (var i = 0; i < tokens.length; i++) { + if (this.attrValue === tokens[i]) + return true; + } + return false; + }; + Qualifier.prototype.toString = function () { + if (this.attrName === 'class' && this.contains) + return '.' + this.attrValue; + if (this.attrName === 'id' && !this.contains) + return '#' + this.attrValue; + if (this.contains) + return '[' + this.attrName + '~=' + escapeQuotes(this.attrValue) + ']'; + if ('attrValue' in this) + return '[' + this.attrName + '=' + escapeQuotes(this.attrValue) + ']'; + return '[' + this.attrName + ']'; + }; + return Qualifier; +}()); +var Selector = /** @class */ (function () { + function Selector() { + this.uid = Selector.nextUid++; + this.qualifiers = []; + } + Object.defineProperty(Selector.prototype, "caseInsensitiveTagName", { + get: function () { + return this.tagName.toUpperCase(); + }, + enumerable: false, + configurable: true + }); + Object.defineProperty(Selector.prototype, "selectorString", { + get: function () { + return this.tagName + this.qualifiers.join(''); + }, + enumerable: false, + configurable: true + }); + Selector.prototype.isMatching = function (el) { + return el[Selector.matchesSelector](this.selectorString); + }; + Selector.prototype.wasMatching = function (el, change, isMatching) { + if (!change || !change.attributes) + return isMatching; + var tagName = change.isCaseInsensitive ? this.caseInsensitiveTagName : this.tagName; + if (tagName !== '*' && tagName !== el.tagName) + return false; + var attributeOldValues = []; + var anyChanged = false; + for (var i = 0; i < this.qualifiers.length; i++) { + var qualifier = this.qualifiers[i]; + var oldValue = change.getAttributeOldValue(qualifier.attrName); + attributeOldValues.push(oldValue); + anyChanged = anyChanged || (oldValue !== undefined); + } + if (!anyChanged) + return isMatching; + for (var i = 0; i < this.qualifiers.length; i++) { + var qualifier = this.qualifiers[i]; + var oldValue = attributeOldValues[i]; + if (oldValue === undefined) + oldValue = el.getAttribute(qualifier.attrName); + if (!qualifier.matches(oldValue)) + return false; + } + return true; + }; + Selector.prototype.matchabilityChange = function (el, change) { + var isMatching = this.isMatching(el); + if (isMatching) + return this.wasMatching(el, change, isMatching) ? Movement.STAYED_IN : Movement.ENTERED; + else + return this.wasMatching(el, change, isMatching) ? Movement.EXITED : Movement.STAYED_OUT; + }; + Selector.parseSelectors = function (input) { + var selectors = []; + var currentSelector; + var currentQualifier; + function newSelector() { + if (currentSelector) { + if (currentQualifier) { + currentSelector.qualifiers.push(currentQualifier); + currentQualifier = undefined; + } + selectors.push(currentSelector); + } + currentSelector = new Selector(); + } + function newQualifier() { + if (currentQualifier) + currentSelector.qualifiers.push(currentQualifier); + currentQualifier = new Qualifier(); + } + var WHITESPACE = /\s/; + var valueQuoteChar; + var SYNTAX_ERROR = 'Invalid or unsupported selector syntax.'; + var SELECTOR = 1; + var TAG_NAME = 2; + var QUALIFIER = 3; + var QUALIFIER_NAME_FIRST_CHAR = 4; + var QUALIFIER_NAME = 5; + var ATTR_NAME_FIRST_CHAR = 6; + var ATTR_NAME = 7; + var EQUIV_OR_ATTR_QUAL_END = 8; + var EQUAL = 9; + var ATTR_QUAL_END = 10; + var VALUE_FIRST_CHAR = 11; + var VALUE = 12; + var QUOTED_VALUE = 13; + var SELECTOR_SEPARATOR = 14; + var state = SELECTOR; + var i = 0; + while (i < input.length) { + var c = input[i++]; + switch (state) { + case SELECTOR: + if (c.match(validNameInitialChar)) { + newSelector(); + currentSelector.tagName = c; + state = TAG_NAME; + break; + } + if (c == '*') { + newSelector(); + currentSelector.tagName = '*'; + state = QUALIFIER; + break; + } + if (c == '.') { + newSelector(); + newQualifier(); + currentSelector.tagName = '*'; + currentQualifier.attrName = 'class'; + currentQualifier.contains = true; + state = QUALIFIER_NAME_FIRST_CHAR; + break; + } + if (c == '#') { + newSelector(); + newQualifier(); + currentSelector.tagName = '*'; + currentQualifier.attrName = 'id'; + state = QUALIFIER_NAME_FIRST_CHAR; + break; + } + if (c == '[') { + newSelector(); + newQualifier(); + currentSelector.tagName = '*'; + currentQualifier.attrName = ''; + state = ATTR_NAME_FIRST_CHAR; + break; + } + if (c.match(WHITESPACE)) + break; + throw Error(SYNTAX_ERROR); + case TAG_NAME: + if (c.match(validNameNonInitialChar)) { + currentSelector.tagName += c; + break; + } + if (c == '.') { + newQualifier(); + currentQualifier.attrName = 'class'; + currentQualifier.contains = true; + state = QUALIFIER_NAME_FIRST_CHAR; + break; + } + if (c == '#') { + newQualifier(); + currentQualifier.attrName = 'id'; + state = QUALIFIER_NAME_FIRST_CHAR; + break; + } + if (c == '[') { + newQualifier(); + currentQualifier.attrName = ''; + state = ATTR_NAME_FIRST_CHAR; + break; + } + if (c.match(WHITESPACE)) { + state = SELECTOR_SEPARATOR; + break; + } + if (c == ',') { + state = SELECTOR; + break; + } + throw Error(SYNTAX_ERROR); + case QUALIFIER: + if (c == '.') { + newQualifier(); + currentQualifier.attrName = 'class'; + currentQualifier.contains = true; + state = QUALIFIER_NAME_FIRST_CHAR; + break; + } + if (c == '#') { + newQualifier(); + currentQualifier.attrName = 'id'; + state = QUALIFIER_NAME_FIRST_CHAR; + break; + } + if (c == '[') { + newQualifier(); + currentQualifier.attrName = ''; + state = ATTR_NAME_FIRST_CHAR; + break; + } + if (c.match(WHITESPACE)) { + state = SELECTOR_SEPARATOR; + break; + } + if (c == ',') { + state = SELECTOR; + break; + } + throw Error(SYNTAX_ERROR); + case QUALIFIER_NAME_FIRST_CHAR: + if (c.match(validNameInitialChar)) { + currentQualifier.attrValue = c; + state = QUALIFIER_NAME; + break; + } + throw Error(SYNTAX_ERROR); + case QUALIFIER_NAME: + if (c.match(validNameNonInitialChar)) { + currentQualifier.attrValue += c; + break; + } + if (c == '.') { + newQualifier(); + currentQualifier.attrName = 'class'; + currentQualifier.contains = true; + state = QUALIFIER_NAME_FIRST_CHAR; + break; + } + if (c == '#') { + newQualifier(); + currentQualifier.attrName = 'id'; + state = QUALIFIER_NAME_FIRST_CHAR; + break; + } + if (c == '[') { + newQualifier(); + state = ATTR_NAME_FIRST_CHAR; + break; + } + if (c.match(WHITESPACE)) { + state = SELECTOR_SEPARATOR; + break; + } + if (c == ',') { + state = SELECTOR; + break; + } + throw Error(SYNTAX_ERROR); + case ATTR_NAME_FIRST_CHAR: + if (c.match(validNameInitialChar)) { + currentQualifier.attrName = c; + state = ATTR_NAME; + break; + } + if (c.match(WHITESPACE)) + break; + throw Error(SYNTAX_ERROR); + case ATTR_NAME: + if (c.match(validNameNonInitialChar)) { + currentQualifier.attrName += c; + break; + } + if (c.match(WHITESPACE)) { + state = EQUIV_OR_ATTR_QUAL_END; + break; + } + if (c == '~') { + currentQualifier.contains = true; + state = EQUAL; + break; + } + if (c == '=') { + currentQualifier.attrValue = ''; + state = VALUE_FIRST_CHAR; + break; + } + if (c == ']') { + state = QUALIFIER; + break; + } + throw Error(SYNTAX_ERROR); + case EQUIV_OR_ATTR_QUAL_END: + if (c == '~') { + currentQualifier.contains = true; + state = EQUAL; + break; + } + if (c == '=') { + currentQualifier.attrValue = ''; + state = VALUE_FIRST_CHAR; + break; + } + if (c == ']') { + state = QUALIFIER; + break; + } + if (c.match(WHITESPACE)) + break; + throw Error(SYNTAX_ERROR); + case EQUAL: + if (c == '=') { + currentQualifier.attrValue = ''; + state = VALUE_FIRST_CHAR; + break; + } + throw Error(SYNTAX_ERROR); + case ATTR_QUAL_END: + if (c == ']') { + state = QUALIFIER; + break; + } + if (c.match(WHITESPACE)) + break; + throw Error(SYNTAX_ERROR); + case VALUE_FIRST_CHAR: + if (c.match(WHITESPACE)) + break; + if (c == '"' || c == "'") { + valueQuoteChar = c; + state = QUOTED_VALUE; + break; + } + currentQualifier.attrValue += c; + state = VALUE; + break; + case VALUE: + if (c.match(WHITESPACE)) { + state = ATTR_QUAL_END; + break; + } + if (c == ']') { + state = QUALIFIER; + break; + } + if (c == "'" || c == '"') + throw Error(SYNTAX_ERROR); + currentQualifier.attrValue += c; + break; + case QUOTED_VALUE: + if (c == valueQuoteChar) { + state = ATTR_QUAL_END; + break; + } + currentQualifier.attrValue += c; + break; + case SELECTOR_SEPARATOR: + if (c.match(WHITESPACE)) + break; + if (c == ',') { + state = SELECTOR; + break; + } + throw Error(SYNTAX_ERROR); + } + } + switch (state) { + case SELECTOR: + case TAG_NAME: + case QUALIFIER: + case QUALIFIER_NAME: + case SELECTOR_SEPARATOR: + // Valid end states. + newSelector(); + break; + default: + throw Error(SYNTAX_ERROR); + } + if (!selectors.length) + throw Error(SYNTAX_ERROR); + return selectors; + }; + Selector.nextUid = 1; + Selector.matchesSelector = (function () { + var element = document.createElement('div'); + if (typeof element['webkitMatchesSelector'] === 'function') + return 'webkitMatchesSelector'; + if (typeof element['mozMatchesSelector'] === 'function') + return 'mozMatchesSelector'; + if (typeof element['msMatchesSelector'] === 'function') + return 'msMatchesSelector'; + return 'matchesSelector'; + })(); + return Selector; +}()); +var attributeFilterPattern = /^([a-zA-Z:_]+[a-zA-Z0-9_\-:\.]*)$/; +function validateAttribute(attribute) { + if (typeof attribute != 'string') + throw Error('Invalid request opion. attribute must be a non-zero length string.'); + attribute = attribute.trim(); + if (!attribute) + throw Error('Invalid request opion. attribute must be a non-zero length string.'); + if (!attribute.match(attributeFilterPattern)) + throw Error('Invalid request option. invalid attribute name: ' + attribute); + return attribute; +} +function validateElementAttributes(attribs) { + if (!attribs.trim().length) + throw Error('Invalid request option: elementAttributes must contain at least one attribute.'); + var lowerAttributes = {}; + var attributes = {}; + var tokens = attribs.split(/\s+/); + for (var i = 0; i < tokens.length; i++) { + var name = tokens[i]; + if (!name) + continue; + var name = validateAttribute(name); + var nameLower = name.toLowerCase(); + if (lowerAttributes[nameLower]) + throw Error('Invalid request option: observing multiple case variations of the same attribute is not supported.'); + attributes[name] = true; + lowerAttributes[nameLower] = true; + } + return Object.keys(attributes); +} +function elementFilterAttributes(selectors) { + var attributes = {}; + selectors.forEach(function (selector) { + selector.qualifiers.forEach(function (qualifier) { + attributes[qualifier.attrName] = true; + }); + }); + return Object.keys(attributes); +} +var MutationSummary = /** @class */ (function () { + function MutationSummary(opts) { + var _this = this; + this.connected = false; + this.options = MutationSummary.validateOptions(opts); + this.observerOptions = MutationSummary.createObserverOptions(this.options.queries); + this.root = this.options.rootNode; + this.callback = this.options.callback; + this.elementFilter = Array.prototype.concat.apply([], this.options.queries.map(function (query) { + return query.elementFilter ? query.elementFilter : []; + })); + if (!this.elementFilter.length) + this.elementFilter = undefined; + this.calcReordered = this.options.queries.some(function (query) { + return query.all; + }); + this.queryValidators = []; // TODO(rafaelw): Shouldn't always define this. + if (MutationSummary.createQueryValidator) { + this.queryValidators = this.options.queries.map(function (query) { + return MutationSummary.createQueryValidator(_this.root, query); + }); + } + this.observer = new MutationObserverCtor(function (mutations) { + _this.observerCallback(mutations); + }); + this.reconnect(); + } + MutationSummary.createObserverOptions = function (queries) { + var observerOptions = { + childList: true, + subtree: true + }; + var attributeFilter; + function observeAttributes(attributes) { + if (observerOptions.attributes && !attributeFilter) + return; // already observing all. + observerOptions.attributes = true; + observerOptions.attributeOldValue = true; + if (!attributes) { + // observe all. + attributeFilter = undefined; + return; + } + // add to observed. + attributeFilter = attributeFilter || {}; + attributes.forEach(function (attribute) { + attributeFilter[attribute] = true; + attributeFilter[attribute.toLowerCase()] = true; + }); + } + queries.forEach(function (query) { + if (query.characterData) { + observerOptions.characterData = true; + observerOptions.characterDataOldValue = true; + return; + } + if (query.all) { + observeAttributes(); + observerOptions.characterData = true; + observerOptions.characterDataOldValue = true; + return; + } + if (query.attribute) { + observeAttributes([query.attribute.trim()]); + return; + } + var attributes = elementFilterAttributes(query.elementFilter).concat(query.attributeList || []); + if (attributes.length) + observeAttributes(attributes); + }); + if (attributeFilter) + observerOptions.attributeFilter = Object.keys(attributeFilter); + return observerOptions; + }; + MutationSummary.validateOptions = function (options) { + for (var prop in options) { + if (!(prop in MutationSummary.optionKeys)) + throw Error('Invalid option: ' + prop); + } + if (typeof options.callback !== 'function') + throw Error('Invalid options: callback is required and must be a function'); + if (!options.queries || !options.queries.length) + throw Error('Invalid options: queries must contain at least one query request object.'); + var opts = { + callback: options.callback, + rootNode: options.rootNode || document, + observeOwnChanges: !!options.observeOwnChanges, + oldPreviousSibling: !!options.oldPreviousSibling, + queries: [] + }; + for (var i = 0; i < options.queries.length; i++) { + var request = options.queries[i]; + // all + if (request.all) { + if (Object.keys(request).length > 1) + throw Error('Invalid request option. all has no options.'); + opts.queries.push({ all: true }); + continue; + } + // attribute + if ('attribute' in request) { + var query = { + attribute: validateAttribute(request.attribute) + }; + query.elementFilter = Selector.parseSelectors('*[' + query.attribute + ']'); + if (Object.keys(request).length > 1) + throw Error('Invalid request option. attribute has no options.'); + opts.queries.push(query); + continue; + } + // element + if ('element' in request) { + var requestOptionCount = Object.keys(request).length; + var query = { + element: request.element, + elementFilter: Selector.parseSelectors(request.element) + }; + if (request.hasOwnProperty('elementAttributes')) { + query.attributeList = validateElementAttributes(request.elementAttributes); + requestOptionCount--; + } + if (requestOptionCount > 1) + throw Error('Invalid request option. element only allows elementAttributes option.'); + opts.queries.push(query); + continue; + } + // characterData + if (request.characterData) { + if (Object.keys(request).length > 1) + throw Error('Invalid request option. characterData has no options.'); + opts.queries.push({ characterData: true }); + continue; + } + throw Error('Invalid request option. Unknown query request.'); + } + return opts; + }; + MutationSummary.prototype.createSummaries = function (mutations) { + if (!mutations || !mutations.length) + return []; + var projection = new MutationProjection(this.root, mutations, this.elementFilter, this.calcReordered, this.options.oldPreviousSibling); + var summaries = []; + for (var i = 0; i < this.options.queries.length; i++) { + summaries.push(new Summary(projection, this.options.queries[i])); + } + return summaries; + }; + MutationSummary.prototype.checkpointQueryValidators = function () { + this.queryValidators.forEach(function (validator) { + if (validator) + validator.recordPreviousState(); + }); + }; + MutationSummary.prototype.runQueryValidators = function (summaries) { + this.queryValidators.forEach(function (validator, index) { + if (validator) + validator.validate(summaries[index]); + }); + }; + MutationSummary.prototype.changesToReport = function (summaries) { + return summaries.some(function (summary) { + var summaryProps = ['added', 'removed', 'reordered', 'reparented', + 'valueChanged', 'characterDataChanged']; + if (summaryProps.some(function (prop) { return summary[prop] && summary[prop].length; })) + return true; + if (summary.attributeChanged) { + var attrNames = Object.keys(summary.attributeChanged); + var attrsChanged = attrNames.some(function (attrName) { + return !!summary.attributeChanged[attrName].length; + }); + if (attrsChanged) + return true; + } + return false; + }); + }; + MutationSummary.prototype.observerCallback = function (mutations) { + if (!this.options.observeOwnChanges) + this.observer.disconnect(); + var summaries = this.createSummaries(mutations); + this.runQueryValidators(summaries); + if (this.options.observeOwnChanges) + this.checkpointQueryValidators(); + if (this.changesToReport(summaries)) + this.callback(summaries); + // disconnect() may have been called during the callback. + if (!this.options.observeOwnChanges && this.connected) { + this.checkpointQueryValidators(); + this.observer.observe(this.root, this.observerOptions); + } + }; + MutationSummary.prototype.reconnect = function () { + if (this.connected) + throw Error('Already connected'); + this.observer.observe(this.root, this.observerOptions); + this.connected = true; + this.checkpointQueryValidators(); + }; + MutationSummary.prototype.takeSummaries = function () { + if (!this.connected) + throw Error('Not connected'); + var summaries = this.createSummaries(this.observer.takeRecords()); + return this.changesToReport(summaries) ? summaries : undefined; + }; + MutationSummary.prototype.disconnect = function () { + var summaries = this.takeSummaries(); + this.observer.disconnect(); + this.connected = false; + return summaries; + }; + MutationSummary.NodeMap = NodeMap; // exposed for use in TreeMirror. + MutationSummary.parseElementFilter = Selector.parseSelectors; // exposed for testing. + MutationSummary.optionKeys = { + 'callback': true, + 'queries': true, + 'rootNode': true, + 'oldPreviousSibling': true, + 'observeOwnChanges': true + }; + return MutationSummary; +}()); diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/libs/mutation-summary/src/mutation-summary.ts b/files/plugin-HeatmapSessionRecording-5.6.0/libs/mutation-summary/src/mutation-summary.ts new file mode 100644 index 0000000..ee3a268 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/libs/mutation-summary/src/mutation-summary.ts @@ -0,0 +1,1750 @@ +// Copyright 2011 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +var MutationObserverCtor; +if (typeof WebKitMutationObserver !== 'undefined') + MutationObserverCtor = WebKitMutationObserver; +else + MutationObserverCtor = MutationObserver; + +if (MutationObserverCtor === undefined) { + console.error('DOM Mutation Observers are required.'); + console.error('https://developer.mozilla.org/en-US/docs/DOM/MutationObserver'); + throw Error('DOM Mutation Observers are required'); +} + +interface StringMap { + [key: string]: T; +} + +interface NumberMap { + [key: number]: T; +} + +class NodeMap { + + private static ID_PROP = '__mutation_summary_node_map_id__'; + private static nextId_:number = 1; + + private nodes:Node[]; + private values:T[]; + + constructor() { + this.nodes = []; + this.values = []; + } + + private isIndex(s:string):boolean { + return +s === s >>> 0; + } + + private nodeId(node:Node) { + var id = node[NodeMap.ID_PROP]; + if (!id) + id = node[NodeMap.ID_PROP] = NodeMap.nextId_++; + return id; + } + + set(node:Node, value:T) { + var id = this.nodeId(node); + this.nodes[id] = node; + this.values[id] = value; + } + + get(node:Node):T { + var id = this.nodeId(node); + return this.values[id]; + } + + has(node:Node):boolean { + return this.nodeId(node) in this.nodes; + } + + delete(node:Node) { + var id = this.nodeId(node); + delete this.nodes[id]; + this.values[id] = undefined; + } + + keys():Node[] { + var nodes:Node[] = []; + for (var id in this.nodes) { + if (!this.isIndex(id)) + continue; + nodes.push(this.nodes[id]); + } + + return nodes; + } +} + +/** + * var reachableMatchableProduct = [ + * // STAYED_OUT, ENTERED, STAYED_IN, EXITED + * [ STAYED_OUT, STAYED_OUT, STAYED_OUT, STAYED_OUT ], // STAYED_OUT + * [ STAYED_OUT, ENTERED, ENTERED, STAYED_OUT ], // ENTERED + * [ STAYED_OUT, ENTERED, STAYED_IN, EXITED ], // STAYED_IN + * [ STAYED_OUT, STAYED_OUT, EXITED, EXITED ] // EXITED + * ]; + */ + +enum Movement { + STAYED_OUT, + ENTERED, + STAYED_IN, + REPARENTED, + REORDERED, + EXITED +} + +function enteredOrExited(changeType:Movement):boolean { + return changeType === Movement.ENTERED || changeType === Movement.EXITED; +} + +class NodeChange { + + public isCaseInsensitive:boolean; + + constructor(public node:Node, + public childList:boolean = false, + public attributes:boolean = false, + public characterData:boolean = false, + public oldParentNode:Node = null, + public added:boolean = false, + private attributeOldValues:StringMap = null, + public characterDataOldValue:string = null) { + this.isCaseInsensitive = + this.node.nodeType === Node.ELEMENT_NODE && + this.node instanceof HTMLElement && + this.node.ownerDocument instanceof HTMLDocument; + } + + getAttributeOldValue(name:string):string { + if (!this.attributeOldValues) + return undefined; + if (this.isCaseInsensitive) + name = name.toLowerCase(); + return this.attributeOldValues[name]; + } + + getAttributeNamesMutated():string[] { + var names:string[] = []; + if (!this.attributeOldValues) + return names; + for (var name in this.attributeOldValues) { + names.push(name); + } + return names; + } + + attributeMutated(name:string, oldValue:string) { + this.attributes = true; + this.attributeOldValues = this.attributeOldValues || {}; + + if (name in this.attributeOldValues) + return; + + this.attributeOldValues[name] = oldValue; + } + + characterDataMutated(oldValue:string) { + if (this.characterData) + return; + this.characterData = true; + this.characterDataOldValue = oldValue; + } + + // Note: is it possible to receive a removal followed by a removal. This + // can occur if the removed node is added to an non-observed node, that + // node is added to the observed area, and then the node removed from + // it. + removedFromParent(parent:Node) { + this.childList = true; + if (this.added || this.oldParentNode) + this.added = false; + else + this.oldParentNode = parent; + } + + insertedIntoParent() { + this.childList = true; + this.added = true; + } + + // An node's oldParent is + // -its present parent, if its parentNode was not changed. + // -null if the first thing that happened to it was an add. + // -the node it was removed from if the first thing that happened to it + // was a remove. + getOldParent() { + if (this.childList) { + if (this.oldParentNode) + return this.oldParentNode; + if (this.added) + return null; + } + + return this.node.parentNode; + } +} + +class ChildListChange { + + public added:NodeMap; + public removed:NodeMap; + public maybeMoved:NodeMap; + public oldPrevious:NodeMap; + public moved:NodeMap; + + constructor() { + this.added = new NodeMap(); + this.removed = new NodeMap(); + this.maybeMoved = new NodeMap(); + this.oldPrevious = new NodeMap(); + this.moved = undefined; + } +} + +class TreeChanges extends NodeMap { + + public anyParentsChanged:boolean; + public anyAttributesChanged:boolean; + public anyCharacterDataChanged:boolean; + + private reachableCache:NodeMap; + private wasReachableCache:NodeMap; + + private rootNode:Node; + + constructor(rootNode:Node, mutations:MutationRecord[]) { + super(); + + this.rootNode = rootNode; + this.reachableCache = undefined; + this.wasReachableCache = undefined; + this.anyParentsChanged = false; + this.anyAttributesChanged = false; + this.anyCharacterDataChanged = false; + + for (var m = 0; m < mutations.length; m++) { + var mutation = mutations[m]; + switch (mutation.type) { + + case 'childList': + this.anyParentsChanged = true; + for (var i = 0; i < mutation.removedNodes.length; i++) { + var node = mutation.removedNodes[i]; + this.getChange(node).removedFromParent(mutation.target); + } + for (var i = 0; i < mutation.addedNodes.length; i++) { + var node = mutation.addedNodes[i]; + this.getChange(node).insertedIntoParent(); + } + break; + + case 'attributes': + this.anyAttributesChanged = true; + var change = this.getChange(mutation.target); + change.attributeMutated(mutation.attributeName, mutation.oldValue); + break; + + case 'characterData': + this.anyCharacterDataChanged = true; + var change = this.getChange(mutation.target); + change.characterDataMutated(mutation.oldValue); + break; + } + } + } + + getChange(node:Node):NodeChange { + var change = this.get(node); + if (!change) { + change = new NodeChange(node); + this.set(node, change); + } + return change; + } + + getOldParent(node:Node):Node { + var change = this.get(node); + return change ? change.getOldParent() : node.parentNode; + } + + getIsReachable(node:Node):boolean { + if (node === this.rootNode) + return true; + if (!node) + return false; + + this.reachableCache = this.reachableCache || new NodeMap(); + var isReachable = this.reachableCache.get(node); + if (isReachable === undefined) { + isReachable = this.getIsReachable(node.parentNode); + this.reachableCache.set(node, isReachable); + } + return isReachable; + } + + // A node wasReachable if its oldParent wasReachable. + getWasReachable(node:Node):boolean { + if (node === this.rootNode) + return true; + if (!node) + return false; + + this.wasReachableCache = this.wasReachableCache || new NodeMap(); + var wasReachable:boolean = this.wasReachableCache.get(node); + if (wasReachable === undefined) { + wasReachable = this.getWasReachable(this.getOldParent(node)); + this.wasReachableCache.set(node, wasReachable); + } + return wasReachable; + } + + reachabilityChange(node:Node):Movement { + if (this.getIsReachable(node)) { + return this.getWasReachable(node) ? + Movement.STAYED_IN : Movement.ENTERED; + } + + return this.getWasReachable(node) ? + Movement.EXITED : Movement.STAYED_OUT; + } +} + +class MutationProjection { + + private treeChanges:TreeChanges; + private entered:Node[]; + private exited:Node[]; + private stayedIn:NodeMap; + private visited:NodeMap; + private childListChangeMap:NodeMap; + private characterDataOnly:boolean; + private matchCache:NumberMap>; + + // TOOD(any) + constructor(public rootNode:Node, + public mutations:MutationRecord[], + public selectors:Selector[], + public calcReordered:boolean, + public calcOldPreviousSibling:boolean) { + + this.treeChanges = new TreeChanges(rootNode, mutations); + this.entered = []; + this.exited = []; + this.stayedIn = new NodeMap(); + this.visited = new NodeMap(); + this.childListChangeMap = undefined; + this.characterDataOnly = undefined; + this.matchCache = undefined; + + this.processMutations(); + } + + processMutations() { + if (!this.treeChanges.anyParentsChanged && + !this.treeChanges.anyAttributesChanged) + return; + + var changedNodes:Node[] = this.treeChanges.keys(); + for (var i = 0; i < changedNodes.length; i++) { + this.visitNode(changedNodes[i], undefined); + } + } + + visitNode(node:Node, parentReachable:Movement) { + if (this.visited.has(node)) + return; + + this.visited.set(node, true); + + var change = this.treeChanges.get(node); + var reachable = parentReachable; + + // node inherits its parent's reachability change unless + // its parentNode was mutated. + if ((change && change.childList) || reachable == undefined) + reachable = this.treeChanges.reachabilityChange(node); + + if (reachable === Movement.STAYED_OUT) + return; + + // Cache match results for sub-patterns. + this.matchabilityChange(node); + + if (reachable === Movement.ENTERED) { + this.entered.push(node); + } else if (reachable === Movement.EXITED) { + this.exited.push(node); + this.ensureHasOldPreviousSiblingIfNeeded(node); + + } else if (reachable === Movement.STAYED_IN) { + var movement = Movement.STAYED_IN; + + if (change && change.childList) { + if (change.oldParentNode !== node.parentNode) { + movement = Movement.REPARENTED; + this.ensureHasOldPreviousSiblingIfNeeded(node); + } else if (this.calcReordered && this.wasReordered(node)) { + movement = Movement.REORDERED; + } + } + + this.stayedIn.set(node, movement); + } + + if (reachable === Movement.STAYED_IN) + return; + + // reachable === ENTERED || reachable === EXITED. + for (var child = node.firstChild; child; child = child.nextSibling) { + this.visitNode(child, reachable); + } + } + + ensureHasOldPreviousSiblingIfNeeded(node:Node) { + if (!this.calcOldPreviousSibling) + return; + + this.processChildlistChanges(); + + var parentNode = node.parentNode; + var nodeChange = this.treeChanges.get(node); + if (nodeChange && nodeChange.oldParentNode) + parentNode = nodeChange.oldParentNode; + + var change = this.childListChangeMap.get(parentNode); + if (!change) { + change = new ChildListChange(); + this.childListChangeMap.set(parentNode, change); + } + + if (!change.oldPrevious.has(node)) { + change.oldPrevious.set(node, node.previousSibling); + } + } + + getChanged(summary:Summary, selectors:Selector[], characterDataOnly:boolean) { + this.selectors = selectors; + this.characterDataOnly = characterDataOnly; + + for (var i = 0; i < this.entered.length; i++) { + var node = this.entered[i]; + var matchable = this.matchabilityChange(node); + if (matchable === Movement.ENTERED || matchable === Movement.STAYED_IN) + summary.added.push(node); + } + + var stayedInNodes = this.stayedIn.keys(); + for (var i = 0; i < stayedInNodes.length; i++) { + var node = stayedInNodes[i]; + var matchable = this.matchabilityChange(node); + + if (matchable === Movement.ENTERED) { + summary.added.push(node); + } else if (matchable === Movement.EXITED) { + summary.removed.push(node); + } else if (matchable === Movement.STAYED_IN && (summary.reparented || summary.reordered)) { + var movement:Movement = this.stayedIn.get(node); + if (summary.reparented && movement === Movement.REPARENTED) + summary.reparented.push(node); + else if (summary.reordered && movement === Movement.REORDERED) + summary.reordered.push(node); + } + } + + for (var i = 0; i < this.exited.length; i++) { + var node = this.exited[i]; + var matchable = this.matchabilityChange(node); + if (matchable === Movement.EXITED || matchable === Movement.STAYED_IN) + summary.removed.push(node); + } + } + + getOldParentNode(node:Node):Node { + var change = this.treeChanges.get(node); + if (change && change.childList) + return change.oldParentNode ? change.oldParentNode : null; + + var reachabilityChange = this.treeChanges.reachabilityChange(node); + if (reachabilityChange === Movement.STAYED_OUT || reachabilityChange === Movement.ENTERED) + throw Error('getOldParentNode requested on invalid node.'); + + return node.parentNode; + } + + getOldPreviousSibling(node:Node):Node { + var parentNode = node.parentNode; + var nodeChange = this.treeChanges.get(node); + if (nodeChange && nodeChange.oldParentNode) + parentNode = nodeChange.oldParentNode; + + var change = this.childListChangeMap.get(parentNode); + if (!change) + throw Error('getOldPreviousSibling requested on invalid node.'); + + return change.oldPrevious.get(node); + } + + getOldAttribute(element:Node, attrName:string):string { + var change = this.treeChanges.get(element); + if (!change || !change.attributes) + throw Error('getOldAttribute requested on invalid node.'); + + var value = change.getAttributeOldValue(attrName); + if (value === undefined) + throw Error('getOldAttribute requested for unchanged attribute name.'); + + return value; + } + + attributeChangedNodes(includeAttributes:string[]):StringMap { + if (!this.treeChanges.anyAttributesChanged) + return {}; // No attributes mutations occurred. + + var attributeFilter:StringMap; + var caseInsensitiveFilter:StringMap; + if (includeAttributes) { + attributeFilter = {}; + caseInsensitiveFilter = {}; + for (var i = 0; i < includeAttributes.length; i++) { + var attrName:string = includeAttributes[i]; + attributeFilter[attrName] = true; + caseInsensitiveFilter[attrName.toLowerCase()] = attrName; + } + } + + var result:StringMap = {}; + var nodes = this.treeChanges.keys(); + + for (var i = 0; i < nodes.length; i++) { + var node = nodes[i]; + + var change = this.treeChanges.get(node); + if (!change.attributes) + continue; + + if (Movement.STAYED_IN !== this.treeChanges.reachabilityChange(node) || + Movement.STAYED_IN !== this.matchabilityChange(node)) { + continue; + } + + var element = node; + var changedAttrNames = change.getAttributeNamesMutated(); + for (var j = 0; j < changedAttrNames.length; j++) { + var attrName = changedAttrNames[j]; + + if (attributeFilter && + !attributeFilter[attrName] && + !(change.isCaseInsensitive && caseInsensitiveFilter[attrName])) { + continue; + } + + var oldValue = change.getAttributeOldValue(attrName); + if (oldValue === element.getAttribute(attrName)) + continue; + + if (caseInsensitiveFilter && change.isCaseInsensitive) + attrName = caseInsensitiveFilter[attrName]; + + result[attrName] = result[attrName] || []; + result[attrName].push(element); + } + } + + return result; + } + + getOldCharacterData(node:Node):string { + var change = this.treeChanges.get(node); + if (!change || !change.characterData) + throw Error('getOldCharacterData requested on invalid node.'); + + return change.characterDataOldValue; + } + + getCharacterDataChanged():Node[] { + if (!this.treeChanges.anyCharacterDataChanged) + return []; // No characterData mutations occurred. + + var nodes = this.treeChanges.keys(); + var result:Node[] = []; + for (var i = 0; i < nodes.length; i++) { + var target = nodes[i]; + if (Movement.STAYED_IN !== this.treeChanges.reachabilityChange(target)) + continue; + + var change = this.treeChanges.get(target); + if (!change.characterData || + target.textContent == change.characterDataOldValue) + continue + + result.push(target); + } + + return result; + } + + computeMatchabilityChange(selector:Selector, el:Element):Movement { + if (!this.matchCache) + this.matchCache = []; + if (!this.matchCache[selector.uid]) + this.matchCache[selector.uid] = new NodeMap(); + + var cache = this.matchCache[selector.uid]; + var result = cache.get(el); + if (result === undefined) { + result = selector.matchabilityChange(el, this.treeChanges.get(el)); + cache.set(el, result); + } + return result; + } + + matchabilityChange(node:Node) { + // TODO(rafaelw): Include PI, CDATA? + // Only include text nodes. + if (this.characterDataOnly) { + switch (node.nodeType) { + case Node.COMMENT_NODE: + case Node.TEXT_NODE: + return Movement.STAYED_IN; + default: + return Movement.STAYED_OUT; + } + } + + // No element filter. Include all nodes. + if (!this.selectors) + return Movement.STAYED_IN; + + // Element filter. Exclude non-elements. + if (node.nodeType !== Node.ELEMENT_NODE) + return Movement.STAYED_OUT; + + var el = node; + + var matchChanges = this.selectors.map((selector:Selector) => { + return this.computeMatchabilityChange(selector, el); + }); + + var accum:Movement = Movement.STAYED_OUT; + var i = 0; + + while (accum !== Movement.STAYED_IN && i < matchChanges.length) { + switch(matchChanges[i]) { + case Movement.STAYED_IN: + accum = Movement.STAYED_IN; + break; + case Movement.ENTERED: + if (accum === Movement.EXITED) + accum = Movement.STAYED_IN; + else + accum = Movement.ENTERED; + break; + case Movement.EXITED: + if (accum === Movement.ENTERED) + accum = Movement.STAYED_IN; + else + accum = Movement.EXITED; + break; + } + + i++; + } + + return accum; + } + + getChildlistChange(el:Element):ChildListChange { + var change = this.childListChangeMap.get(el); + if (!change) { + change = new ChildListChange(); + this.childListChangeMap.set(el, change); + } + + return change; + } + + processChildlistChanges() { + if (this.childListChangeMap) + return; + + this.childListChangeMap = new NodeMap(); + + for (var i = 0; i < this.mutations.length; i++) { + var mutation = this.mutations[i]; + if (mutation.type != 'childList') + continue; + + if (this.treeChanges.reachabilityChange(mutation.target) !== Movement.STAYED_IN && + !this.calcOldPreviousSibling) + continue; + + var change = this.getChildlistChange(mutation.target); + + var oldPrevious = mutation.previousSibling; + + function recordOldPrevious(node:Node, previous:Node) { + if (!node || + change.oldPrevious.has(node) || + change.added.has(node) || + change.maybeMoved.has(node)) + return; + + if (previous && + (change.added.has(previous) || + change.maybeMoved.has(previous))) + return; + + change.oldPrevious.set(node, previous); + } + + for (var j = 0; j < mutation.removedNodes.length; j++) { + var node = mutation.removedNodes[j]; + recordOldPrevious(node, oldPrevious); + + if (change.added.has(node)) { + change.added.delete(node); + } else { + change.removed.set(node, true); + change.maybeMoved.delete(node); + } + + oldPrevious = node; + } + + recordOldPrevious(mutation.nextSibling, oldPrevious); + + for (var j = 0; j < mutation.addedNodes.length; j++) { + var node = mutation.addedNodes[j]; + if (change.removed.has(node)) { + change.removed.delete(node); + change.maybeMoved.set(node, true); + } else { + change.added.set(node, true); + } + } + } + } + + wasReordered(node:Node) { + if (!this.treeChanges.anyParentsChanged) + return false; + + this.processChildlistChanges(); + + var parentNode = node.parentNode; + var nodeChange = this.treeChanges.get(node); + if (nodeChange && nodeChange.oldParentNode) + parentNode = nodeChange.oldParentNode; + + var change = this.childListChangeMap.get(parentNode); + if (!change) + return false; + + if (change.moved) + return change.moved.get(node); + + change.moved = new NodeMap(); + var pendingMoveDecision = new NodeMap(); + + function isMoved(node:Node) { + if (!node) + return false; + if (!change.maybeMoved.has(node)) + return false; + + var didMove = change.moved.get(node); + if (didMove !== undefined) + return didMove; + + if (pendingMoveDecision.has(node)) { + didMove = true; + } else { + pendingMoveDecision.set(node, true); + didMove = getPrevious(node) !== getOldPrevious(node); + } + + if (pendingMoveDecision.has(node)) { + pendingMoveDecision.delete(node); + change.moved.set(node, didMove); + } else { + didMove = change.moved.get(node); + } + + return didMove; + } + + var oldPreviousCache = new NodeMap(); + function getOldPrevious(node:Node):Node { + var oldPrevious = oldPreviousCache.get(node); + if (oldPrevious !== undefined) + return oldPrevious; + + oldPrevious = change.oldPrevious.get(node); + while (oldPrevious && + (change.removed.has(oldPrevious) || isMoved(oldPrevious))) { + oldPrevious = getOldPrevious(oldPrevious); + } + + if (oldPrevious === undefined) + oldPrevious = node.previousSibling; + oldPreviousCache.set(node, oldPrevious); + + return oldPrevious; + } + + var previousCache = new NodeMap(); + function getPrevious(node:Node):Node { + if (previousCache.has(node)) + return previousCache.get(node); + + var previous = node.previousSibling; + while (previous && (change.added.has(previous) || isMoved(previous))) + previous = previous.previousSibling; + + previousCache.set(node, previous); + return previous; + } + + change.maybeMoved.keys().forEach(isMoved); + return change.moved.get(node); + } +} + +class Summary { + public added:Node[]; + public removed:Node[]; + public reparented:Node[]; + public reordered:Node[]; + public valueChanged:Node[]; + public attributeChanged:StringMap; + public characterDataChanged:Node[]; + + constructor(private projection:MutationProjection, query:Query) { + this.added = []; + this.removed = []; + this.reparented = query.all || query.element || query.characterData ? [] : undefined; + this.reordered = query.all ? [] : undefined; + + projection.getChanged(this, query.elementFilter, query.characterData); + + if (query.all || query.attribute || query.attributeList) { + var filter = query.attribute ? [ query.attribute ] : query.attributeList; + var attributeChanged = projection.attributeChangedNodes(filter); + + if (query.attribute) { + this.valueChanged = attributeChanged[query.attribute] || []; + } else { + this.attributeChanged = attributeChanged; + if (query.attributeList) { + query.attributeList.forEach((attrName) => { + if (!this.attributeChanged.hasOwnProperty(attrName)) + this.attributeChanged[attrName] = []; + }); + } + } + } + + if (query.all || query.characterData) { + var characterDataChanged = projection.getCharacterDataChanged() + + if (query.characterData) + this.valueChanged = characterDataChanged; + else + this.characterDataChanged = characterDataChanged; + } + + if (this.reordered) + this.getOldPreviousSibling = projection.getOldPreviousSibling.bind(projection); + } + + getOldParentNode(node:Node):Node { + return this.projection.getOldParentNode(node); + } + + getOldAttribute(node:Node, name: string):string { + return this.projection.getOldAttribute(node, name); + } + + getOldCharacterData(node:Node):string { + return this.projection.getOldCharacterData(node); + } + + getOldPreviousSibling(node:Node):Node { + return this.projection.getOldPreviousSibling(node); + } +} + +// TODO(rafaelw): Allow ':' and '.' as valid name characters. +var validNameInitialChar = /[a-zA-Z_]+/; +var validNameNonInitialChar = /[a-zA-Z0-9_\-]+/; + +// TODO(rafaelw): Consider allowing backslash in the attrValue. +// TODO(rafaelw): There's got a to be way to represent this state machine +// more compactly??? + +function escapeQuotes(value:string):string { + return '"' + value.replace(/"/, '\\\"') + '"'; +} + +class Qualifier { + public attrName:string; + public attrValue:string; + public contains:boolean; + + constructor() {} + + public matches(oldValue:string):boolean { + if (oldValue === null) + return false; + + if (this.attrValue === undefined) + return true; + + if (!this.contains) + return this.attrValue == oldValue; + + var tokens = oldValue.split(' '); + for (var i = 0; i < tokens.length; i++) { + if (this.attrValue === tokens[i]) + return true; + } + + return false; + } + + public toString():string { + if (this.attrName === 'class' && this.contains) + return '.' + this.attrValue; + + if (this.attrName === 'id' && !this.contains) + return '#' + this.attrValue; + + if (this.contains) + return '[' + this.attrName + '~=' + escapeQuotes(this.attrValue) + ']'; + + if ('attrValue' in this) + return '[' + this.attrName + '=' + escapeQuotes(this.attrValue) + ']'; + + return '[' + this.attrName + ']'; + } +} + +class Selector { + private static nextUid:number = 1; + private static matchesSelector:string = (function(){ + var element = document.createElement('div'); + if (typeof element['webkitMatchesSelector'] === 'function') + return 'webkitMatchesSelector'; + if (typeof element['mozMatchesSelector'] === 'function') + return 'mozMatchesSelector'; + if (typeof element['msMatchesSelector'] === 'function') + return 'msMatchesSelector'; + + return 'matchesSelector'; + })(); + + public tagName:string; + public qualifiers:Qualifier[]; + public uid:number; + + private get caseInsensitiveTagName():string { + return this.tagName.toUpperCase(); + } + + get selectorString() { + return this.tagName + this.qualifiers.join(''); + } + + constructor() { + this.uid = Selector.nextUid++; + this.qualifiers = []; + } + + private isMatching(el:Element):boolean { + return el[Selector.matchesSelector](this.selectorString); + } + + private wasMatching(el:Element, change:NodeChange, isMatching:boolean):boolean { + if (!change || !change.attributes) + return isMatching; + + var tagName = change.isCaseInsensitive ? this.caseInsensitiveTagName : this.tagName; + if (tagName !== '*' && tagName !== el.tagName) + return false; + + var attributeOldValues:string[] = []; + var anyChanged = false; + for (var i = 0; i < this.qualifiers.length; i++) { + var qualifier = this.qualifiers[i]; + var oldValue = change.getAttributeOldValue(qualifier.attrName); + attributeOldValues.push(oldValue); + anyChanged = anyChanged || (oldValue !== undefined); + } + + if (!anyChanged) + return isMatching; + + for (var i = 0; i < this.qualifiers.length; i++) { + var qualifier = this.qualifiers[i]; + var oldValue = attributeOldValues[i]; + if (oldValue === undefined) + oldValue = el.getAttribute(qualifier.attrName); + if (!qualifier.matches(oldValue)) + return false; + } + + return true; + } + + public matchabilityChange(el:Element, change:NodeChange):Movement { + var isMatching = this.isMatching(el); + if (isMatching) + return this.wasMatching(el, change, isMatching) ? Movement.STAYED_IN : Movement.ENTERED; + else + return this.wasMatching(el, change, isMatching) ? Movement.EXITED : Movement.STAYED_OUT; + } + + public static parseSelectors(input:string):Selector[] { + var selectors:Selector[] = []; + var currentSelector:Selector; + var currentQualifier:Qualifier; + + function newSelector() { + if (currentSelector) { + if (currentQualifier) { + currentSelector.qualifiers.push(currentQualifier); + currentQualifier = undefined; + } + + selectors.push(currentSelector); + } + currentSelector = new Selector(); + } + + function newQualifier() { + if (currentQualifier) + currentSelector.qualifiers.push(currentQualifier); + + currentQualifier = new Qualifier(); + } + + var WHITESPACE = /\s/; + var valueQuoteChar:string; + var SYNTAX_ERROR = 'Invalid or unsupported selector syntax.'; + + var SELECTOR = 1; + var TAG_NAME = 2; + var QUALIFIER = 3; + var QUALIFIER_NAME_FIRST_CHAR = 4; + var QUALIFIER_NAME = 5; + var ATTR_NAME_FIRST_CHAR = 6; + var ATTR_NAME = 7; + var EQUIV_OR_ATTR_QUAL_END = 8; + var EQUAL = 9; + var ATTR_QUAL_END = 10; + var VALUE_FIRST_CHAR = 11; + var VALUE = 12; + var QUOTED_VALUE = 13; + var SELECTOR_SEPARATOR = 14; + + var state = SELECTOR; + var i = 0; + while (i < input.length) { + var c = input[i++]; + + switch (state) { + case SELECTOR: + if (c.match(validNameInitialChar)) { + newSelector(); + currentSelector.tagName = c; + state = TAG_NAME; + break; + } + + if (c == '*') { + newSelector(); + currentSelector.tagName = '*'; + state = QUALIFIER; + break; + } + + if (c == '.') { + newSelector(); + newQualifier(); + currentSelector.tagName = '*'; + currentQualifier.attrName = 'class'; + currentQualifier.contains = true; + state = QUALIFIER_NAME_FIRST_CHAR; + break; + } + if (c == '#') { + newSelector(); + newQualifier(); + currentSelector.tagName = '*'; + currentQualifier.attrName = 'id'; + state = QUALIFIER_NAME_FIRST_CHAR; + break; + } + if (c == '[') { + newSelector(); + newQualifier(); + currentSelector.tagName = '*'; + currentQualifier.attrName = ''; + state = ATTR_NAME_FIRST_CHAR; + break; + } + + if (c.match(WHITESPACE)) + break; + + throw Error(SYNTAX_ERROR); + + case TAG_NAME: + if (c.match(validNameNonInitialChar)) { + currentSelector.tagName += c; + break; + } + + if (c == '.') { + newQualifier(); + currentQualifier.attrName = 'class'; + currentQualifier.contains = true; + state = QUALIFIER_NAME_FIRST_CHAR; + break; + } + if (c == '#') { + newQualifier(); + currentQualifier.attrName = 'id'; + state = QUALIFIER_NAME_FIRST_CHAR; + break; + } + if (c == '[') { + newQualifier(); + currentQualifier.attrName = ''; + state = ATTR_NAME_FIRST_CHAR; + break; + } + + if (c.match(WHITESPACE)) { + state = SELECTOR_SEPARATOR; + break; + } + + if (c == ',') { + state = SELECTOR; + break; + } + + throw Error(SYNTAX_ERROR); + + case QUALIFIER: + if (c == '.') { + newQualifier(); + currentQualifier.attrName = 'class'; + currentQualifier.contains = true; + state = QUALIFIER_NAME_FIRST_CHAR; + break; + } + if (c == '#') { + newQualifier(); + currentQualifier.attrName = 'id'; + state = QUALIFIER_NAME_FIRST_CHAR; + break; + } + if (c == '[') { + newQualifier(); + currentQualifier.attrName = ''; + state = ATTR_NAME_FIRST_CHAR; + break; + } + + if (c.match(WHITESPACE)) { + state = SELECTOR_SEPARATOR; + break; + } + + if (c == ',') { + state = SELECTOR; + break; + } + + throw Error(SYNTAX_ERROR); + + case QUALIFIER_NAME_FIRST_CHAR: + if (c.match(validNameInitialChar)) { + currentQualifier.attrValue = c; + state = QUALIFIER_NAME; + break; + } + + throw Error(SYNTAX_ERROR); + + case QUALIFIER_NAME: + if (c.match(validNameNonInitialChar)) { + currentQualifier.attrValue += c; + break; + } + + if (c == '.') { + newQualifier(); + currentQualifier.attrName = 'class'; + currentQualifier.contains = true; + state = QUALIFIER_NAME_FIRST_CHAR; + break; + } + if (c == '#') { + newQualifier(); + currentQualifier.attrName = 'id'; + state = QUALIFIER_NAME_FIRST_CHAR; + break; + } + if (c == '[') { + newQualifier(); + state = ATTR_NAME_FIRST_CHAR; + break; + } + + if (c.match(WHITESPACE)) { + state = SELECTOR_SEPARATOR; + break; + } + if (c == ',') { + state = SELECTOR; + break + } + + throw Error(SYNTAX_ERROR); + + case ATTR_NAME_FIRST_CHAR: + if (c.match(validNameInitialChar)) { + currentQualifier.attrName = c; + state = ATTR_NAME; + break; + } + + if (c.match(WHITESPACE)) + break; + + throw Error(SYNTAX_ERROR); + + case ATTR_NAME: + if (c.match(validNameNonInitialChar)) { + currentQualifier.attrName += c; + break; + } + + if (c.match(WHITESPACE)) { + state = EQUIV_OR_ATTR_QUAL_END; + break; + } + + if (c == '~') { + currentQualifier.contains = true; + state = EQUAL; + break; + } + + if (c == '=') { + currentQualifier.attrValue = ''; + state = VALUE_FIRST_CHAR; + break; + } + + if (c == ']') { + state = QUALIFIER; + break; + } + + throw Error(SYNTAX_ERROR); + + case EQUIV_OR_ATTR_QUAL_END: + if (c == '~') { + currentQualifier.contains = true; + state = EQUAL; + break; + } + + if (c == '=') { + currentQualifier.attrValue = ''; + state = VALUE_FIRST_CHAR; + break; + } + + if (c == ']') { + state = QUALIFIER; + break; + } + + if (c.match(WHITESPACE)) + break; + + throw Error(SYNTAX_ERROR); + + case EQUAL: + if (c == '=') { + currentQualifier.attrValue = ''; + state = VALUE_FIRST_CHAR + break; + } + + throw Error(SYNTAX_ERROR); + + case ATTR_QUAL_END: + if (c == ']') { + state = QUALIFIER; + break; + } + + if (c.match(WHITESPACE)) + break; + + throw Error(SYNTAX_ERROR); + + case VALUE_FIRST_CHAR: + if (c.match(WHITESPACE)) + break; + + if (c == '"' || c == "'") { + valueQuoteChar = c; + state = QUOTED_VALUE; + break; + } + + currentQualifier.attrValue += c; + state = VALUE; + break; + + case VALUE: + if (c.match(WHITESPACE)) { + state = ATTR_QUAL_END; + break; + } + if (c == ']') { + state = QUALIFIER; + break; + } + if (c == "'" || c == '"') + throw Error(SYNTAX_ERROR); + + currentQualifier.attrValue += c; + break; + + case QUOTED_VALUE: + if (c == valueQuoteChar) { + state = ATTR_QUAL_END; + break; + } + + currentQualifier.attrValue += c; + break; + + case SELECTOR_SEPARATOR: + if (c.match(WHITESPACE)) + break; + + if (c == ',') { + state = SELECTOR; + break + } + + throw Error(SYNTAX_ERROR); + } + } + + switch (state) { + case SELECTOR: + case TAG_NAME: + case QUALIFIER: + case QUALIFIER_NAME: + case SELECTOR_SEPARATOR: + // Valid end states. + newSelector(); + break; + default: + throw Error(SYNTAX_ERROR); + } + + if (!selectors.length) + throw Error(SYNTAX_ERROR); + + return selectors; + } +} + +var attributeFilterPattern = /^([a-zA-Z:_]+[a-zA-Z0-9_\-:\.]*)$/; + +function validateAttribute(attribute:string) { + if (typeof attribute != 'string') + throw Error('Invalid request opion. attribute must be a non-zero length string.'); + + attribute = attribute.trim(); + + if (!attribute) + throw Error('Invalid request opion. attribute must be a non-zero length string.'); + + + if (!attribute.match(attributeFilterPattern)) + throw Error('Invalid request option. invalid attribute name: ' + attribute); + + return attribute; +} + +function validateElementAttributes(attribs:string):string[] { + if (!attribs.trim().length) + throw Error('Invalid request option: elementAttributes must contain at least one attribute.'); + + var lowerAttributes = {}; + var attributes = {}; + + var tokens = attribs.split(/\s+/); + for (var i = 0; i < tokens.length; i++) { + var name = tokens[i]; + if (!name) + continue; + + var name = validateAttribute(name); + var nameLower = name.toLowerCase(); + if (lowerAttributes[nameLower]) + throw Error('Invalid request option: observing multiple case variations of the same attribute is not supported.'); + + attributes[name] = true; + lowerAttributes[nameLower] = true; + } + + return Object.keys(attributes); +} + + + +function elementFilterAttributes(selectors:Selector[]):string[] { + var attributes:StringMap = {}; + + selectors.forEach((selector) => { + selector.qualifiers.forEach((qualifier) => { + attributes[qualifier.attrName] = true; + }); + }); + + return Object.keys(attributes); +} + +interface Query { + element?:string; + attribute?:string; + all?:boolean; + characterData?:boolean; + elementAttributes?:string; + attributeList?:string[]; + elementFilter?:Selector[]; +} + +interface Options { + callback:(summaries:Summary[]) => any; + queries: Query[]; + rootNode?:Node; + oldPreviousSibling?:boolean; + observeOwnChanges?:boolean; +} + +class MutationSummary { + + public static NodeMap = NodeMap; // exposed for use in TreeMirror. + public static parseElementFilter = Selector.parseSelectors; // exposed for testing. + + public static createQueryValidator:(root:Node, query:Query)=>any; + private connected:boolean; + private options:Options; + private observer:MutationObserver; + private observerOptions:MutationObserverInit; + private root:Node; + private callback:(summaries:Summary[])=>any; + private elementFilter:Selector[]; + private calcReordered:boolean; + private queryValidators:any[]; + + private static optionKeys:StringMap = { + 'callback': true, // required + 'queries': true, // required + 'rootNode': true, + 'oldPreviousSibling': true, + 'observeOwnChanges': true + }; + + private static createObserverOptions(queries:Query[]):MutationObserverInit { + var observerOptions:MutationObserverInit = { + childList: true, + subtree: true + }; + + var attributeFilter:StringMap; + function observeAttributes(attributes?:string[]) { + if (observerOptions.attributes && !attributeFilter) + return; // already observing all. + + observerOptions.attributes = true; + observerOptions.attributeOldValue = true; + + if (!attributes) { + // observe all. + attributeFilter = undefined; + return; + } + + // add to observed. + attributeFilter = attributeFilter || {}; + attributes.forEach((attribute) => { + attributeFilter[attribute] = true; + attributeFilter[attribute.toLowerCase()] = true; + }); + } + + queries.forEach((query) => { + if (query.characterData) { + observerOptions.characterData = true; + observerOptions.characterDataOldValue = true; + return; + } + + if (query.all) { + observeAttributes(); + observerOptions.characterData = true; + observerOptions.characterDataOldValue = true; + return; + } + + if (query.attribute) { + observeAttributes([query.attribute.trim()]); + return; + } + + var attributes = elementFilterAttributes(query.elementFilter).concat(query.attributeList || []); + if (attributes.length) + observeAttributes(attributes); + }); + + if (attributeFilter) + observerOptions.attributeFilter = Object.keys(attributeFilter); + + return observerOptions; + } + + private static validateOptions(options:Options):Options { + for (var prop in options) { + if (!(prop in MutationSummary.optionKeys)) + throw Error('Invalid option: ' + prop); + } + + if (typeof options.callback !== 'function') + throw Error('Invalid options: callback is required and must be a function'); + + if (!options.queries || !options.queries.length) + throw Error('Invalid options: queries must contain at least one query request object.'); + + var opts:Options = { + callback: options.callback, + rootNode: options.rootNode || document, + observeOwnChanges: !!options.observeOwnChanges, + oldPreviousSibling: !!options.oldPreviousSibling, + queries: [] + }; + + for (var i = 0; i < options.queries.length; i++) { + var request = options.queries[i]; + + // all + if (request.all) { + if (Object.keys(request).length > 1) + throw Error('Invalid request option. all has no options.'); + + opts.queries.push({all: true}); + continue; + } + + // attribute + if ('attribute' in request) { + var query:Query = { + attribute: validateAttribute(request.attribute) + }; + + query.elementFilter = Selector.parseSelectors('*[' + query.attribute + ']'); + + if (Object.keys(request).length > 1) + throw Error('Invalid request option. attribute has no options.'); + + opts.queries.push(query); + continue; + } + + // element + if ('element' in request) { + var requestOptionCount = Object.keys(request).length; + var query:Query = { + element: request.element, + elementFilter: Selector.parseSelectors(request.element) + }; + + if (request.hasOwnProperty('elementAttributes')) { + query.attributeList = validateElementAttributes(request.elementAttributes); + requestOptionCount--; + } + + if (requestOptionCount > 1) + throw Error('Invalid request option. element only allows elementAttributes option.'); + + opts.queries.push(query); + continue; + } + + // characterData + if (request.characterData) { + if (Object.keys(request).length > 1) + throw Error('Invalid request option. characterData has no options.'); + + opts.queries.push({ characterData: true }); + continue; + } + + throw Error('Invalid request option. Unknown query request.'); + } + + return opts; + } + + private createSummaries(mutations:MutationRecord[]):Summary[] { + if (!mutations || !mutations.length) + return []; + + var projection = new MutationProjection(this.root, mutations, this.elementFilter, this.calcReordered, this.options.oldPreviousSibling); + + var summaries:Summary[] = []; + for (var i = 0; i < this.options.queries.length; i++) { + summaries.push(new Summary(projection, this.options.queries[i])); + } + + return summaries; + } + + private checkpointQueryValidators() { + this.queryValidators.forEach((validator) => { + if (validator) + validator.recordPreviousState(); + }); + } + + private runQueryValidators(summaries:Summary[]) { + this.queryValidators.forEach((validator, index) => { + if (validator) + validator.validate(summaries[index]); + }); + } + + private changesToReport(summaries:Summary[]):boolean { + return summaries.some((summary) => { + var summaryProps = ['added', 'removed', 'reordered', 'reparented', + 'valueChanged', 'characterDataChanged']; + if (summaryProps.some(function(prop) { return summary[prop] && summary[prop].length; })) + return true; + + if (summary.attributeChanged) { + var attrNames = Object.keys(summary.attributeChanged); + var attrsChanged = attrNames.some((attrName) => { + return !!summary.attributeChanged[attrName].length + }); + if (attrsChanged) + return true; + } + return false; + }); + } + + constructor(opts:Options) { + this.connected = false; + this.options = MutationSummary.validateOptions(opts); + this.observerOptions = MutationSummary.createObserverOptions(this.options.queries); + this.root = this.options.rootNode; + this.callback = this.options.callback; + + this.elementFilter = Array.prototype.concat.apply([], this.options.queries.map((query) => { + return query.elementFilter ? query.elementFilter : []; + })); + if (!this.elementFilter.length) + this.elementFilter = undefined; + + this.calcReordered = this.options.queries.some((query) => { + return query.all; + }); + + this.queryValidators = []; // TODO(rafaelw): Shouldn't always define this. + if (MutationSummary.createQueryValidator) { + this.queryValidators = this.options.queries.map((query) => { + return MutationSummary.createQueryValidator(this.root, query); + }); + } + + this.observer = new MutationObserverCtor((mutations:MutationRecord[]) => { + this.observerCallback(mutations); + }); + + this.reconnect(); + } + + private observerCallback(mutations:MutationRecord[]) { + if (!this.options.observeOwnChanges) + this.observer.disconnect(); + + var summaries = this.createSummaries(mutations); + this.runQueryValidators(summaries); + + if (this.options.observeOwnChanges) + this.checkpointQueryValidators(); + + if (this.changesToReport(summaries)) + this.callback(summaries); + + // disconnect() may have been called during the callback. + if (!this.options.observeOwnChanges && this.connected) { + this.checkpointQueryValidators(); + this.observer.observe(this.root, this.observerOptions); + } + } + + reconnect() { + if (this.connected) + throw Error('Already connected'); + + this.observer.observe(this.root, this.observerOptions); + this.connected = true; + this.checkpointQueryValidators(); + } + + takeSummaries():Summary[] { + if (!this.connected) + throw Error('Not connected'); + + var summaries = this.createSummaries(this.observer.takeRecords()); + return this.changesToReport(summaries) ? summaries : undefined; + } + + disconnect():Summary[] { + var summaries = this.takeSummaries(); + this.observer.disconnect(); + this.connected = false; + return summaries; + } +} + diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/libs/mutation-summary/util/tree-mirror.js b/files/plugin-HeatmapSessionRecording-5.6.0/libs/mutation-summary/util/tree-mirror.js new file mode 100644 index 0000000..6d04d33 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/libs/mutation-summary/util/tree-mirror.js @@ -0,0 +1,275 @@ +/// +var TreeMirror = /** @class */ (function () { + function TreeMirror(root, delegate) { + this.root = root; + this.delegate = delegate; + this.idMap = {}; + } + TreeMirror.prototype.initialize = function (rootId, children) { + this.idMap[rootId] = this.root; + for (var i = 0; i < children.length; i++) + this.deserializeNode(children[i], this.root); + }; + TreeMirror.prototype.getNode = function (id) { + return this.idMap[id] || null; + }; + TreeMirror.prototype.applyChanged = function (removed, addedOrMoved, attributes, text) { + var _this = this; + // NOTE: Applying the changes can result in an attempting to add a child + // to a parent which is presently an ancestor of the parent. This can occur + // based on random ordering of moves. The way we handle this is to first + // remove all changed nodes from their parents, then apply. + addedOrMoved.forEach(function (data) { + var node = _this.deserializeNode(data); + var parent = _this.deserializeNode(data.parentNode); + var previous = _this.deserializeNode(data.previousSibling); + if (node.parentNode) + node.parentNode.removeChild(node); + }); + removed.forEach(function (data) { + var node = _this.deserializeNode(data); + if (node.parentNode) + node.parentNode.removeChild(node); + }); + addedOrMoved.forEach(function (data) { + var node = _this.deserializeNode(data); + var parent = _this.deserializeNode(data.parentNode); + var previous = _this.deserializeNode(data.previousSibling); + parent.insertBefore(node, previous ? previous.nextSibling : parent.firstChild); + }); + attributes.forEach(function (data) { + var node = _this.deserializeNode(data); + Object.keys(data.attributes).forEach(function (attrName) { + var newVal = data.attributes[attrName]; + if (newVal === null) { + if (!_this.delegate || + !_this.delegate.removeAttribute || + !_this.delegate.removeAttribute(node, attrName)) { + node.removeAttribute(attrName); + } + } + else { + if (!_this.delegate || + !_this.delegate.setAttribute || + !_this.delegate.setAttribute(node, attrName, newVal)) { + node.setAttribute(attrName, newVal); + } + } + }); + }); + text.forEach(function (data) { + var node = _this.deserializeNode(data); + node.textContent = data.textContent; + }); + removed.forEach(function (node) { + delete _this.idMap[node.id]; + }); + }; + TreeMirror.prototype.deserializeNode = function (nodeData, parent) { + var _this = this; + if (nodeData === null) + return null; + var node = this.idMap[nodeData.id]; + if (node) + return node; + var doc = this.root.ownerDocument; + if (doc === null) + doc = this.root; + switch (nodeData.nodeType) { + case Node.COMMENT_NODE: + node = doc.createComment(nodeData.textContent); + break; + case Node.TEXT_NODE: + node = doc.createTextNode(nodeData.textContent); + break; + case Node.DOCUMENT_TYPE_NODE: + node = doc.implementation.createDocumentType(nodeData.name, nodeData.publicId, nodeData.systemId); + break; + case Node.ELEMENT_NODE: + if (this.delegate && this.delegate.createElement) + node = this.delegate.createElement(nodeData.tagName, nodeData); + if (!node) + try { + node = doc.createElement(nodeData.tagName); + } + catch (e) { + console.log("Removing invalid node", nodeData); + return null; + } + Object.keys(nodeData.attributes).forEach(function (name) { + if (!_this.delegate || + !_this.delegate.setAttribute || + !_this.delegate.setAttribute(node, name, nodeData.attributes[name])) { + try { + node.setAttribute(name, nodeData.attributes[name]); + } + catch (e) { + console.log("Removing node due to invalid attribute", nodeData); + return null; + } + } + }); + break; + } + if (!node) + throw "ouch"; + this.idMap[nodeData.id] = node; + if (parent) + parent.appendChild(node); + if (nodeData.childNodes) { + for (var i = 0; i < nodeData.childNodes.length; i++) + this.deserializeNode(nodeData.childNodes[i], node); + } + return node; + }; + return TreeMirror; +}()); +var TreeMirrorClient = /** @class */ (function () { + function TreeMirrorClient(target, mirror, testingQueries) { + var _this = this; + this.target = target; + this.mirror = mirror; + this.nextId = 1; + this.knownNodes = new MutationSummary.NodeMap(); + var rootId = this.serializeNode(target).id; + var children = []; + for (var child = target.firstChild; child; child = child.nextSibling) + children.push(this.serializeNode(child, true)); + this.mirror.initialize(rootId, children); + var self = this; + var queries = [{ all: true }]; + if (testingQueries) + queries = queries.concat(testingQueries); + this.mutationSummary = new MutationSummary({ + rootNode: target, + callback: function (summaries) { + _this.applyChanged(summaries); + }, + queries: queries + }); + } + TreeMirrorClient.prototype.disconnect = function () { + if (this.mutationSummary) { + this.mutationSummary.disconnect(); + this.mutationSummary = undefined; + } + }; + TreeMirrorClient.prototype.rememberNode = function (node) { + var id = this.nextId++; + this.knownNodes.set(node, id); + return id; + }; + TreeMirrorClient.prototype.forgetNode = function (node) { + this.knownNodes["delete"](node); + }; + TreeMirrorClient.prototype.serializeNode = function (node, recursive) { + if (node === null) + return null; + var id = this.knownNodes.get(node); + if (id !== undefined) { + return { id: id }; + } + var data = { + nodeType: node.nodeType, + id: this.rememberNode(node) + }; + switch (data.nodeType) { + case Node.DOCUMENT_TYPE_NODE: + var docType = node; + data.name = docType.name; + data.publicId = docType.publicId; + data.systemId = docType.systemId; + break; + case Node.COMMENT_NODE: + case Node.TEXT_NODE: + data.textContent = node.textContent; + break; + case Node.ELEMENT_NODE: + var elm = node; + data.tagName = elm.tagName; + data.attributes = {}; + for (var i = 0; i < elm.attributes.length; i++) { + var attr = elm.attributes[i]; + data.attributes[attr.name] = attr.value; + } + if (recursive && elm.childNodes.length) { + data.childNodes = []; + for (var child = elm.firstChild; child; child = child.nextSibling) + data.childNodes.push(this.serializeNode(child, true)); + } + break; + } + return data; + }; + TreeMirrorClient.prototype.serializeAddedAndMoved = function (added, reparented, reordered) { + var _this = this; + var all = added.concat(reparented).concat(reordered); + var parentMap = new MutationSummary.NodeMap(); + all.forEach(function (node) { + var parent = node.parentNode; + var children = parentMap.get(parent); + if (!children) { + children = new MutationSummary.NodeMap(); + parentMap.set(parent, children); + } + children.set(node, true); + }); + var moved = []; + parentMap.keys().forEach(function (parent) { + var children = parentMap.get(parent); + var keys = children.keys(); + while (keys.length) { + var node = keys[0]; + while (node.previousSibling && children.has(node.previousSibling)) + node = node.previousSibling; + while (node && children.has(node)) { + var data = _this.serializeNode(node); + data.previousSibling = _this.serializeNode(node.previousSibling); + data.parentNode = _this.serializeNode(node.parentNode); + moved.push(data); + children["delete"](node); + node = node.nextSibling; + } + var keys = children.keys(); + } + }); + return moved; + }; + TreeMirrorClient.prototype.serializeAttributeChanges = function (attributeChanged) { + var _this = this; + var map = new MutationSummary.NodeMap(); + Object.keys(attributeChanged).forEach(function (attrName) { + attributeChanged[attrName].forEach(function (element) { + var record = map.get(element); + if (!record) { + record = _this.serializeNode(element); + record.attributes = {}; + map.set(element, record); + } + record.attributes[attrName] = element.getAttribute(attrName); + }); + }); + return map.keys().map(function (node) { + return map.get(node); + }); + }; + TreeMirrorClient.prototype.applyChanged = function (summaries) { + var _this = this; + var summary = summaries[0]; + var removed = summary.removed.map(function (node) { + return _this.serializeNode(node); + }); + var moved = this.serializeAddedAndMoved(summary.added, summary.reparented, summary.reordered); + var attributes = this.serializeAttributeChanges(summary.attributeChanged); + var text = summary.characterDataChanged.map(function (node) { + var data = _this.serializeNode(node); + data.textContent = node.textContent; + return data; + }); + this.mirror.applyChanged(removed, moved, attributes, text); + summary.removed.forEach(function (node) { + _this.forgetNode(node); + }); + }; + return TreeMirrorClient; +}()); diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/libs/mutation-summary/util/tree-mirror.ts b/files/plugin-HeatmapSessionRecording-5.6.0/libs/mutation-summary/util/tree-mirror.ts new file mode 100644 index 0000000..42390df --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/libs/mutation-summary/util/tree-mirror.ts @@ -0,0 +1,383 @@ +/// + +// Copyright 2013 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +interface NodeData { + id:number; + nodeType?:number; + name?:string; + publicId?:string; + systemId?:string; + textContent?:string; + tagName?:string; + attributes?:StringMap; + childNodes?:NodeData[]; +} + +interface PositionData extends NodeData { + previousSibling:NodeData; + parentNode:NodeData; +} + +interface AttributeData extends NodeData { + attributes:StringMap; +} + +interface TextData extends NodeData{ + textContent:string; +} + +class TreeMirror { + + private idMap:NumberMap; + + constructor(public root:Node, public delegate?:any) { + this.idMap = {}; + } + + initialize(rootId:number, children:NodeData[]) { + this.idMap[rootId] = this.root; + + for (var i = 0; i < children.length; i++) + this.deserializeNode(children[i], this.root); + } + + getNode(id:number):Node { + return this.idMap[id] || null; + } + + applyChanged(removed:NodeData[], + addedOrMoved:PositionData[], + attributes:AttributeData[], + text:TextData[]) { + + // NOTE: Applying the changes can result in an attempting to add a child + // to a parent which is presently an ancestor of the parent. This can occur + // based on random ordering of moves. The way we handle this is to first + // remove all changed nodes from their parents, then apply. + addedOrMoved.forEach((data:PositionData) => { + var node = this.deserializeNode(data); + var parent = this.deserializeNode(data.parentNode); + var previous = this.deserializeNode(data.previousSibling); + if (node.parentNode) + node.parentNode.removeChild(node); + }); + + removed.forEach((data:NodeData) => { + var node = this.deserializeNode(data); + if (node.parentNode) + node.parentNode.removeChild(node); + }); + + addedOrMoved.forEach((data:PositionData) => { + var node = this.deserializeNode(data); + var parent = this.deserializeNode(data.parentNode); + var previous = this.deserializeNode(data.previousSibling); + parent.insertBefore(node, + previous ? previous.nextSibling : parent.firstChild); + }); + + attributes.forEach((data:AttributeData) => { + var node = this.deserializeNode(data); + Object.keys(data.attributes).forEach((attrName) => { + var newVal = data.attributes[attrName]; + if (newVal === null) { + if (!this.delegate || + !this.delegate.removeAttribute || + !this.delegate.removeAttribute(node, attrName)) { + node.removeAttribute(attrName); + } + } else { + if (!this.delegate || + !this.delegate.setAttribute || + !this.delegate.setAttribute(node, attrName, newVal)) { + node.setAttribute(attrName, newVal); + } + } + }); + }); + + text.forEach((data:TextData) => { + var node = this.deserializeNode(data); + node.textContent = data.textContent; + }); + + removed.forEach((node:NodeData) => { + delete this.idMap[node.id]; + }); + } + + private deserializeNode(nodeData:NodeData, parent?:Element):Node { + if (nodeData === null) + return null; + + var node:Node = this.idMap[nodeData.id]; + if (node) + return node; + + var doc = this.root.ownerDocument; + if (doc === null) + doc = this.root; + + switch(nodeData.nodeType) { + case Node.COMMENT_NODE: + node = doc.createComment(nodeData.textContent); + break; + + case Node.TEXT_NODE: + node = doc.createTextNode(nodeData.textContent); + break; + + case Node.DOCUMENT_TYPE_NODE: + node = doc.implementation.createDocumentType(nodeData.name, nodeData.publicId, nodeData.systemId); + break; + + case Node.ELEMENT_NODE: + if (this.delegate && this.delegate.createElement) + node = this.delegate.createElement(nodeData.tagName, nodeData); + if (!node) + try { + node = doc.createElement(nodeData.tagName); + } catch (e) { + console.log("Removing invalid node", nodeData); + return null; + } + + Object.keys(nodeData.attributes).forEach((name) => { + if (!this.delegate || + !this.delegate.setAttribute || + !this.delegate.setAttribute(node, name, nodeData.attributes[name])) { + try { + (node).setAttribute(name, nodeData.attributes[name]); + } catch (e) { + console.log("Removing node due to invalid attribute", nodeData); + return null; + } + } + }); + + break; + } + + if (!node) + throw "ouch"; + + this.idMap[nodeData.id] = node; + + if (parent) + parent.appendChild(node); + + if (nodeData.childNodes) { + for (var i = 0; i < nodeData.childNodes.length; i++) + this.deserializeNode(nodeData.childNodes[i], node); + } + + return node; + } +} + +class TreeMirrorClient { + private nextId:number; + + private mutationSummary:MutationSummary; + private knownNodes:NodeMap; + + constructor(public target:Node, public mirror:any, testingQueries:Query[]) { + this.nextId = 1; + this.knownNodes = new MutationSummary.NodeMap(); + + var rootId = this.serializeNode(target).id; + var children:NodeData[] = []; + for (var child = target.firstChild; child; child = child.nextSibling) + children.push(this.serializeNode(child, true)); + + this.mirror.initialize(rootId, children); + + var self = this; + + var queries = [{ all: true }]; + + if (testingQueries) + queries = queries.concat(testingQueries); + + this.mutationSummary = new MutationSummary({ + rootNode: target, + callback: (summaries:Summary[]) => { + this.applyChanged(summaries); + }, + queries: queries + }); + } + + + disconnect() { + if (this.mutationSummary) { + this.mutationSummary.disconnect(); + this.mutationSummary = undefined; + } + } + + private rememberNode(node:Node):number { + var id = this.nextId++; + this.knownNodes.set(node, id); + return id; + } + + private forgetNode(node:Node) { + this.knownNodes.delete(node); + } + + private serializeNode(node:Node, recursive?:boolean):NodeData { + if (node === null) + return null; + + var id = this.knownNodes.get(node); + if (id !== undefined) { + return { id: id }; + } + + var data:NodeData = { + nodeType: node.nodeType, + id: this.rememberNode(node) + }; + + switch(data.nodeType) { + case Node.DOCUMENT_TYPE_NODE: + var docType = node; + data.name = docType.name; + data.publicId = docType.publicId; + data.systemId = docType.systemId; + break; + + case Node.COMMENT_NODE: + case Node.TEXT_NODE: + data.textContent = node.textContent; + break; + + case Node.ELEMENT_NODE: + var elm = node; + data.tagName = elm.tagName; + data.attributes = {}; + for (var i = 0; i < elm.attributes.length; i++) { + var attr = elm.attributes[i]; + data.attributes[attr.name] = attr.value; + } + + if (recursive && elm.childNodes.length) { + data.childNodes = []; + + for (var child = elm.firstChild; child; child = child.nextSibling) + data.childNodes.push(this.serializeNode(child, true)); + } + break; + } + + return data; + } + + private serializeAddedAndMoved(added:Node[], + reparented:Node[], + reordered:Node[]):PositionData[] { + var all = added.concat(reparented).concat(reordered); + + var parentMap = new MutationSummary.NodeMap>(); + + all.forEach((node) => { + var parent = node.parentNode; + var children = parentMap.get(parent) + if (!children) { + children = new MutationSummary.NodeMap(); + parentMap.set(parent, children); + } + + children.set(node, true); + }); + + var moved:PositionData[] = []; + + parentMap.keys().forEach((parent) => { + var children = parentMap.get(parent); + + var keys = children.keys(); + while (keys.length) { + var node = keys[0]; + while (node.previousSibling && children.has(node.previousSibling)) + node = node.previousSibling; + + while (node && children.has(node)) { + var data = this.serializeNode(node); + data.previousSibling = this.serializeNode(node.previousSibling); + data.parentNode = this.serializeNode(node.parentNode); + moved.push(data); + children.delete(node); + node = node.nextSibling; + } + + var keys = children.keys(); + } + }); + + return moved; + } + + private serializeAttributeChanges(attributeChanged:StringMap):AttributeData[] { + var map = new MutationSummary.NodeMap(); + + Object.keys(attributeChanged).forEach((attrName) => { + attributeChanged[attrName].forEach((element) => { + var record = map.get(element); + if (!record) { + record = this.serializeNode(element); + record.attributes = {}; + map.set(element, record); + } + + record.attributes[attrName] = element.getAttribute(attrName); + }); + }); + + return map.keys().map((node:Node) => { + return map.get(node); + }); + } + + applyChanged(summaries:Summary[]) { + var summary:Summary = summaries[0] + + var removed:NodeData[] = summary.removed.map((node:Node) => { + return this.serializeNode(node); + }); + + var moved:PositionData[] = + this.serializeAddedAndMoved(summary.added, + summary.reparented, + summary.reordered); + + var attributes:AttributeData[] = + this.serializeAttributeChanges(summary.attributeChanged); + + var text:TextData[] = summary.characterDataChanged.map((node:Node) => { + var data = this.serializeNode(node); + data.textContent = node.textContent; + return data; + }); + + this.mirror.applyChanged(removed, moved, attributes, text); + + summary.removed.forEach((node:Node) => { + this.forgetNode(node); + }); + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/libs/svg.js/CHANGELOG.md b/files/plugin-HeatmapSessionRecording-5.6.0/libs/svg.js/CHANGELOG.md new file mode 100644 index 0000000..6d9eb1e --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/libs/svg.js/CHANGELOG.md @@ -0,0 +1,642 @@ +# Change Log + +All notable changes to this project will be documented in this file. + +The document follows the conventions described in [“Keep a CHANGELOG”](http://keepachangelog.com). + + +==== + + +## UNRELEASED 3.0.0 + +### Added +- added `'random'` option and `randomize()` method to `SVG.Color` -> __TODO!__ +- added `precision()` method to round numeric element attributes -> __TODO!__ +- added specs for `SVG.FX` -> __TODO!__ + +### Changed +- made transform-methods relative as default (breaking change) +- changed SVG() to use querySelector instead of getElementById (breaking change) -> __TODO!__ +- made `parents()` method on `SVG.Element` return an instance of SVG.Set (breaking change) -> __TODO!__ +- replaced static reference to `masker` in `SVG.Mask` with the `masker()` method (breaking change) -> __TODO!__ +- replaced static reference to `clipper` in `SVG.ClipPath` with the `clipper()` method (breaking change) -> __TODO!__ +- replaced static reference to `targets` in `SVG.Mask` and `SVG.ClipPath` with the `targets()` method (breaking change) -> __TODO!__ +- moved all regexes to `SVG.regex` (in color, element, pointarray, style, transform and viewbox) -> __TODO!__ + +### Fixed +- fixed a bug in clipping and masking where empty nodes persists after removal -> __TODO!__ +- fixed a bug in IE11 with `mouseenter` and `mouseleave` -> __TODO!__ + + +## [2.6.1] - 2017-04-25 + +### Fixed +- fixed a bug in path parser which made it stop parsing when hitting z command (#665) + +## [2.6.0] - 2017-04-21 + +### Added +- added `options` object to `SVG.on()` and `el.on()` (#661) + +### Changed +- back to sloppy mode because of problems with plugins (#660) + + +## [2.5.3] - 2017-04-15 + +### Added +- added gitter badge in readme + + +### Fixed +- fixed svg.js.d.ts (#644 #648) +- fixed bug in `el.flip()` which causes an error when calling flip without any argument + +### Removed +- component.json (#652) + + +## [2.5.2] - 2017-04-11 + +### Changed +- SVG.js is now running in strict mode + +### Fixed +- `clear()` does not remove the parser in svg documents anymore +- `len` not declared in FX module, making it a global variable (9737e8a) +- `bbox` not declared in SVG.Box.transform in the Box module (131df0f) +- `namespace` not declared in the Event module (e89c97e) + + +## [2.5.1] - 2017-03-27 + +### Changed +- make svgjs ready to be used on the server + +### Fixed +- fixed `SVG.PathArray.parse` that did not correctly parsed flat arrays +- prevented unnecessary parsing of point or path strings + + +## [2.5.0] - 2017-03-10 + +### Added +- added a plot and array method to `SVG.TextPath` (#582) +- added `clone()` method to `SVG.Array/PointArray/PathArray` (#590) +- added `font()` method to `SVG.Tspan` +- added `SVG.Box()` +- added `transform()` method to boxes +- added `event()` to `SVG.Element` to retrieve the event that was fired last on the element (#550) + +### Changed +- changed CHANGELOG to follow the conventions described in [“Keep a CHANGELOG”](http://keepachangelog.com) (#578) +- make the method plot a getter when no parameter is passed for `SVG.Polyline`,`SVG.Polygon`, `SVG.Line`, `SVG.Path` (related #547) +- allow `SVG.PointArray` to be passed flat array +- change the regexp `SVG.PointArray` use to parse string to allow more flexibility in the way spaces and commas can be used +- allow `plot` to be called with 4 parameters when animating an `SVG.Line` +- relative value for `SVG.Number` are now calculated in its `morph` method (related #547) +- clean up the implementation of the `initAnimation` method of the FX module (#547, #552, #584) +- deprecated `.tbox()`. `.tbox()` now map to `.rbox()`. If you are using `.tbox()`, you can substitute it with `.rbox()` (#594, #602) +- all boxes now accept 4 values or an object on creation +- `el.rbox()` now always returns the right boxes in screen coordinates and has an additional paramater to transform the box into other coordinate systems +- `font()` method can now be used like `attr()` method (#620) +- events are now cancelable by default (#550) + +### Fixed +- fixed a bug in the plain morphing part of `SVG.MorphObj` that is in the FX module +- fixed bug which produces an error when removing an event from a node which was formerly removed with a global `off()` (#518) +- fixed a bug in `size()` for poly elements when their height/width is zero (#505) +- viewbox now also accepts strings and arrays as constructor arguments +- `SVG.Array` now accepts a comma seperated string and returns array of numbers instead of strings +- `SVG.Matrix` now accepts an array as input +- `SVG.Element.matrix()` now accepts also 6 values +- `dx()/dy()` now accepts percentage values, too but only if the value on the element is already percentage +- `flip()` now flips on both axis when no parameter is passed +- fixed bug with `documentElement.contains()` in IE +- fixed offset produced by svg parser (#553) +- fixed a bug with clone which didnt copy over dom data (#621) + + +## [2.4.0] - 2017-01-14 + +### Added +- added support for basic path animations (#561) + + +## [2.3.7] - 2017-01-14 + +### Added +- added code coverage https://coveralls.io/github/svgdotjs/svg.js (3e614d4) +- added `npm run test:quick` which aim at being fast rather than correct - great for git hooks (981ce24) + +### Changed +- moved project to [svgdotjs](https://github.com/svgdotjs) +- made matrixify work with transformation chain separated by commas (#543) +- updated dev dependencies; request and gulp-chmod - `npm run build` now requires nodejs 4.x+ + +### Fixed +- fixed `SVG.Matrix.skew()` (#545) +- fixed broken animations, if using polyfills for es6/7 proposals (#504) +- fixed and improved `SVG.FX.dequeue()` (#546) +- fixed an error in `SVG.FX.step`, if custom properties is added to `Array.prototype` (#549) + + +## [2.3.6] - 2016-10-21 + +### Changed +- make SVG.FX.loop modify the last situation instead of the current one (#532) + +### Fixed +- fixed leading and trailing space in SVG.PointArray would return NaN for some points (695f26a) (#529) +- fixed test of `SVG.FX.afterAll` (#534) +- fixed `SVG.FX.speed()` (#536) + + +## [2.3.5] - 2016-10-13 + +### Added +- added automated unit tests via [Travis](https://travis-ci.org/svgdotjs/svg.js) (#527) +- added `npm run build` to build a new version of SVG.js without requiring gulp to be globally installed + +### Changed +- calling `fill()`, `stroke()` without an argument is now a nop +- Polygon now accepts comma less points to achieve parity with Adobe Illustrator (#529) +- updated dependencies + + +## [2.3.4] - 2016-08-04 + +### Changed +- reworked parent module for speed improvemenents +- reworked `filterSVGElements` utility to use a for loop instead of the native filter function + + +## [2.3.3] - 2016-08-02 + +### Added +- add error callback on image loading (#508) + +### Fixed +- fixed bug when getting bbox of text elements which are not in the dom (#514) +- fixed bug when getting bbox of element which is hidden with css (#516) + + +## [2.3.2] - 2016-06-21 + +### Added +- added specs for `SVG.ViewBox` +- added `parent` parameter for `clone()` +- added spec for mentioned issue + +### Fixed +- fixed string parsing in viewbox (#483) +- fixed bbox when element is not in the dom (#480) +- fixed line constructor which doesn't work with Array as input (#487) +- fixed problem in IE with `document.contains` (#490) related to (#480) +- fixed `undo` when undoing transformations (#494) + + +## [2.3.1] - 2016-05-05 + +### Added +- added typings for svg.js (#470) + +### Fixed +- fixed `SVG.morph()` (#473) +- fixed parser error (#471) +- fixed bug in `SVG.Color` with new fx +- fixed `radius()` for circles when animating and other related code (#477) +- fixed bug where `stop(true)` throws an error when element is not animated (#475) +- fixed bug in `add()` when altering svgs with whitespaces +- fixed bug in `SVG.Doc().create` where size was set to 100% even if size was already specified +- fixed bug in `parse()` from `SVG.PathArray` which does not correctly handled `S` and `T` (#485) + + +## [2.3.0] - 2016-03-30 + +### Added +- added `SVG.Point` which serves as Wrapper to the native `SVGPoint` (#437) +- added `element.point(x,y)` which transforms a point from screen coordinates to the elements space (#403) +- added `element.is()` which helps to check for the object instance faster (instanceof check) +- added more fx specs + +### Changed +- textpath now is a parent element, the lines method of text will return the tspans inside the textpath (#450) +- fx module rewritten to support animation chaining and several other stuff (see docs) + +### Fixed +- fixed `svgjs:data` attribute which was not set properly in all browsers (#428) +- fixed `isNumber` and `numberAndUnit` regex (#405) +- fixed error where a parent node is not found when loading an image but the canvas was cleared (#447) +- fixed absolute transformation animations (not perfect but better) +- fixed event listeners which didnt work correctly when identic funtions used + + +## [2.2.5] - 2015-12-29 + +### Added +- added check for existence of node (#431) + +### Changed +- `group.move()` now allows string numbers as input (#433) +- `matrixify()` will not apply the calculated matrix to the node anymore + + +## [2.2.4] - 2015-12-12 + +### Fixed +- fixed `transform()` which returns the matrix values (a-f) now, too (#423) +- double newlines (\n\n) are correctly handled as blank line from `text()` +- fixed use of scrollX vs pageXOffset in `rbox()` (#425) +- fixed target array in mask and clip which was removed instead of reinitialized (#429) + + +## [2.2.3] - 2015-11-30 + +### Fixed +- fixed null check in image (see 2.2.2) +- fixed bug related to the new path parser (see 2.2.2) +- fixed amd loader (#412) + + +## [2.2.2] - 2015-11-28 + +### Added +- added null check in image onload callback (#415) + +### Changed +- documentation rework (#407) [thanks @snowyplover] + +### Fixed +- fixed leading point bug in path parsing (#416) + + +## [2.2.1] - 2015-11-18 + +### Added +- added workaround for `SvgPathSeg` which is removed in Chrome 48 (#409) +- added `gbox()` to group to get bbox with translation included (#405) + +### Fixed +- fixed dom data which was not cleaned up properly (#398) + + +## [2.2.0] - 2015-11-06 + +### Added +- added `ungroup()/flatten()` (#238), `toParent()` and `toDoc()` +- added UMD-Wrapper with possibility to pass custom window object (#352) +- added `morph()` method for paths via plugin [svg.pathmorphing.js](https://github.com/Fuzzyma/svg.pathmorphing.js) +- added support for css selectors within the `parent()` method +- added `parents()` method to get an array of all parenting elements + +### Changed +- svgjs now saves crucial data in the dom before export and restores them when element is adopted + +### Fixed +- fixed pattern and gradient animation (#385) +- fixed mask animation in Firefox (#287) +- fixed return value of `text()` after import/clone (#393) + + +## [2.1.1] - 2015-10-03 + +### Added +- added custom context binding to event callback (default is the element the event is bound to) + + +## [2.1.0] - 2015-09-20 + +### Added +- added transform to pattern and gradients (#383) + +### Fixed +- fixed clone of textnodes (#369) +- fixed transformlists in IE (#372) +- fixed typo that leads to broken gradients (#370) +- fixed animate radius for circles (#367) + + +## [2.0.2] - 2015-06-22 + +### Fixed +- Fixed zoom consideration in circle and ellipse + + +## [2.0.1] - 2015-06-21 + +### Added +- added possibility to remove all events from a certain namespace + +### Fixed +- fixed bug with `doc()` which always should return root svg +- fixed bug in `SVG.FX` when animating with `plot()` + +### Removed +- removed target reference from use which caused bugs in `dmove()` and `use()` with external file +- removed scale consideration in `move()` duo to incompatibilities with other move-functions e.g. in `SVG.PointArray` + + +## [2.0.0] - 2015-06-11 + +### Added +- implemented an SVG adoption system to be able to manipulate existing SVG's not created with svg.js +- added polyfill for IE9 and IE10 custom events [thanks @Fuzzyma] +- added DOM query selector with the `select()` method globally or on parent elements +- added the intentionally neglected `SVG.Circle` element +- added `rx()` and `ry()` to `SVG.Rect`, `SVG.Circle`, `SVG.Ellispe` and `SVG.FX` +- added support to clone manually built text elements +- added `svg.wiml.js` plugin to plugins list +- added `ctm()` method to for matrix-centric transformations +- added `morph()` method to `SVG.Matrix` +- added support for new matrix system to `SVG.FX` +- added `native()` method to elements and matrix to get to the native api +- added `untransform()` method to remove all transformations +- added raw svg import functionality with the `svg()` method +- added coding style description to README +- added reverse functionality for animations +- documented the `situation` object in `SVG.FX` +- added distinction between relative and absolute matrix transformations +- implemented the `element()` method using the `SVG.Bare` class to create elements that are not described by SVG.js +- added `w` and `h` properties as shorthand for `width` and `height` to `SVG.BBox` +- added `SVG.TBox` to get a bounding box that is affected by transformation values +- added event-based or complete detaching of event listeners in `off()` method + +### Changed +- changed `parent` reference on elements to `parent()` method +- using `CustomEvent` instead of `Event` to be able to fire events with a `detail` object [thanks @Fuzzyma] +- renamed `SVG.TSpan` class to `SVG.Tspan` to play nice with the adoption system +- completely reworked `clone()` method to use the adoption system +- completely reworked transformations to be chainable and more true to their nature +- changed `lines` reference to `lines()` on `SVG.Text` +- changed `track` reference to `track()` on `SVG.Text` +- changed `textPath` reference to `textPath()` on `SVG.Text` +- changed `array` reference to `array()` method on `SVG.Polyline`, `SVG.Polygon` and `SVG.Path` +- reworked sup-pixel offset implementation to be more compact +- switched from Ruby's `rake` to Node's `gulp` for building [thanks to Alex Ewerlöf] +- changed `to()` method to `at()` method in `SVG.FX` +- renamed `SVG.SetFX` to `SVG.FX.Set` +- reworked `SVG.Number` to return new instances with calculations rather than itself +- reworked animatable matrix rotations +- removed `SVG.Symbol` but kept the `symbol()` method using the new `element()` method + +### Fixed +- fixed bug in `radius()` method when `y` value equals `0` +- fixed a bug where events are not detached properly + + +## [1.0.0-rc.9] - 2014-06-17 + +### Added +- added `SVG.Marker` +- added `SVG.Symbol` +- added `first()` and `last()` methods to `SVG.Set` +- added `length()` method to `SVG.Text` and `SVG.TSpan` to calculate total text length +- added `reference()` method to get referenced elements from a given attribute value + +### Changed +- `SVG.get()` will now also fetch elements with a `xlink:href="#elementId"` or `url(#elementId)` value given + +### Fixed +- fixed infinite loop in viewbox when element has a percentage width / height [thanks @shabegger] + + +## [1.0.0-rc.8] - 2014-06-12 + +### Fixed +- fixed bug in `SVG.off` +- fixed offset by window scroll position in `rbox()` [thanks @bryhoyt] + + +## [1.0.0-rc.7] - 2014-06-11 + +### Added +- added `classes()`, `hasClass()`, `addClass()`, `removeClass()` and `toggleClass()` [thanks @pklingem] + +### Changed +- binding events listeners to svg.js instance +- calling `after()` when calling `stop(true)` (fulfill flag) [thanks @vird] +- text element fires `rebuild` event whenever the `rebuild()` method is called + +### Fixed +- fixed a bug where `Element#style()` would not save empty values in IE11 [thanks @Shtong] +- fixed `SVG is not defined error` [thanks @anvaka] +- fixed a bug in `move()`on text elements with a string based value +- fix for `text()` method on text element when acting as getter [thanks @Lochemage] +- fix in `style()` method with a css string [thanks @TobiasHeckel] + + +## [1.0.0-rc.6] - 2014-03-03 + +### Added +- added `leading()` method to `SVG.FX` +- added `reverse()` method to `SVG.Array` (and thereby also to `SVG.PointArray` and `SVG.PathArray`) +- added `fulfill` option to `stop()` method in `SVG.FX` to finalise animations +- added more output values to `bbox()` and `rbox()` methods + +### Changed +- fine-tuned text element positioning +- calling `at()` method directly on morphable svg.js instances in `SVG.FX` module +- moved most `_private` methods to local named functions +- moved helpers to a separate file + +### Fixed +- fixed a bug in text `dy()` method + +### Removed +- removed internal representation for `style` + + +## [1.0.0-rc.5] - 2014-02-14 + +### Added +- added `plain()` method to `SVG.Text` element to add plain text content, without tspans +- added `plain()` method to parent elements to create a text element without tspans +- added `build()` to enable/disable build mode + +### Changed +- updated `SVG.TSpan` to accept nested tspan elements, not unlike the `text()` method in `SVG.Text` +- removed the `relative()` method in favour of `dx()`, `dy()` and `dmove()` +- switched form objects to arrays in `SVG.PathArray` for compatibility with other libraries and better performance on parsing and rendering (up-to 48% faster than 1.0.0-rc.4) +- refined docs on element-specific methods and `SVG.PathArray` structure +- reworked `leading()` implementation to be more font-size "aware" +- refactored the `attr` method on `SVG.Element` +- applied Helvetica as default font +- building `SVG.FX` class with `SVG.invent()` function + +### Removed +- removed verbose style application to tspans + + +## [1.0.0-rc.4] - 2014-02-04 + +### Added +- automatic pattern creation by passing an image url or instance as `fill` attribute on elements +- added `loaded()` method to image tag +- added `pointAt()` method to `SVG.Path`, wrapping the native `getPointAtLength()` + +### Changed +- switched to `MAJOR`.`MINOR`.`PATCH` versioning format to play nice with package managers +- made svg.pattern.js part of the core library +- moved `length()` method to sugar module + +### Fixed +- fix in `animate('=').to()` +- fix for arcs in patharray `toString()` method [thanks @dotnetCarpenter] + + +## [v1.0rc3] - 2014-02-03 + +### Added +- added the `SVG.invent` function to ease invention of new elements +- added second values for `animate('2s')` +- added `length()` mehtod to path, wrapping the native `getTotalLength()` + +### Changed +- using `SVG.invent` to generate core shapes as well for leaner code + +### Fixed +- fix for html-less documents +- fix for arcs in patharray `toString()` method + + +## [v1.0rc2] - 2014-02-01 + +### Added +- added `index()` method to `SVG.Parent` and `SVG.Set` +- added `morph()` and `at()` methods to `SVG.Number` for unit morphing + +### Changed +- modified `cx()` and `cy()` methods on elements with native `x`, `y`, `width` and `height` attributes for better performance + + +## [v1.0rc1] - 2014-01-31 + +### Added +- added `SVG.PathArray` for real path transformations +- added `bbox()` method to `SVG.Set` +- added `relative()` method for moves relative to the current position +- added `morph()` and `at()` methods to `SVG.Color` for color morphing + +### Changed +- enabled proportional resizing on `size()` method with `null` for either `width` or `height` values +- moved data module to separate file +- `data()` method now accepts object for for multiple key / value assignments + +### Removed +- removed `unbiased` system for paths + + +## [v0.38] - 2014-01-28 + +### Added +- added `loop()` method to `SVG.FX` + +### Changed +- switched from `setInterval` to `requestAnimFrame` for animations + + +## [v0.37] - 2014-01-26 + +### Added +- added `get()` to `SVG.Set` + +### Changed +- moved `SVG.PointArray` to a separate file + + +## [v0.36] - 2014-01-25 + +### Added +- added `linkTo()`, `addTo()` and `putIn()` methods on `SVG.Element` + +### Changed +- provided more detailed documentation on parent elements + +### Fixed + + +## [v0.35] - 2014-01-23 + +### Added +- added `SVG.A` element with the `link()` + + +## [v0.34] - 2014-01-23 + +### Added +- added `pause()` and `play()` to `SVG.FX` + +### Changed +- storing animation values in `situation` object + + +## [v0.33] - 2014-01-22 + +### Added +- added `has()` method to `SVG.Set` +- added `width()` and `height()` as setter and getter methods on all shapes +- added `replace()` method to elements +- added `radius()` method to `SVG.Rect` and `SVG.Ellipse` +- added reference to parent node in defs + +### Changed +- moved sub-pixel offset fix to be an optional method (e.g. `SVG('drawing').fixSubPixelOffset()`) +- merged plotable.js and path.js + + +## [v0.32] + +### Added +- added library to [cdnjs](http://cdnjs.com) + + + +[2.6.0]: https://github.com/svgdotjs/svg.js/releases/tag/2.6.0 +[2.5.3]: https://github.com/svgdotjs/svg.js/releases/tag/2.5.3 +[2.5.2]: https://github.com/svgdotjs/svg.js/releases/tag/2.5.2 +[2.5.1]: https://github.com/svgdotjs/svg.js/releases/tag/2.5.1 +[2.5.0]: https://github.com/svgdotjs/svg.js/releases/tag/2.5.0 +[2.4.0]: https://github.com/svgdotjs/svg.js/releases/tag/2.4.0 + +[2.3.7]: https://github.com/svgdotjs/svg.js/releases/tag/2.3.7 +[2.3.6]: https://github.com/svgdotjs/svg.js/releases/tag/2.3.6 +[2.3.5]: https://github.com/svgdotjs/svg.js/releases/tag/2.3.5 +[2.3.4]: https://github.com/svgdotjs/svg.js/releases/tag/2.3.4 +[2.3.3]: https://github.com/svgdotjs/svg.js/releases/tag/2.3.3 +[2.3.2]: https://github.com/svgdotjs/svg.js/releases/tag/2.3.2 +[2.3.1]: https://github.com/svgdotjs/svg.js/releases/tag/2.3.1 +[2.3.0]: https://github.com/svgdotjs/svg.js/releases/tag/2.3.0 + +[2.2.5]: https://github.com/svgdotjs/svg.js/releases/tag/2.2.5 +[2.2.4]: https://github.com/svgdotjs/svg.js/releases/tag/2.2.4 +[2.2.3]: https://github.com/svgdotjs/svg.js/releases/tag/2.2.3 +[2.2.2]: https://github.com/svgdotjs/svg.js/releases/tag/2.2.2 +[2.2.1]: https://github.com/svgdotjs/svg.js/releases/tag/2.2.1 +[2.2.0]: https://github.com/svgdotjs/svg.js/releases/tag/2.2.0 + +[2.1.1]: https://github.com/svgdotjs/svg.js/releases/tag/2.1.1 +[2.1.0]: https://github.com/svgdotjs/svg.js/releases/tag/2.1.0 + +[2.0.2]: https://github.com/svgdotjs/svg.js/releases/tag/2.0.2 +[2.0.1]: https://github.com/svgdotjs/svg.js/releases/tag/2.0.1 +[2.0.0]: https://github.com/svgdotjs/svg.js/releases/tag/2.0.0 + +[1.0.0-rc.9]: https://github.com/svgdotjs/svg.js/releases/tag/1.0.0-rc.9 +[1.0.0-rc.8]: https://github.com/svgdotjs/svg.js/releases/tag/1.0.0-rc.8 +[1.0.0-rc.7]: https://github.com/svgdotjs/svg.js/releases/tag/1.0.0-rc.7 +[1.0.0-rc.6]: https://github.com/svgdotjs/svg.js/releases/tag/1.0.0-rc.6 +[1.0.0-rc.5]: https://github.com/svgdotjs/svg.js/releases/tag/1.0.0-rc.5 +[1.0.0-rc.4]: https://github.com/svgdotjs/svg.js/releases/tag/1.0.0-rc.4 +[v1.0rc3]: https://github.com/svgdotjs/svg.js/releases/tag/v1.0rc3 +[v1.0rc2]: https://github.com/svgdotjs/svg.js/releases/tag/v1.0rc2 +[v1.0rc1]: https://github.com/svgdotjs/svg.js/releases/tag/v1.0rc1 + +[v0.38]: https://github.com/svgdotjs/svg.js/releases/tag/v0.38 +[v0.37]: https://github.com/svgdotjs/svg.js/releases/tag/v0.37 +[v0.36]: https://github.com/svgdotjs/svg.js/releases/tag/v0.36 +[v0.35]: https://github.com/svgdotjs/svg.js/releases/tag/v0.35 +[v0.34]: https://github.com/svgdotjs/svg.js/releases/tag/v0.34 +[v0.33]: https://github.com/svgdotjs/svg.js/releases/tag/v0.33 +[v0.32]: https://github.com/svgdotjs/svg.js/releases/tag/v0.32 diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/libs/svg.js/LICENSE.txt b/files/plugin-HeatmapSessionRecording-5.6.0/libs/svg.js/LICENSE.txt new file mode 100644 index 0000000..148b70a --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/libs/svg.js/LICENSE.txt @@ -0,0 +1,21 @@ +Copyright (c) 2012-2017 Wout Fierens +https://svgdotjs.github.io/ + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/libs/svg.js/README.md b/files/plugin-HeatmapSessionRecording-5.6.0/libs/svg.js/README.md new file mode 100644 index 0000000..b88c5a5 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/libs/svg.js/README.md @@ -0,0 +1,29 @@ +# SVG.js + +[![Build Status](https://travis-ci.org/svgdotjs/svg.js.svg?branch=master)](https://travis-ci.org/svgdotjs/svg.js) +[![Coverage Status](https://coveralls.io/repos/github/svgdotjs/svg.js/badge.svg?branch=master)](https://coveralls.io/github/svgdotjs/svg.js?branch=master) +[![CDNJS](https://img.shields.io/cdnjs/v/svg.js.svg)](https://cdnjs.com/libraries/svg.js) +[![Join the chat at https://gitter.im/svgdotjs/svg.js](https://badges.gitter.im/svgdotjs/svg.js.svg)](https://gitter.im/svgdotjs/svg.js?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) + +__A lightweight library for manipulating and animating SVG, without any dependencies.__ + +SVG.js is licensed under the terms of the MIT License. + +## Installation + +#### Bower: + +`bower install svg.js` + +#### Node: + +`npm install svg.js` + +#### Cdnjs: + +[https://cdnjs.com/libraries/svg.js](https://cdnjs.com/libraries/svg.js) + +## Documentation +Check [https://svgdotjs.github.io](https://svgdotjs.github.io/) to learn more. + +[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=pay%40woutfierens.com&lc=US&item_name=SVG.JS¤cy_code=EUR&bn=PP-DonationsBF%3Abtn_donate_74x21.png%3ANonHostedGuest) diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/libs/svg.js/dist/svg.js b/files/plugin-HeatmapSessionRecording-5.6.0/libs/svg.js/dist/svg.js new file mode 100644 index 0000000..d2fd5d3 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/libs/svg.js/dist/svg.js @@ -0,0 +1,5518 @@ +/*! +* svg.js - A lightweight library for manipulating and animating SVG. +* @version 2.6.1 +* https://svgdotjs.github.io/ +* +* @copyright Wout Fierens +* @license MIT +* +* BUILT: Tue Apr 25 2017 11:58:09 GMT+0200 (Mitteleuropäische Sommerzeit) +*/; +(function(root, factory) { + /* istanbul ignore next */ + if (typeof define === 'function' && define.amd) { + define(function(){ + return factory(root, root.document) + }) + } else if (typeof exports === 'object') { + module.exports = root.document ? factory(root, root.document) : function(w){ return factory(w, w.document) } + } else { + root.SVG = factory(root, root.document) + } +}(typeof window !== "undefined" ? window : this, function(window, document) { + +// The main wrapping element +var SVG = this.SVG = function(element) { + if (SVG.supported) { + element = new SVG.Doc(element) + + if(!SVG.parser.draw) + SVG.prepare() + + return element + } +} + +// Default namespaces +SVG.ns = 'http://www.w3.org/2000/svg' +SVG.xmlns = 'http://www.w3.org/2000/xmlns/' +SVG.xlink = 'http://www.w3.org/1999/xlink' +SVG.svgjs = 'http://svgjs.com/svgjs' + +// Svg support test +SVG.supported = (function() { + return !! document.createElementNS && + !! document.createElementNS(SVG.ns,'svg').createSVGRect +})() + +// Don't bother to continue if SVG is not supported +if (!SVG.supported) return false + +// Element id sequence +SVG.did = 1000 + +// Get next named element id +SVG.eid = function(name) { + return 'Svgjs' + capitalize(name) + (SVG.did++) +} + +// Method for element creation +SVG.create = function(name) { + // create element + var element = document.createElementNS(this.ns, name) + + // apply unique id + element.setAttribute('id', this.eid(name)) + + return element +} + +// Method for extending objects +SVG.extend = function() { + var modules, methods, key, i + + // Get list of modules + modules = [].slice.call(arguments) + + // Get object with extensions + methods = modules.pop() + + for (i = modules.length - 1; i >= 0; i--) + if (modules[i]) + for (key in methods) + modules[i].prototype[key] = methods[key] + + // Make sure SVG.Set inherits any newly added methods + if (SVG.Set && SVG.Set.inherit) + SVG.Set.inherit() +} + +// Invent new element +SVG.invent = function(config) { + // Create element initializer + var initializer = typeof config.create == 'function' ? + config.create : + function() { + this.constructor.call(this, SVG.create(config.create)) + } + + // Inherit prototype + if (config.inherit) + initializer.prototype = new config.inherit + + // Extend with methods + if (config.extend) + SVG.extend(initializer, config.extend) + + // Attach construct method to parent + if (config.construct) + SVG.extend(config.parent || SVG.Container, config.construct) + + return initializer +} + +// Adopt existing svg elements +SVG.adopt = function(node) { + // check for presence of node + if (!node) return null + + // make sure a node isn't already adopted + if (node.instance) return node.instance + + // initialize variables + var element + + // adopt with element-specific settings + if (node.nodeName == 'svg') + element = node.parentNode instanceof window.SVGElement ? new SVG.Nested : new SVG.Doc + else if (node.nodeName == 'linearGradient') + element = new SVG.Gradient('linear') + else if (node.nodeName == 'radialGradient') + element = new SVG.Gradient('radial') + else if (SVG[capitalize(node.nodeName)]) + element = new SVG[capitalize(node.nodeName)] + else + element = new SVG.Element(node) + + // ensure references + element.type = node.nodeName + element.node = node + node.instance = element + + // SVG.Class specific preparations + if (element instanceof SVG.Doc) + element.namespace().defs() + + // pull svgjs data from the dom (getAttributeNS doesn't work in html5) + element.setData(JSON.parse(node.getAttribute('svgjs:data')) || {}) + + return element +} + +// Initialize parsing element +SVG.prepare = function() { + // Select document body and create invisible svg element + var body = document.getElementsByTagName('body')[0] + , draw = (body ? new SVG.Doc(body) : SVG.adopt(document.documentElement).nested()).size(2, 0) + + // Create parser object + SVG.parser = { + body: body || document.documentElement + , draw: draw.style('opacity:0;position:absolute;left:-100%;top:-100%;overflow:hidden').node + , poly: draw.polyline().node + , path: draw.path().node + , native: SVG.create('svg') + } +} + +SVG.parser = { + native: SVG.create('svg') +} + +document.addEventListener('DOMContentLoaded', function() { + if(!SVG.parser.draw) + SVG.prepare() +}, false) + +// Storage for regular expressions +SVG.regex = { + // Parse unit value + numberAndUnit: /^([+-]?(\d+(\.\d*)?|\.\d+)(e[+-]?\d+)?)([a-z%]*)$/i + + // Parse hex value +, hex: /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i + + // Parse rgb value +, rgb: /rgb\((\d+),(\d+),(\d+)\)/ + + // Parse reference id +, reference: /#([a-z0-9\-_]+)/i + + // splits a transformation chain +, transforms: /\)\s*,?\s*/ + + // Whitespace +, whitespace: /\s/g + + // Test hex value +, isHex: /^#[a-f0-9]{3,6}$/i + + // Test rgb value +, isRgb: /^rgb\(/ + + // Test css declaration +, isCss: /[^:]+:[^;]+;?/ + + // Test for blank string +, isBlank: /^(\s+)?$/ + + // Test for numeric string +, isNumber: /^[+-]?(\d+(\.\d*)?|\.\d+)(e[+-]?\d+)?$/i + + // Test for percent value +, isPercent: /^-?[\d\.]+%$/ + + // Test for image url +, isImage: /\.(jpg|jpeg|png|gif|svg)(\?[^=]+.*)?/i + + // split at whitespace and comma +, delimiter: /[\s,]+/ + + // The following regex are used to parse the d attribute of a path + + // Matches all hyphens which are not after an exponent +, hyphen: /([^e])\-/gi + + // Replaces and tests for all path letters +, pathLetters: /[MLHVCSQTAZ]/gi + + // yes we need this one, too +, isPathLetter: /[MLHVCSQTAZ]/i + + // matches 0.154.23.45 +, numbersWithDots: /((\d?\.\d+(?:e[+-]?\d+)?)((?:\.\d+(?:e[+-]?\d+)?)+))+/gi + + // matches . +, dots: /\./g +} + +SVG.utils = { + // Map function + map: function(array, block) { + var i + , il = array.length + , result = [] + + for (i = 0; i < il; i++) + result.push(block(array[i])) + + return result + } + + // Filter function +, filter: function(array, block) { + var i + , il = array.length + , result = [] + + for (i = 0; i < il; i++) + if (block(array[i])) + result.push(array[i]) + + return result + } + + // Degrees to radians +, radians: function(d) { + return d % 360 * Math.PI / 180 + } + + // Radians to degrees +, degrees: function(r) { + return r * 180 / Math.PI % 360 + } + +, filterSVGElements: function(nodes) { + return this.filter( nodes, function(el) { return el instanceof window.SVGElement }) + } + +} + +SVG.defaults = { + // Default attribute values + attrs: { + // fill and stroke + 'fill-opacity': 1 + , 'stroke-opacity': 1 + , 'stroke-width': 0 + , 'stroke-linejoin': 'miter' + , 'stroke-linecap': 'butt' + , fill: '#000000' + , stroke: '#000000' + , opacity: 1 + // position + , x: 0 + , y: 0 + , cx: 0 + , cy: 0 + // size + , width: 0 + , height: 0 + // radius + , r: 0 + , rx: 0 + , ry: 0 + // gradient + , offset: 0 + , 'stop-opacity': 1 + , 'stop-color': '#000000' + // text + , 'font-size': 16 + , 'font-family': 'Helvetica, Arial, sans-serif' + , 'text-anchor': 'start' + } + +} +// Module for color convertions +SVG.Color = function(color) { + var match + + // initialize defaults + this.r = 0 + this.g = 0 + this.b = 0 + + if(!color) return + + // parse color + if (typeof color === 'string') { + if (SVG.regex.isRgb.test(color)) { + // get rgb values + match = SVG.regex.rgb.exec(color.replace(SVG.regex.whitespace,'')) + + // parse numeric values + this.r = parseInt(match[1]) + this.g = parseInt(match[2]) + this.b = parseInt(match[3]) + + } else if (SVG.regex.isHex.test(color)) { + // get hex values + match = SVG.regex.hex.exec(fullHex(color)) + + // parse numeric values + this.r = parseInt(match[1], 16) + this.g = parseInt(match[2], 16) + this.b = parseInt(match[3], 16) + + } + + } else if (typeof color === 'object') { + this.r = color.r + this.g = color.g + this.b = color.b + + } + +} + +SVG.extend(SVG.Color, { + // Default to hex conversion + toString: function() { + return this.toHex() + } + // Build hex value +, toHex: function() { + return '#' + + compToHex(this.r) + + compToHex(this.g) + + compToHex(this.b) + } + // Build rgb value +, toRgb: function() { + return 'rgb(' + [this.r, this.g, this.b].join() + ')' + } + // Calculate true brightness +, brightness: function() { + return (this.r / 255 * 0.30) + + (this.g / 255 * 0.59) + + (this.b / 255 * 0.11) + } + // Make color morphable +, morph: function(color) { + this.destination = new SVG.Color(color) + + return this + } + // Get morphed color at given position +, at: function(pos) { + // make sure a destination is defined + if (!this.destination) return this + + // normalise pos + pos = pos < 0 ? 0 : pos > 1 ? 1 : pos + + // generate morphed color + return new SVG.Color({ + r: ~~(this.r + (this.destination.r - this.r) * pos) + , g: ~~(this.g + (this.destination.g - this.g) * pos) + , b: ~~(this.b + (this.destination.b - this.b) * pos) + }) + } + +}) + +// Testers + +// Test if given value is a color string +SVG.Color.test = function(color) { + color += '' + return SVG.regex.isHex.test(color) + || SVG.regex.isRgb.test(color) +} + +// Test if given value is a rgb object +SVG.Color.isRgb = function(color) { + return color && typeof color.r == 'number' + && typeof color.g == 'number' + && typeof color.b == 'number' +} + +// Test if given value is a color +SVG.Color.isColor = function(color) { + return SVG.Color.isRgb(color) || SVG.Color.test(color) +} +// Module for array conversion +SVG.Array = function(array, fallback) { + array = (array || []).valueOf() + + // if array is empty and fallback is provided, use fallback + if (array.length == 0 && fallback) + array = fallback.valueOf() + + // parse array + this.value = this.parse(array) +} + +SVG.extend(SVG.Array, { + // Make array morphable + morph: function(array) { + this.destination = this.parse(array) + + // normalize length of arrays + if (this.value.length != this.destination.length) { + var lastValue = this.value[this.value.length - 1] + , lastDestination = this.destination[this.destination.length - 1] + + while(this.value.length > this.destination.length) + this.destination.push(lastDestination) + while(this.value.length < this.destination.length) + this.value.push(lastValue) + } + + return this + } + // Clean up any duplicate points +, settle: function() { + // find all unique values + for (var i = 0, il = this.value.length, seen = []; i < il; i++) + if (seen.indexOf(this.value[i]) == -1) + seen.push(this.value[i]) + + // set new value + return this.value = seen + } + // Get morphed array at given position +, at: function(pos) { + // make sure a destination is defined + if (!this.destination) return this + + // generate morphed array + for (var i = 0, il = this.value.length, array = []; i < il; i++) + array.push(this.value[i] + (this.destination[i] - this.value[i]) * pos) + + return new SVG.Array(array) + } + // Convert array to string +, toString: function() { + return this.value.join(' ') + } + // Real value +, valueOf: function() { + return this.value + } + // Parse whitespace separated string +, parse: function(array) { + array = array.valueOf() + + // if already is an array, no need to parse it + if (Array.isArray(array)) return array + + return this.split(array) + } + // Strip unnecessary whitespace +, split: function(string) { + return string.trim().split(SVG.regex.delimiter).map(parseFloat) + } + // Reverse array +, reverse: function() { + this.value.reverse() + + return this + } +, clone: function() { + var clone = new this.constructor() + clone.value = array_clone(this.value) + return clone + } +}) +// Poly points array +SVG.PointArray = function(array, fallback) { + SVG.Array.call(this, array, fallback || [[0,0]]) +} + +// Inherit from SVG.Array +SVG.PointArray.prototype = new SVG.Array +SVG.PointArray.prototype.constructor = SVG.PointArray + +SVG.extend(SVG.PointArray, { + // Convert array to string + toString: function() { + // convert to a poly point string + for (var i = 0, il = this.value.length, array = []; i < il; i++) + array.push(this.value[i].join(',')) + + return array.join(' ') + } + // Convert array to line object +, toLine: function() { + return { + x1: this.value[0][0] + , y1: this.value[0][1] + , x2: this.value[1][0] + , y2: this.value[1][1] + } + } + // Get morphed array at given position +, at: function(pos) { + // make sure a destination is defined + if (!this.destination) return this + + // generate morphed point string + for (var i = 0, il = this.value.length, array = []; i < il; i++) + array.push([ + this.value[i][0] + (this.destination[i][0] - this.value[i][0]) * pos + , this.value[i][1] + (this.destination[i][1] - this.value[i][1]) * pos + ]) + + return new SVG.PointArray(array) + } + // Parse point string and flat array +, parse: function(array) { + var points = [] + + array = array.valueOf() + + // if it is an array + if (Array.isArray(array)) { + // and it is not flat, there is no need to parse it + if(Array.isArray(array[0])) { + return array + } + } else { // Else, it is considered as a string + // parse points + array = array.trim().split(SVG.regex.delimiter).map(parseFloat) + } + + // validate points - https://svgwg.org/svg2-draft/shapes.html#DataTypePoints + // Odd number of coordinates is an error. In such cases, drop the last odd coordinate. + if (array.length % 2 !== 0) array.pop() + + // wrap points in two-tuples and parse points as floats + for(var i = 0, len = array.length; i < len; i = i + 2) + points.push([ array[i], array[i+1] ]) + + return points + } + // Move point string +, move: function(x, y) { + var box = this.bbox() + + // get relative offset + x -= box.x + y -= box.y + + // move every point + if (!isNaN(x) && !isNaN(y)) + for (var i = this.value.length - 1; i >= 0; i--) + this.value[i] = [this.value[i][0] + x, this.value[i][1] + y] + + return this + } + // Resize poly string +, size: function(width, height) { + var i, box = this.bbox() + + // recalculate position of all points according to new size + for (i = this.value.length - 1; i >= 0; i--) { + if(box.width) this.value[i][0] = ((this.value[i][0] - box.x) * width) / box.width + box.x + if(box.height) this.value[i][1] = ((this.value[i][1] - box.y) * height) / box.height + box.y + } + + return this + } + // Get bounding box of points +, bbox: function() { + SVG.parser.poly.setAttribute('points', this.toString()) + + return SVG.parser.poly.getBBox() + } +}) + +var pathHandlers = { + M: function(c, p, p0) { + p.x = p0.x = c[0] + p.y = p0.y = c[1] + + return ['M', p.x, p.y] + }, + L: function(c, p) { + p.x = c[0] + p.y = c[1] + return ['L', c[0], c[1]] + }, + H: function(c, p) { + p.x = c[0] + return ['H', c[0]] + }, + V: function(c, p) { + p.y = c[0] + return ['V', c[0]] + }, + C: function(c, p) { + p.x = c[4] + p.y = c[5] + return ['C', c[0], c[1], c[2], c[3], c[4], c[5]] + }, + S: function(c, p) { + p.x = c[2] + p.y = c[3] + return ['S', c[0], c[1], c[2], c[3]] + }, + Q: function(c, p) { + p.x = c[2] + p.y = c[3] + return ['Q', c[0], c[1], c[2], c[3]] + }, + T: function(c, p) { + p.x = c[0] + p.y = c[1] + return ['T', c[0], c[1]] + }, + Z: function(c, p, p0) { + p.x = p0.x + p.y = p0.y + return ['Z'] + }, + A: function(c, p) { + p.x = c[5] + p.y = c[6] + return ['A', c[0], c[1], c[2], c[3], c[4], c[5], c[6]] + } +} + +var mlhvqtcsa = 'mlhvqtcsaz'.split('') + +for(var i = 0, il = mlhvqtcsa.length; i < il; ++i){ + pathHandlers[mlhvqtcsa[i]] = (function(i){ + return function(c, p, p0) { + if(i == 'H') c[0] = c[0] + p.x + else if(i == 'V') c[0] = c[0] + p.y + else if(i == 'A'){ + c[5] = c[5] + p.x, + c[6] = c[6] + p.y + } + else + for(var j = 0, jl = c.length; j < jl; ++j) { + c[j] = c[j] + (j%2 ? p.y : p.x) + } + + return pathHandlers[i](c, p, p0) + } + })(mlhvqtcsa[i].toUpperCase()) +} + +// Path points array +SVG.PathArray = function(array, fallback) { + SVG.Array.call(this, array, fallback || [['M', 0, 0]]) +} + +// Inherit from SVG.Array +SVG.PathArray.prototype = new SVG.Array +SVG.PathArray.prototype.constructor = SVG.PathArray + +SVG.extend(SVG.PathArray, { + // Convert array to string + toString: function() { + return arrayToString(this.value) + } + // Move path string +, move: function(x, y) { + // get bounding box of current situation + var box = this.bbox() + + // get relative offset + x -= box.x + y -= box.y + + if (!isNaN(x) && !isNaN(y)) { + // move every point + for (var l, i = this.value.length - 1; i >= 0; i--) { + l = this.value[i][0] + + if (l == 'M' || l == 'L' || l == 'T') { + this.value[i][1] += x + this.value[i][2] += y + + } else if (l == 'H') { + this.value[i][1] += x + + } else if (l == 'V') { + this.value[i][1] += y + + } else if (l == 'C' || l == 'S' || l == 'Q') { + this.value[i][1] += x + this.value[i][2] += y + this.value[i][3] += x + this.value[i][4] += y + + if (l == 'C') { + this.value[i][5] += x + this.value[i][6] += y + } + + } else if (l == 'A') { + this.value[i][6] += x + this.value[i][7] += y + } + + } + } + + return this + } + // Resize path string +, size: function(width, height) { + // get bounding box of current situation + var i, l, box = this.bbox() + + // recalculate position of all points according to new size + for (i = this.value.length - 1; i >= 0; i--) { + l = this.value[i][0] + + if (l == 'M' || l == 'L' || l == 'T') { + this.value[i][1] = ((this.value[i][1] - box.x) * width) / box.width + box.x + this.value[i][2] = ((this.value[i][2] - box.y) * height) / box.height + box.y + + } else if (l == 'H') { + this.value[i][1] = ((this.value[i][1] - box.x) * width) / box.width + box.x + + } else if (l == 'V') { + this.value[i][1] = ((this.value[i][1] - box.y) * height) / box.height + box.y + + } else if (l == 'C' || l == 'S' || l == 'Q') { + this.value[i][1] = ((this.value[i][1] - box.x) * width) / box.width + box.x + this.value[i][2] = ((this.value[i][2] - box.y) * height) / box.height + box.y + this.value[i][3] = ((this.value[i][3] - box.x) * width) / box.width + box.x + this.value[i][4] = ((this.value[i][4] - box.y) * height) / box.height + box.y + + if (l == 'C') { + this.value[i][5] = ((this.value[i][5] - box.x) * width) / box.width + box.x + this.value[i][6] = ((this.value[i][6] - box.y) * height) / box.height + box.y + } + + } else if (l == 'A') { + // resize radii + this.value[i][1] = (this.value[i][1] * width) / box.width + this.value[i][2] = (this.value[i][2] * height) / box.height + + // move position values + this.value[i][6] = ((this.value[i][6] - box.x) * width) / box.width + box.x + this.value[i][7] = ((this.value[i][7] - box.y) * height) / box.height + box.y + } + + } + + return this + } + // Test if the passed path array use the same path data commands as this path array +, equalCommands: function(pathArray) { + var i, il, equalCommands + + pathArray = new SVG.PathArray(pathArray) + + equalCommands = this.value.length === pathArray.value.length + for(i = 0, il = this.value.length; equalCommands && i < il; i++) { + equalCommands = this.value[i][0] === pathArray.value[i][0] + } + + return equalCommands + } + // Make path array morphable +, morph: function(pathArray) { + pathArray = new SVG.PathArray(pathArray) + + if(this.equalCommands(pathArray)) { + this.destination = pathArray + } else { + this.destination = null + } + + return this + } + // Get morphed path array at given position +, at: function(pos) { + // make sure a destination is defined + if (!this.destination) return this + + var sourceArray = this.value + , destinationArray = this.destination.value + , array = [], pathArray = new SVG.PathArray() + , i, il, j, jl + + // Animate has specified in the SVG spec + // See: https://www.w3.org/TR/SVG11/paths.html#PathElement + for (i = 0, il = sourceArray.length; i < il; i++) { + array[i] = [sourceArray[i][0]] + for(j = 1, jl = sourceArray[i].length; j < jl; j++) { + array[i][j] = sourceArray[i][j] + (destinationArray[i][j] - sourceArray[i][j]) * pos + } + // For the two flags of the elliptical arc command, the SVG spec say: + // Flags and booleans are interpolated as fractions between zero and one, with any non-zero value considered to be a value of one/true + // Elliptical arc command as an array followed by corresponding indexes: + // ['A', rx, ry, x-axis-rotation, large-arc-flag, sweep-flag, x, y] + // 0 1 2 3 4 5 6 7 + if(array[i][0] === 'A') { + array[i][4] = +(array[i][4] != 0) + array[i][5] = +(array[i][5] != 0) + } + } + + // Directly modify the value of a path array, this is done this way for performance + pathArray.value = array + return pathArray + } + // Absolutize and parse path to array +, parse: function(array) { + // if it's already a patharray, no need to parse it + if (array instanceof SVG.PathArray) return array.valueOf() + + // prepare for parsing + var i, x0, y0, s, seg, arr + , x = 0 + , y = 0 + , paramCnt = { 'M':2, 'L':2, 'H':1, 'V':1, 'C':6, 'S':4, 'Q':4, 'T':2, 'A':7, 'Z':0 } + + if(typeof array == 'string'){ + + array = array + .replace(SVG.regex.numbersWithDots, pathRegReplace) // convert 45.123.123 to 45.123 .123 + .replace(SVG.regex.pathLetters, ' $& ') // put some room between letters and numbers + .replace(SVG.regex.hyphen, '$1 -') // add space before hyphen + .trim() // trim + .split(SVG.regex.delimiter) // split into array + + }else{ + array = array.reduce(function(prev, curr){ + return [].concat.call(prev, curr) + }, []) + } + + // array now is an array containing all parts of a path e.g. ['M', '0', '0', 'L', '30', '30' ...] + var arr = [] + , p = new SVG.Point() + , p0 = new SVG.Point() + , index = 0 + , len = array.length + + do{ + // Test if we have a path letter + if(SVG.regex.isPathLetter.test(array[index])){ + s = array[index] + ++index + // If last letter was a move command and we got no new, it defaults to [L]ine + }else if(s == 'M'){ + s = 'L' + }else if(s == 'm'){ + s = 'l' + } + + arr.push(pathHandlers[s].call(null, + array.slice(index, (index = index + paramCnt[s.toUpperCase()])).map(parseFloat), + p, p0 + ) + ) + + }while(len > index) + + return arr + + } + // Get bounding box of path +, bbox: function() { + SVG.parser.path.setAttribute('d', this.toString()) + + return SVG.parser.path.getBBox() + } + +}) + +// Module for unit convertions +SVG.Number = SVG.invent({ + // Initialize + create: function(value, unit) { + // initialize defaults + this.value = 0 + this.unit = unit || '' + + // parse value + if (typeof value === 'number') { + // ensure a valid numeric value + this.value = isNaN(value) ? 0 : !isFinite(value) ? (value < 0 ? -3.4e+38 : +3.4e+38) : value + + } else if (typeof value === 'string') { + unit = value.match(SVG.regex.numberAndUnit) + + if (unit) { + // make value numeric + this.value = parseFloat(unit[1]) + + // normalize + if (unit[5] == '%') + this.value /= 100 + else if (unit[5] == 's') + this.value *= 1000 + + // store unit + this.unit = unit[5] + } + + } else { + if (value instanceof SVG.Number) { + this.value = value.valueOf() + this.unit = value.unit + } + } + + } + // Add methods +, extend: { + // Stringalize + toString: function() { + return ( + this.unit == '%' ? + ~~(this.value * 1e8) / 1e6: + this.unit == 's' ? + this.value / 1e3 : + this.value + ) + this.unit + } + , toJSON: function() { + return this.toString() + } + , // Convert to primitive + valueOf: function() { + return this.value + } + // Add number + , plus: function(number) { + number = new SVG.Number(number) + return new SVG.Number(this + number, this.unit || number.unit) + } + // Subtract number + , minus: function(number) { + number = new SVG.Number(number) + return new SVG.Number(this - number, this.unit || number.unit) + } + // Multiply number + , times: function(number) { + number = new SVG.Number(number) + return new SVG.Number(this * number, this.unit || number.unit) + } + // Divide number + , divide: function(number) { + number = new SVG.Number(number) + return new SVG.Number(this / number, this.unit || number.unit) + } + // Convert to different unit + , to: function(unit) { + var number = new SVG.Number(this) + + if (typeof unit === 'string') + number.unit = unit + + return number + } + // Make number morphable + , morph: function(number) { + this.destination = new SVG.Number(number) + + if(number.relative) { + this.destination.value += this.value + } + + return this + } + // Get morphed number at given position + , at: function(pos) { + // Make sure a destination is defined + if (!this.destination) return this + + // Generate new morphed number + return new SVG.Number(this.destination) + .minus(this) + .times(pos) + .plus(this) + } + + } +}) + + +SVG.Element = SVG.invent({ + // Initialize node + create: function(node) { + // make stroke value accessible dynamically + this._stroke = SVG.defaults.attrs.stroke + this._event = null + + // initialize data object + this.dom = {} + + // create circular reference + if (this.node = node) { + this.type = node.nodeName + this.node.instance = this + + // store current attribute value + this._stroke = node.getAttribute('stroke') || this._stroke + } + } + + // Add class methods +, extend: { + // Move over x-axis + x: function(x) { + return this.attr('x', x) + } + // Move over y-axis + , y: function(y) { + return this.attr('y', y) + } + // Move by center over x-axis + , cx: function(x) { + return x == null ? this.x() + this.width() / 2 : this.x(x - this.width() / 2) + } + // Move by center over y-axis + , cy: function(y) { + return y == null ? this.y() + this.height() / 2 : this.y(y - this.height() / 2) + } + // Move element to given x and y values + , move: function(x, y) { + return this.x(x).y(y) + } + // Move element by its center + , center: function(x, y) { + return this.cx(x).cy(y) + } + // Set width of element + , width: function(width) { + return this.attr('width', width) + } + // Set height of element + , height: function(height) { + return this.attr('height', height) + } + // Set element size to given width and height + , size: function(width, height) { + var p = proportionalSize(this, width, height) + + return this + .width(new SVG.Number(p.width)) + .height(new SVG.Number(p.height)) + } + // Clone element + , clone: function(parent, withData) { + // write dom data to the dom so the clone can pickup the data + this.writeDataToDom() + + // clone element and assign new id + var clone = assignNewId(this.node.cloneNode(true)) + + // insert the clone in the given parent or after myself + if(parent) parent.add(clone) + else this.after(clone) + + return clone + } + // Remove element + , remove: function() { + if (this.parent()) + this.parent().removeElement(this) + + return this + } + // Replace element + , replace: function(element) { + this.after(element).remove() + + return element + } + // Add element to given container and return self + , addTo: function(parent) { + return parent.put(this) + } + // Add element to given container and return container + , putIn: function(parent) { + return parent.add(this) + } + // Get / set id + , id: function(id) { + return this.attr('id', id) + } + // Checks whether the given point inside the bounding box of the element + , inside: function(x, y) { + var box = this.bbox() + + return x > box.x + && y > box.y + && x < box.x + box.width + && y < box.y + box.height + } + // Show element + , show: function() { + return this.style('display', '') + } + // Hide element + , hide: function() { + return this.style('display', 'none') + } + // Is element visible? + , visible: function() { + return this.style('display') != 'none' + } + // Return id on string conversion + , toString: function() { + return this.attr('id') + } + // Return array of classes on the node + , classes: function() { + var attr = this.attr('class') + + return attr == null ? [] : attr.trim().split(SVG.regex.delimiter) + } + // Return true if class exists on the node, false otherwise + , hasClass: function(name) { + return this.classes().indexOf(name) != -1 + } + // Add class to the node + , addClass: function(name) { + if (!this.hasClass(name)) { + var array = this.classes() + array.push(name) + this.attr('class', array.join(' ')) + } + + return this + } + // Remove class from the node + , removeClass: function(name) { + if (this.hasClass(name)) { + this.attr('class', this.classes().filter(function(c) { + return c != name + }).join(' ')) + } + + return this + } + // Toggle the presence of a class on the node + , toggleClass: function(name) { + return this.hasClass(name) ? this.removeClass(name) : this.addClass(name) + } + // Get referenced element form attribute value + , reference: function(attr) { + return SVG.get(this.attr(attr)) + } + // Returns the parent element instance + , parent: function(type) { + var parent = this + + // check for parent + if(!parent.node.parentNode) return null + + // get parent element + parent = SVG.adopt(parent.node.parentNode) + + if(!type) return parent + + // loop trough ancestors if type is given + while(parent && parent.node instanceof window.SVGElement){ + if(typeof type === 'string' ? parent.matches(type) : parent instanceof type) return parent + parent = SVG.adopt(parent.node.parentNode) + } + } + // Get parent document + , doc: function() { + return this instanceof SVG.Doc ? this : this.parent(SVG.Doc) + } + // return array of all ancestors of given type up to the root svg + , parents: function(type) { + var parents = [], parent = this + + do{ + parent = parent.parent(type) + if(!parent || !parent.node) break + + parents.push(parent) + } while(parent.parent) + + return parents + } + // matches the element vs a css selector + , matches: function(selector){ + return matches(this.node, selector) + } + // Returns the svg node to call native svg methods on it + , native: function() { + return this.node + } + // Import raw svg + , svg: function(svg) { + // create temporary holder + var well = document.createElement('svg') + + // act as a setter if svg is given + if (svg && this instanceof SVG.Parent) { + // dump raw svg + well.innerHTML = '' + svg.replace(/\n/, '').replace(/<(\w+)([^<]+?)\/>/g, '<$1$2>') + '' + + // transplant nodes + for (var i = 0, il = well.firstChild.childNodes.length; i < il; i++) + this.node.appendChild(well.firstChild.firstChild) + + // otherwise act as a getter + } else { + // create a wrapping svg element in case of partial content + well.appendChild(svg = document.createElement('svg')) + + // write svgjs data to the dom + this.writeDataToDom() + + // insert a copy of this node + svg.appendChild(this.node.cloneNode(true)) + + // return target element + return well.innerHTML.replace(/^/, '').replace(/<\/svg>$/, '') + } + + return this + } + // write svgjs data to the dom + , writeDataToDom: function() { + + // dump variables recursively + if(this.each || this.lines){ + var fn = this.each ? this : this.lines(); + fn.each(function(){ + this.writeDataToDom() + }) + } + + // remove previously set data + this.node.removeAttribute('svgjs:data') + + if(Object.keys(this.dom).length) + this.node.setAttribute('svgjs:data', JSON.stringify(this.dom)) // see #428 + + return this + } + // set given data to the elements data property + , setData: function(o){ + this.dom = o + return this + } + , is: function(obj){ + return is(this, obj) + } + } +}) + +SVG.easing = { + '-': function(pos){return pos} +, '<>':function(pos){return -Math.cos(pos * Math.PI) / 2 + 0.5} +, '>': function(pos){return Math.sin(pos * Math.PI / 2)} +, '<': function(pos){return -Math.cos(pos * Math.PI / 2) + 1} +} + +SVG.morph = function(pos){ + return function(from, to) { + return new SVG.MorphObj(from, to).at(pos) + } +} + +SVG.Situation = SVG.invent({ + + create: function(o){ + this.init = false + this.reversed = false + this.reversing = false + + this.duration = new SVG.Number(o.duration).valueOf() + this.delay = new SVG.Number(o.delay).valueOf() + + this.start = +new Date() + this.delay + this.finish = this.start + this.duration + this.ease = o.ease + + // this.loop is incremented from 0 to this.loops + // it is also incremented when in an infinite loop (when this.loops is true) + this.loop = 0 + this.loops = false + + this.animations = { + // functionToCall: [list of morphable objects] + // e.g. move: [SVG.Number, SVG.Number] + } + + this.attrs = { + // holds all attributes which are not represented from a function svg.js provides + // e.g. someAttr: SVG.Number + } + + this.styles = { + // holds all styles which should be animated + // e.g. fill-color: SVG.Color + } + + this.transforms = [ + // holds all transformations as transformation objects + // e.g. [SVG.Rotate, SVG.Translate, SVG.Matrix] + ] + + this.once = { + // functions to fire at a specific position + // e.g. "0.5": function foo(){} + } + + } + +}) + + +SVG.FX = SVG.invent({ + + create: function(element) { + this._target = element + this.situations = [] + this.active = false + this.situation = null + this.paused = false + this.lastPos = 0 + this.pos = 0 + // The absolute position of an animation is its position in the context of its complete duration (including delay and loops) + // When performing a delay, absPos is below 0 and when performing a loop, its value is above 1 + this.absPos = 0 + this._speed = 1 + } + +, extend: { + + /** + * sets or returns the target of this animation + * @param o object || number In case of Object it holds all parameters. In case of number its the duration of the animation + * @param ease function || string Function which should be used for easing or easing keyword + * @param delay Number indicating the delay before the animation starts + * @return target || this + */ + animate: function(o, ease, delay){ + + if(typeof o == 'object'){ + ease = o.ease + delay = o.delay + o = o.duration + } + + var situation = new SVG.Situation({ + duration: o || 1000, + delay: delay || 0, + ease: SVG.easing[ease || '-'] || ease + }) + + this.queue(situation) + + return this + } + + /** + * sets a delay before the next element of the queue is called + * @param delay Duration of delay in milliseconds + * @return this.target() + */ + , delay: function(delay){ + // The delay is performed by an empty situation with its duration + // attribute set to the duration of the delay + var situation = new SVG.Situation({ + duration: delay, + delay: 0, + ease: SVG.easing['-'] + }) + + return this.queue(situation) + } + + /** + * sets or returns the target of this animation + * @param null || target SVG.Element which should be set as new target + * @return target || this + */ + , target: function(target){ + if(target && target instanceof SVG.Element){ + this._target = target + return this + } + + return this._target + } + + // returns the absolute position at a given time + , timeToAbsPos: function(timestamp){ + return (timestamp - this.situation.start) / (this.situation.duration/this._speed) + } + + // returns the timestamp from a given absolute positon + , absPosToTime: function(absPos){ + return this.situation.duration/this._speed * absPos + this.situation.start + } + + // starts the animationloop + , startAnimFrame: function(){ + this.stopAnimFrame() + this.animationFrame = window.requestAnimationFrame(function(){ this.step() }.bind(this)) + } + + // cancels the animationframe + , stopAnimFrame: function(){ + window.cancelAnimationFrame(this.animationFrame) + } + + // kicks off the animation - only does something when the queue is currently not active and at least one situation is set + , start: function(){ + // dont start if already started + if(!this.active && this.situation){ + this.active = true + this.startCurrent() + } + + return this + } + + // start the current situation + , startCurrent: function(){ + this.situation.start = +new Date + this.situation.delay/this._speed + this.situation.finish = this.situation.start + this.situation.duration/this._speed + return this.initAnimations().step() + } + + /** + * adds a function / Situation to the animation queue + * @param fn function / situation to add + * @return this + */ + , queue: function(fn){ + if(typeof fn == 'function' || fn instanceof SVG.Situation) + this.situations.push(fn) + + if(!this.situation) this.situation = this.situations.shift() + + return this + } + + /** + * pulls next element from the queue and execute it + * @return this + */ + , dequeue: function(){ + // stop current animation + this.stop() + + // get next animation from queue + this.situation = this.situations.shift() + + if(this.situation){ + if(this.situation instanceof SVG.Situation) { + this.start() + } else { + // If it is not a SVG.Situation, then it is a function, we execute it + this.situation.call(this) + } + } + + return this + } + + // updates all animations to the current state of the element + // this is important when one property could be changed from another property + , initAnimations: function() { + var i, source + var s = this.situation + + if(s.init) return this + + for(i in s.animations){ + source = this.target()[i]() + + // The condition is because some methods return a normal number instead + // of a SVG.Number + if(s.animations[i] instanceof SVG.Number) + source = new SVG.Number(source) + + s.animations[i] = source.morph(s.animations[i]) + } + + for(i in s.attrs){ + s.attrs[i] = new SVG.MorphObj(this.target().attr(i), s.attrs[i]) + } + + for(i in s.styles){ + s.styles[i] = new SVG.MorphObj(this.target().style(i), s.styles[i]) + } + + s.initialTransformation = this.target().matrixify() + + s.init = true + return this + } + , clearQueue: function(){ + this.situations = [] + return this + } + , clearCurrent: function(){ + this.situation = null + return this + } + /** stops the animation immediately + * @param jumpToEnd A Boolean indicating whether to complete the current animation immediately. + * @param clearQueue A Boolean indicating whether to remove queued animation as well. + * @return this + */ + , stop: function(jumpToEnd, clearQueue){ + var active = this.active + this.active = false + + if(clearQueue){ + this.clearQueue() + } + + if(jumpToEnd && this.situation){ + // initialize the situation if it was not + !active && this.startCurrent() + this.atEnd() + } + + this.stopAnimFrame() + + return this.clearCurrent() + } + + /** resets the element to the state where the current element has started + * @return this + */ + , reset: function(){ + if(this.situation){ + var temp = this.situation + this.stop() + this.situation = temp + this.atStart() + } + return this + } + + // Stop the currently-running animation, remove all queued animations, and complete all animations for the element. + , finish: function(){ + + this.stop(true, false) + + while(this.dequeue().situation && this.stop(true, false)); + + this.clearQueue().clearCurrent() + + return this + } + + // set the internal animation pointer at the start position, before any loops, and updates the visualisation + , atStart: function() { + return this.at(0, true) + } + + // set the internal animation pointer at the end position, after all the loops, and updates the visualisation + , atEnd: function() { + if (this.situation.loops === true) { + // If in a infinite loop, we end the current iteration + this.situation.loops = this.situation.loop + 1 + } + + if(typeof this.situation.loops == 'number') { + // If performing a finite number of loops, we go after all the loops + return this.at(this.situation.loops, true) + } else { + // If no loops, we just go at the end + return this.at(1, true) + } + } + + // set the internal animation pointer to the specified position and updates the visualisation + // if isAbsPos is true, pos is treated as an absolute position + , at: function(pos, isAbsPos){ + var durDivSpd = this.situation.duration/this._speed + + this.absPos = pos + // If pos is not an absolute position, we convert it into one + if (!isAbsPos) { + if (this.situation.reversed) this.absPos = 1 - this.absPos + this.absPos += this.situation.loop + } + + this.situation.start = +new Date - this.absPos * durDivSpd + this.situation.finish = this.situation.start + durDivSpd + + return this.step(true) + } + + /** + * sets or returns the speed of the animations + * @param speed null || Number The new speed of the animations + * @return Number || this + */ + , speed: function(speed){ + if (speed === 0) return this.pause() + + if (speed) { + this._speed = speed + // We use an absolute position here so that speed can affect the delay before the animation + return this.at(this.absPos, true) + } else return this._speed + } + + // Make loopable + , loop: function(times, reverse) { + var c = this.last() + + // store total loops + c.loops = (times != null) ? times : true + c.loop = 0 + + if(reverse) c.reversing = true + return this + } + + // pauses the animation + , pause: function(){ + this.paused = true + this.stopAnimFrame() + + return this + } + + // unpause the animation + , play: function(){ + if(!this.paused) return this + this.paused = false + // We use an absolute position here so that the delay before the animation can be paused + return this.at(this.absPos, true) + } + + /** + * toggle or set the direction of the animation + * true sets direction to backwards while false sets it to forwards + * @param reversed Boolean indicating whether to reverse the animation or not (default: toggle the reverse status) + * @return this + */ + , reverse: function(reversed){ + var c = this.last() + + if(typeof reversed == 'undefined') c.reversed = !c.reversed + else c.reversed = reversed + + return this + } + + + /** + * returns a float from 0-1 indicating the progress of the current animation + * @param eased Boolean indicating whether the returned position should be eased or not + * @return number + */ + , progress: function(easeIt){ + return easeIt ? this.situation.ease(this.pos) : this.pos + } + + /** + * adds a callback function which is called when the current animation is finished + * @param fn Function which should be executed as callback + * @return number + */ + , after: function(fn){ + var c = this.last() + , wrapper = function wrapper(e){ + if(e.detail.situation == c){ + fn.call(this, c) + this.off('finished.fx', wrapper) // prevent memory leak + } + } + + this.target().on('finished.fx', wrapper) + + return this._callStart() + } + + // adds a callback which is called whenever one animation step is performed + , during: function(fn){ + var c = this.last() + , wrapper = function(e){ + if(e.detail.situation == c){ + fn.call(this, e.detail.pos, SVG.morph(e.detail.pos), e.detail.eased, c) + } + } + + // see above + this.target().off('during.fx', wrapper).on('during.fx', wrapper) + + this.after(function(){ + this.off('during.fx', wrapper) + }) + + return this._callStart() + } + + // calls after ALL animations in the queue are finished + , afterAll: function(fn){ + var wrapper = function wrapper(e){ + fn.call(this) + this.off('allfinished.fx', wrapper) + } + + // see above + this.target().off('allfinished.fx', wrapper).on('allfinished.fx', wrapper) + + return this._callStart() + } + + // calls on every animation step for all animations + , duringAll: function(fn){ + var wrapper = function(e){ + fn.call(this, e.detail.pos, SVG.morph(e.detail.pos), e.detail.eased, e.detail.situation) + } + + this.target().off('during.fx', wrapper).on('during.fx', wrapper) + + this.afterAll(function(){ + this.off('during.fx', wrapper) + }) + + return this._callStart() + } + + , last: function(){ + return this.situations.length ? this.situations[this.situations.length-1] : this.situation + } + + // adds one property to the animations + , add: function(method, args, type){ + this.last()[type || 'animations'][method] = args + return this._callStart() + } + + /** perform one step of the animation + * @param ignoreTime Boolean indicating whether to ignore time and use position directly or recalculate position based on time + * @return this + */ + , step: function(ignoreTime){ + + // convert current time to an absolute position + if(!ignoreTime) this.absPos = this.timeToAbsPos(+new Date) + + // This part convert an absolute position to a position + if(this.situation.loops !== false) { + var absPos, absPosInt, lastLoop + + // If the absolute position is below 0, we just treat it as if it was 0 + absPos = Math.max(this.absPos, 0) + absPosInt = Math.floor(absPos) + + if(this.situation.loops === true || absPosInt < this.situation.loops) { + this.pos = absPos - absPosInt + lastLoop = this.situation.loop + this.situation.loop = absPosInt + } else { + this.absPos = this.situation.loops + this.pos = 1 + // The -1 here is because we don't want to toggle reversed when all the loops have been completed + lastLoop = this.situation.loop - 1 + this.situation.loop = this.situation.loops + } + + if(this.situation.reversing) { + // Toggle reversed if an odd number of loops as occured since the last call of step + this.situation.reversed = this.situation.reversed != Boolean((this.situation.loop - lastLoop) % 2) + } + + } else { + // If there are no loop, the absolute position must not be above 1 + this.absPos = Math.min(this.absPos, 1) + this.pos = this.absPos + } + + // while the absolute position can be below 0, the position must not be below 0 + if(this.pos < 0) this.pos = 0 + + if(this.situation.reversed) this.pos = 1 - this.pos + + + // apply easing + var eased = this.situation.ease(this.pos) + + // call once-callbacks + for(var i in this.situation.once){ + if(i > this.lastPos && i <= eased){ + this.situation.once[i].call(this.target(), this.pos, eased) + delete this.situation.once[i] + } + } + + // fire during callback with position, eased position and current situation as parameter + if(this.active) this.target().fire('during', {pos: this.pos, eased: eased, fx: this, situation: this.situation}) + + // the user may call stop or finish in the during callback + // so make sure that we still have a valid situation + if(!this.situation){ + return this + } + + // apply the actual animation to every property + this.eachAt() + + // do final code when situation is finished + if((this.pos == 1 && !this.situation.reversed) || (this.situation.reversed && this.pos == 0)){ + + // stop animation callback + this.stopAnimFrame() + + // fire finished callback with current situation as parameter + this.target().fire('finished', {fx:this, situation: this.situation}) + + if(!this.situations.length){ + this.target().fire('allfinished') + this.target().off('.fx') // there shouldnt be any binding left, but to make sure... + this.active = false + } + + // start next animation + if(this.active) this.dequeue() + else this.clearCurrent() + + }else if(!this.paused && this.active){ + // we continue animating when we are not at the end + this.startAnimFrame() + } + + // save last eased position for once callback triggering + this.lastPos = eased + return this + + } + + // calculates the step for every property and calls block with it + , eachAt: function(){ + var i, len, at, self = this, target = this.target(), s = this.situation + + // apply animations which can be called trough a method + for(i in s.animations){ + + at = [].concat(s.animations[i]).map(function(el){ + return typeof el !== 'string' && el.at ? el.at(s.ease(self.pos), self.pos) : el + }) + + target[i].apply(target, at) + + } + + // apply animation which has to be applied with attr() + for(i in s.attrs){ + + at = [i].concat(s.attrs[i]).map(function(el){ + return typeof el !== 'string' && el.at ? el.at(s.ease(self.pos), self.pos) : el + }) + + target.attr.apply(target, at) + + } + + // apply animation which has to be applied with style() + for(i in s.styles){ + + at = [i].concat(s.styles[i]).map(function(el){ + return typeof el !== 'string' && el.at ? el.at(s.ease(self.pos), self.pos) : el + }) + + target.style.apply(target, at) + + } + + // animate initialTransformation which has to be chained + if(s.transforms.length){ + + // get initial initialTransformation + at = s.initialTransformation + for(i = 0, len = s.transforms.length; i < len; i++){ + + // get next transformation in chain + var a = s.transforms[i] + + // multiply matrix directly + if(a instanceof SVG.Matrix){ + + if(a.relative){ + at = at.multiply(new SVG.Matrix().morph(a).at(s.ease(this.pos))) + }else{ + at = at.morph(a).at(s.ease(this.pos)) + } + continue + } + + // when transformation is absolute we have to reset the needed transformation first + if(!a.relative) + a.undo(at.extract()) + + // and reapply it after + at = at.multiply(a.at(s.ease(this.pos))) + + } + + // set new matrix on element + target.matrix(at) + } + + return this + + } + + + // adds an once-callback which is called at a specific position and never again + , once: function(pos, fn, isEased){ + + if(!isEased)pos = this.situation.ease(pos) + + this.situation.once[pos] = fn + + return this + } + + , _callStart: function() { + setTimeout(function(){this.start()}.bind(this), 0) + return this + } + + } + +, parent: SVG.Element + + // Add method to parent elements +, construct: { + // Get fx module or create a new one, then animate with given duration and ease + animate: function(o, ease, delay) { + return (this.fx || (this.fx = new SVG.FX(this))).animate(o, ease, delay) + } + , delay: function(delay){ + return (this.fx || (this.fx = new SVG.FX(this))).delay(delay) + } + , stop: function(jumpToEnd, clearQueue) { + if (this.fx) + this.fx.stop(jumpToEnd, clearQueue) + + return this + } + , finish: function() { + if (this.fx) + this.fx.finish() + + return this + } + // Pause current animation + , pause: function() { + if (this.fx) + this.fx.pause() + + return this + } + // Play paused current animation + , play: function() { + if (this.fx) + this.fx.play() + + return this + } + // Set/Get the speed of the animations + , speed: function(speed) { + if (this.fx) + if (speed == null) + return this.fx.speed() + else + this.fx.speed(speed) + + return this + } + } + +}) + +// MorphObj is used whenever no morphable object is given +SVG.MorphObj = SVG.invent({ + + create: function(from, to){ + // prepare color for morphing + if(SVG.Color.isColor(to)) return new SVG.Color(from).morph(to) + // prepare number for morphing + if(SVG.regex.numberAndUnit.test(to)) return new SVG.Number(from).morph(to) + + // prepare for plain morphing + this.value = from + this.destination = to + } + +, extend: { + at: function(pos, real){ + return real < 1 ? this.value : this.destination + }, + + valueOf: function(){ + return this.value + } + } + +}) + +SVG.extend(SVG.FX, { + // Add animatable attributes + attr: function(a, v, relative) { + // apply attributes individually + if (typeof a == 'object') { + for (var key in a) + this.attr(key, a[key]) + + } else { + this.add(a, v, 'attrs') + } + + return this + } + // Add animatable styles +, style: function(s, v) { + if (typeof s == 'object') + for (var key in s) + this.style(key, s[key]) + + else + this.add(s, v, 'styles') + + return this + } + // Animatable x-axis +, x: function(x, relative) { + if(this.target() instanceof SVG.G){ + this.transform({x:x}, relative) + return this + } + + var num = new SVG.Number(x) + num.relative = relative + return this.add('x', num) + } + // Animatable y-axis +, y: function(y, relative) { + if(this.target() instanceof SVG.G){ + this.transform({y:y}, relative) + return this + } + + var num = new SVG.Number(y) + num.relative = relative + return this.add('y', num) + } + // Animatable center x-axis +, cx: function(x) { + return this.add('cx', new SVG.Number(x)) + } + // Animatable center y-axis +, cy: function(y) { + return this.add('cy', new SVG.Number(y)) + } + // Add animatable move +, move: function(x, y) { + return this.x(x).y(y) + } + // Add animatable center +, center: function(x, y) { + return this.cx(x).cy(y) + } + // Add animatable size +, size: function(width, height) { + if (this.target() instanceof SVG.Text) { + // animate font size for Text elements + this.attr('font-size', width) + + } else { + // animate bbox based size for all other elements + var box + + if(!width || !height){ + box = this.target().bbox() + } + + if(!width){ + width = box.width / box.height * height + } + + if(!height){ + height = box.height / box.width * width + } + + this.add('width' , new SVG.Number(width)) + .add('height', new SVG.Number(height)) + + } + + return this + } + // Add animatable plot +, plot: function() { + // We use arguments here since SVG.Line's plot method can be passed 4 parameters + return this.add('plot', arguments.length > 1 ? [].slice.call(arguments) : arguments[0]) + } + // Add leading method +, leading: function(value) { + return this.target().leading ? + this.add('leading', new SVG.Number(value)) : + this + } + // Add animatable viewbox +, viewbox: function(x, y, width, height) { + if (this.target() instanceof SVG.Container) { + this.add('viewbox', new SVG.ViewBox(x, y, width, height)) + } + + return this + } +, update: function(o) { + if (this.target() instanceof SVG.Stop) { + if (typeof o == 'number' || o instanceof SVG.Number) { + return this.update({ + offset: arguments[0] + , color: arguments[1] + , opacity: arguments[2] + }) + } + + if (o.opacity != null) this.attr('stop-opacity', o.opacity) + if (o.color != null) this.attr('stop-color', o.color) + if (o.offset != null) this.attr('offset', o.offset) + } + + return this + } +}) + +SVG.Box = SVG.invent({ + create: function(x, y, width, height) { + if (typeof x == 'object' && !(x instanceof SVG.Element)) { + // chromes getBoundingClientRect has no x and y property + return SVG.Box.call(this, x.left != null ? x.left : x.x , x.top != null ? x.top : x.y, x.width, x.height) + } else if (arguments.length == 4) { + this.x = x + this.y = y + this.width = width + this.height = height + } + + // add center, right, bottom... + fullBox(this) + } +, extend: { + // Merge rect box with another, return a new instance + merge: function(box) { + var b = new this.constructor() + + // merge boxes + b.x = Math.min(this.x, box.x) + b.y = Math.min(this.y, box.y) + b.width = Math.max(this.x + this.width, box.x + box.width) - b.x + b.height = Math.max(this.y + this.height, box.y + box.height) - b.y + + return fullBox(b) + } + + , transform: function(m) { + var xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity, p, bbox + + var pts = [ + new SVG.Point(this.x, this.y), + new SVG.Point(this.x2, this.y), + new SVG.Point(this.x, this.y2), + new SVG.Point(this.x2, this.y2) + ] + + pts.forEach(function(p) { + p = p.transform(m) + xMin = Math.min(xMin,p.x) + xMax = Math.max(xMax,p.x) + yMin = Math.min(yMin,p.y) + yMax = Math.max(yMax,p.y) + }) + + bbox = new this.constructor() + bbox.x = xMin + bbox.width = xMax-xMin + bbox.y = yMin + bbox.height = yMax-yMin + + fullBox(bbox) + + return bbox + } + } +}) + +SVG.BBox = SVG.invent({ + // Initialize + create: function(element) { + SVG.Box.apply(this, [].slice.call(arguments)) + + // get values if element is given + if (element instanceof SVG.Element) { + var box + + // yes this is ugly, but Firefox can be a bitch when it comes to elements that are not yet rendered + try { + + if (!document.documentElement.contains){ + // This is IE - it does not support contains() for top-level SVGs + var topParent = element.node + while (topParent.parentNode){ + topParent = topParent.parentNode + } + if (topParent != document) throw new Exception('Element not in the dom') + } else { + // the element is NOT in the dom, throw error + if(!document.documentElement.contains(element.node)) throw new Exception('Element not in the dom') + } + + // find native bbox + box = element.node.getBBox() + } catch(e) { + if(element instanceof SVG.Shape){ + var clone = element.clone(SVG.parser.draw.instance).show() + box = clone.node.getBBox() + clone.remove() + }else{ + box = { + x: element.node.clientLeft + , y: element.node.clientTop + , width: element.node.clientWidth + , height: element.node.clientHeight + } + } + } + + SVG.Box.call(this, box) + } + + } + + // Define ancestor +, inherit: SVG.Box + + // Define Parent +, parent: SVG.Element + + // Constructor +, construct: { + // Get bounding box + bbox: function() { + return new SVG.BBox(this) + } + } + +}) + +SVG.BBox.prototype.constructor = SVG.BBox + + +SVG.extend(SVG.Element, { + tbox: function(){ + console.warn('Use of TBox is deprecated and mapped to RBox. Use .rbox() instead.') + return this.rbox(this.doc()) + } +}) + +SVG.RBox = SVG.invent({ + // Initialize + create: function(element) { + SVG.Box.apply(this, [].slice.call(arguments)) + + if (element instanceof SVG.Element) { + SVG.Box.call(this, element.node.getBoundingClientRect()) + } + } + +, inherit: SVG.Box + + // define Parent +, parent: SVG.Element + +, extend: { + addOffset: function() { + // offset by window scroll position, because getBoundingClientRect changes when window is scrolled + this.x += window.pageXOffset + this.y += window.pageYOffset + return this + } + } + + // Constructor +, construct: { + // Get rect box + rbox: function(el) { + if (el) return new SVG.RBox(this).transform(el.screenCTM().inverse()) + return new SVG.RBox(this).addOffset() + } + } + +}) + +SVG.RBox.prototype.constructor = SVG.RBox + +SVG.Matrix = SVG.invent({ + // Initialize + create: function(source) { + var i, base = arrayToMatrix([1, 0, 0, 1, 0, 0]) + + // ensure source as object + source = source instanceof SVG.Element ? + source.matrixify() : + typeof source === 'string' ? + arrayToMatrix(source.split(SVG.regex.delimiter).map(parseFloat)) : + arguments.length == 6 ? + arrayToMatrix([].slice.call(arguments)) : + Array.isArray(source) ? + arrayToMatrix(source) : + typeof source === 'object' ? + source : base + + // merge source + for (i = abcdef.length - 1; i >= 0; --i) + this[abcdef[i]] = source && typeof source[abcdef[i]] === 'number' ? + source[abcdef[i]] : base[abcdef[i]] + } + + // Add methods +, extend: { + // Extract individual transformations + extract: function() { + // find delta transform points + var px = deltaTransformPoint(this, 0, 1) + , py = deltaTransformPoint(this, 1, 0) + , skewX = 180 / Math.PI * Math.atan2(px.y, px.x) - 90 + + return { + // translation + x: this.e + , y: this.f + , transformedX:(this.e * Math.cos(skewX * Math.PI / 180) + this.f * Math.sin(skewX * Math.PI / 180)) / Math.sqrt(this.a * this.a + this.b * this.b) + , transformedY:(this.f * Math.cos(skewX * Math.PI / 180) + this.e * Math.sin(-skewX * Math.PI / 180)) / Math.sqrt(this.c * this.c + this.d * this.d) + // skew + , skewX: -skewX + , skewY: 180 / Math.PI * Math.atan2(py.y, py.x) + // scale + , scaleX: Math.sqrt(this.a * this.a + this.b * this.b) + , scaleY: Math.sqrt(this.c * this.c + this.d * this.d) + // rotation + , rotation: skewX + , a: this.a + , b: this.b + , c: this.c + , d: this.d + , e: this.e + , f: this.f + , matrix: new SVG.Matrix(this) + } + } + // Clone matrix + , clone: function() { + return new SVG.Matrix(this) + } + // Morph one matrix into another + , morph: function(matrix) { + // store new destination + this.destination = new SVG.Matrix(matrix) + + return this + } + // Get morphed matrix at a given position + , at: function(pos) { + // make sure a destination is defined + if (!this.destination) return this + + // calculate morphed matrix at a given position + var matrix = new SVG.Matrix({ + a: this.a + (this.destination.a - this.a) * pos + , b: this.b + (this.destination.b - this.b) * pos + , c: this.c + (this.destination.c - this.c) * pos + , d: this.d + (this.destination.d - this.d) * pos + , e: this.e + (this.destination.e - this.e) * pos + , f: this.f + (this.destination.f - this.f) * pos + }) + + return matrix + } + // Multiplies by given matrix + , multiply: function(matrix) { + return new SVG.Matrix(this.native().multiply(parseMatrix(matrix).native())) + } + // Inverses matrix + , inverse: function() { + return new SVG.Matrix(this.native().inverse()) + } + // Translate matrix + , translate: function(x, y) { + return new SVG.Matrix(this.native().translate(x || 0, y || 0)) + } + // Scale matrix + , scale: function(x, y, cx, cy) { + // support uniformal scale + if (arguments.length == 1) { + y = x + } else if (arguments.length == 3) { + cy = cx + cx = y + y = x + } + + return this.around(cx, cy, new SVG.Matrix(x, 0, 0, y, 0, 0)) + } + // Rotate matrix + , rotate: function(r, cx, cy) { + // convert degrees to radians + r = SVG.utils.radians(r) + + return this.around(cx, cy, new SVG.Matrix(Math.cos(r), Math.sin(r), -Math.sin(r), Math.cos(r), 0, 0)) + } + // Flip matrix on x or y, at a given offset + , flip: function(a, o) { + return a == 'x' ? + this.scale(-1, 1, o, 0) : + a == 'y' ? + this.scale(1, -1, 0, o) : + this.scale(-1, -1, a, o != null ? o : a) + } + // Skew + , skew: function(x, y, cx, cy) { + // support uniformal skew + if (arguments.length == 1) { + y = x + } else if (arguments.length == 3) { + cy = cx + cx = y + y = x + } + + // convert degrees to radians + x = SVG.utils.radians(x) + y = SVG.utils.radians(y) + + return this.around(cx, cy, new SVG.Matrix(1, Math.tan(y), Math.tan(x), 1, 0, 0)) + } + // SkewX + , skewX: function(x, cx, cy) { + return this.skew(x, 0, cx, cy) + } + // SkewY + , skewY: function(y, cx, cy) { + return this.skew(0, y, cx, cy) + } + // Transform around a center point + , around: function(cx, cy, matrix) { + return this + .multiply(new SVG.Matrix(1, 0, 0, 1, cx || 0, cy || 0)) + .multiply(matrix) + .multiply(new SVG.Matrix(1, 0, 0, 1, -cx || 0, -cy || 0)) + } + // Convert to native SVGMatrix + , native: function() { + // create new matrix + var matrix = SVG.parser.native.createSVGMatrix() + + // update with current values + for (var i = abcdef.length - 1; i >= 0; i--) + matrix[abcdef[i]] = this[abcdef[i]] + + return matrix + } + // Convert matrix to string + , toString: function() { + return 'matrix(' + this.a + ',' + this.b + ',' + this.c + ',' + this.d + ',' + this.e + ',' + this.f + ')' + } + } + + // Define parent +, parent: SVG.Element + + // Add parent method +, construct: { + // Get current matrix + ctm: function() { + return new SVG.Matrix(this.node.getCTM()) + }, + // Get current screen matrix + screenCTM: function() { + /* https://bugzilla.mozilla.org/show_bug.cgi?id=1344537 + This is needed because FF does not return the transformation matrix + for the inner coordinate system when getScreenCTM() is called on nested svgs. + However all other Browsers do that */ + if(this instanceof SVG.Nested) { + var rect = this.rect(1,1) + var m = rect.node.getScreenCTM() + rect.remove() + return new SVG.Matrix(m) + } + return new SVG.Matrix(this.node.getScreenCTM()) + } + + } + +}) + +SVG.Point = SVG.invent({ + // Initialize + create: function(x,y) { + var i, source, base = {x:0, y:0} + + // ensure source as object + source = Array.isArray(x) ? + {x:x[0], y:x[1]} : + typeof x === 'object' ? + {x:x.x, y:x.y} : + x != null ? + {x:x, y:(y != null ? y : x)} : base // If y has no value, then x is used has its value + + // merge source + this.x = source.x + this.y = source.y + } + + // Add methods +, extend: { + // Clone point + clone: function() { + return new SVG.Point(this) + } + // Morph one point into another + , morph: function(x, y) { + // store new destination + this.destination = new SVG.Point(x, y) + + return this + } + // Get morphed point at a given position + , at: function(pos) { + // make sure a destination is defined + if (!this.destination) return this + + // calculate morphed matrix at a given position + var point = new SVG.Point({ + x: this.x + (this.destination.x - this.x) * pos + , y: this.y + (this.destination.y - this.y) * pos + }) + + return point + } + // Convert to native SVGPoint + , native: function() { + // create new point + var point = SVG.parser.native.createSVGPoint() + + // update with current values + point.x = this.x + point.y = this.y + + return point + } + // transform point with matrix + , transform: function(matrix) { + return new SVG.Point(this.native().matrixTransform(matrix.native())) + } + + } + +}) + +SVG.extend(SVG.Element, { + + // Get point + point: function(x, y) { + return new SVG.Point(x,y).transform(this.screenCTM().inverse()); + } + +}) + +SVG.extend(SVG.Element, { + // Set svg element attribute + attr: function(a, v, n) { + // act as full getter + if (a == null) { + // get an object of attributes + a = {} + v = this.node.attributes + for (n = v.length - 1; n >= 0; n--) + a[v[n].nodeName] = SVG.regex.isNumber.test(v[n].nodeValue) ? parseFloat(v[n].nodeValue) : v[n].nodeValue + + return a + + } else if (typeof a == 'object') { + // apply every attribute individually if an object is passed + for (v in a) this.attr(v, a[v]) + + } else if (v === null) { + // remove value + this.node.removeAttribute(a) + + } else if (v == null) { + // act as a getter if the first and only argument is not an object + v = this.node.getAttribute(a) + return v == null ? + SVG.defaults.attrs[a] : + SVG.regex.isNumber.test(v) ? + parseFloat(v) : v + + } else { + // BUG FIX: some browsers will render a stroke if a color is given even though stroke width is 0 + if (a == 'stroke-width') + this.attr('stroke', parseFloat(v) > 0 ? this._stroke : null) + else if (a == 'stroke') + this._stroke = v + + // convert image fill and stroke to patterns + if (a == 'fill' || a == 'stroke') { + if (SVG.regex.isImage.test(v)) + v = this.doc().defs().image(v, 0, 0) + + if (v instanceof SVG.Image) + v = this.doc().defs().pattern(0, 0, function() { + this.add(v) + }) + } + + // ensure correct numeric values (also accepts NaN and Infinity) + if (typeof v === 'number') + v = new SVG.Number(v) + + // ensure full hex color + else if (SVG.Color.isColor(v)) + v = new SVG.Color(v) + + // parse array values + else if (Array.isArray(v)) + v = new SVG.Array(v) + + // if the passed attribute is leading... + if (a == 'leading') { + // ... call the leading method instead + if (this.leading) + this.leading(v) + } else { + // set given attribute on node + typeof n === 'string' ? + this.node.setAttributeNS(n, a, v.toString()) : + this.node.setAttribute(a, v.toString()) + } + + // rebuild if required + if (this.rebuild && (a == 'font-size' || a == 'x')) + this.rebuild(a, v) + } + + return this + } +}) +SVG.extend(SVG.Element, { + // Add transformations + transform: function(o, relative) { + // get target in case of the fx module, otherwise reference this + var target = this + , matrix, bbox + + // act as a getter + if (typeof o !== 'object') { + // get current matrix + matrix = new SVG.Matrix(target).extract() + + return typeof o === 'string' ? matrix[o] : matrix + } + + // get current matrix + matrix = new SVG.Matrix(target) + + // ensure relative flag + relative = !!relative || !!o.relative + + // act on matrix + if (o.a != null) { + matrix = relative ? + // relative + matrix.multiply(new SVG.Matrix(o)) : + // absolute + new SVG.Matrix(o) + + // act on rotation + } else if (o.rotation != null) { + // ensure centre point + ensureCentre(o, target) + + // apply transformation + matrix = relative ? + // relative + matrix.rotate(o.rotation, o.cx, o.cy) : + // absolute + matrix.rotate(o.rotation - matrix.extract().rotation, o.cx, o.cy) + + // act on scale + } else if (o.scale != null || o.scaleX != null || o.scaleY != null) { + // ensure centre point + ensureCentre(o, target) + + // ensure scale values on both axes + o.scaleX = o.scale != null ? o.scale : o.scaleX != null ? o.scaleX : 1 + o.scaleY = o.scale != null ? o.scale : o.scaleY != null ? o.scaleY : 1 + + if (!relative) { + // absolute; multiply inversed values + var e = matrix.extract() + o.scaleX = o.scaleX * 1 / e.scaleX + o.scaleY = o.scaleY * 1 / e.scaleY + } + + matrix = matrix.scale(o.scaleX, o.scaleY, o.cx, o.cy) + + // act on skew + } else if (o.skew != null || o.skewX != null || o.skewY != null) { + // ensure centre point + ensureCentre(o, target) + + // ensure skew values on both axes + o.skewX = o.skew != null ? o.skew : o.skewX != null ? o.skewX : 0 + o.skewY = o.skew != null ? o.skew : o.skewY != null ? o.skewY : 0 + + if (!relative) { + // absolute; reset skew values + var e = matrix.extract() + matrix = matrix.multiply(new SVG.Matrix().skew(e.skewX, e.skewY, o.cx, o.cy).inverse()) + } + + matrix = matrix.skew(o.skewX, o.skewY, o.cx, o.cy) + + // act on flip + } else if (o.flip) { + if(o.flip == 'x' || o.flip == 'y') { + o.offset = o.offset == null ? target.bbox()['c' + o.flip] : o.offset + } else { + if(o.offset == null) { + bbox = target.bbox() + o.flip = bbox.cx + o.offset = bbox.cy + } else { + o.flip = o.offset + } + } + + matrix = new SVG.Matrix().flip(o.flip, o.offset) + + // act on translate + } else if (o.x != null || o.y != null) { + if (relative) { + // relative + matrix = matrix.translate(o.x, o.y) + } else { + // absolute + if (o.x != null) matrix.e = o.x + if (o.y != null) matrix.f = o.y + } + } + + return this.attr('transform', matrix) + } +}) + +SVG.extend(SVG.FX, { + transform: function(o, relative) { + // get target in case of the fx module, otherwise reference this + var target = this.target() + , matrix, bbox + + // act as a getter + if (typeof o !== 'object') { + // get current matrix + matrix = new SVG.Matrix(target).extract() + + return typeof o === 'string' ? matrix[o] : matrix + } + + // ensure relative flag + relative = !!relative || !!o.relative + + // act on matrix + if (o.a != null) { + matrix = new SVG.Matrix(o) + + // act on rotation + } else if (o.rotation != null) { + // ensure centre point + ensureCentre(o, target) + + // apply transformation + matrix = new SVG.Rotate(o.rotation, o.cx, o.cy) + + // act on scale + } else if (o.scale != null || o.scaleX != null || o.scaleY != null) { + // ensure centre point + ensureCentre(o, target) + + // ensure scale values on both axes + o.scaleX = o.scale != null ? o.scale : o.scaleX != null ? o.scaleX : 1 + o.scaleY = o.scale != null ? o.scale : o.scaleY != null ? o.scaleY : 1 + + matrix = new SVG.Scale(o.scaleX, o.scaleY, o.cx, o.cy) + + // act on skew + } else if (o.skewX != null || o.skewY != null) { + // ensure centre point + ensureCentre(o, target) + + // ensure skew values on both axes + o.skewX = o.skewX != null ? o.skewX : 0 + o.skewY = o.skewY != null ? o.skewY : 0 + + matrix = new SVG.Skew(o.skewX, o.skewY, o.cx, o.cy) + + // act on flip + } else if (o.flip) { + if(o.flip == 'x' || o.flip == 'y') { + o.offset = o.offset == null ? target.bbox()['c' + o.flip] : o.offset + } else { + if(o.offset == null) { + bbox = target.bbox() + o.flip = bbox.cx + o.offset = bbox.cy + } else { + o.flip = o.offset + } + } + + matrix = new SVG.Matrix().flip(o.flip, o.offset) + + // act on translate + } else if (o.x != null || o.y != null) { + matrix = new SVG.Translate(o.x, o.y) + } + + if(!matrix) return this + + matrix.relative = relative + + this.last().transforms.push(matrix) + + return this._callStart() + } +}) + +SVG.extend(SVG.Element, { + // Reset all transformations + untransform: function() { + return this.attr('transform', null) + }, + // merge the whole transformation chain into one matrix and returns it + matrixify: function() { + + var matrix = (this.attr('transform') || '') + // split transformations + .split(SVG.regex.transforms).slice(0,-1).map(function(str){ + // generate key => value pairs + var kv = str.trim().split('(') + return [kv[0], kv[1].split(SVG.regex.delimiter).map(function(str){ return parseFloat(str) })] + }) + // merge every transformation into one matrix + .reduce(function(matrix, transform){ + + if(transform[0] == 'matrix') return matrix.multiply(arrayToMatrix(transform[1])) + return matrix[transform[0]].apply(matrix, transform[1]) + + }, new SVG.Matrix()) + + return matrix + }, + // add an element to another parent without changing the visual representation on the screen + toParent: function(parent) { + if(this == parent) return this + var ctm = this.screenCTM() + var pCtm = parent.screenCTM().inverse() + + this.addTo(parent).untransform().transform(pCtm.multiply(ctm)) + + return this + }, + // same as above with parent equals root-svg + toDoc: function() { + return this.toParent(this.doc()) + } + +}) + +SVG.Transformation = SVG.invent({ + + create: function(source, inversed){ + + if(arguments.length > 1 && typeof inversed != 'boolean'){ + return this.constructor.call(this, [].slice.call(arguments)) + } + + if(Array.isArray(source)){ + for(var i = 0, len = this.arguments.length; i < len; ++i){ + this[this.arguments[i]] = source[i] + } + } else if(typeof source == 'object'){ + for(var i = 0, len = this.arguments.length; i < len; ++i){ + this[this.arguments[i]] = source[this.arguments[i]] + } + } + + this.inversed = false + + if(inversed === true){ + this.inversed = true + } + + } + +, extend: { + + arguments: [] + , method: '' + + , at: function(pos){ + + var params = [] + + for(var i = 0, len = this.arguments.length; i < len; ++i){ + params.push(this[this.arguments[i]]) + } + + var m = this._undo || new SVG.Matrix() + + m = new SVG.Matrix().morph(SVG.Matrix.prototype[this.method].apply(m, params)).at(pos) + + return this.inversed ? m.inverse() : m + + } + + , undo: function(o){ + for(var i = 0, len = this.arguments.length; i < len; ++i){ + o[this.arguments[i]] = typeof this[this.arguments[i]] == 'undefined' ? 0 : o[this.arguments[i]] + } + + // The method SVG.Matrix.extract which was used before calling this + // method to obtain a value for the parameter o doesn't return a cx and + // a cy so we use the ones that were provided to this object at its creation + o.cx = this.cx + o.cy = this.cy + + this._undo = new SVG[capitalize(this.method)](o, true).at(1) + + return this + } + + } + +}) + +SVG.Translate = SVG.invent({ + + parent: SVG.Matrix +, inherit: SVG.Transformation + +, create: function(source, inversed){ + this.constructor.apply(this, [].slice.call(arguments)) + } + +, extend: { + arguments: ['transformedX', 'transformedY'] + , method: 'translate' + } + +}) + +SVG.Rotate = SVG.invent({ + + parent: SVG.Matrix +, inherit: SVG.Transformation + +, create: function(source, inversed){ + this.constructor.apply(this, [].slice.call(arguments)) + } + +, extend: { + arguments: ['rotation', 'cx', 'cy'] + , method: 'rotate' + , at: function(pos){ + var m = new SVG.Matrix().rotate(new SVG.Number().morph(this.rotation - (this._undo ? this._undo.rotation : 0)).at(pos), this.cx, this.cy) + return this.inversed ? m.inverse() : m + } + , undo: function(o){ + this._undo = o + return this + } + } + +}) + +SVG.Scale = SVG.invent({ + + parent: SVG.Matrix +, inherit: SVG.Transformation + +, create: function(source, inversed){ + this.constructor.apply(this, [].slice.call(arguments)) + } + +, extend: { + arguments: ['scaleX', 'scaleY', 'cx', 'cy'] + , method: 'scale' + } + +}) + +SVG.Skew = SVG.invent({ + + parent: SVG.Matrix +, inherit: SVG.Transformation + +, create: function(source, inversed){ + this.constructor.apply(this, [].slice.call(arguments)) + } + +, extend: { + arguments: ['skewX', 'skewY', 'cx', 'cy'] + , method: 'skew' + } + +}) + +SVG.extend(SVG.Element, { + // Dynamic style generator + style: function(s, v) { + if (arguments.length == 0) { + // get full style + return this.node.style.cssText || '' + + } else if (arguments.length < 2) { + // apply every style individually if an object is passed + if (typeof s == 'object') { + for (v in s) this.style(v, s[v]) + + } else if (SVG.regex.isCss.test(s)) { + // parse css string + s = s.split(/\s*;\s*/) + // filter out suffix ; and stuff like ;; + .filter(function(e) { return !!e }) + .map(function(e){ return e.split(/\s*:\s*/) }) + + // apply every definition individually + while (v = s.pop()) { + this.style(v[0], v[1]) + } + } else { + // act as a getter if the first and only argument is not an object + return this.node.style[camelCase(s)] + } + + } else { + this.node.style[camelCase(s)] = v === null || SVG.regex.isBlank.test(v) ? '' : v + } + + return this + } +}) +SVG.Parent = SVG.invent({ + // Initialize node + create: function(element) { + this.constructor.call(this, element) + } + + // Inherit from +, inherit: SVG.Element + + // Add class methods +, extend: { + // Returns all child elements + children: function() { + return SVG.utils.map(SVG.utils.filterSVGElements(this.node.childNodes), function(node) { + return SVG.adopt(node) + }) + } + // Add given element at a position + , add: function(element, i) { + if (i == null) + this.node.appendChild(element.node) + else if (element.node != this.node.childNodes[i]) + this.node.insertBefore(element.node, this.node.childNodes[i]) + + return this + } + // Basically does the same as `add()` but returns the added element instead + , put: function(element, i) { + this.add(element, i) + return element + } + // Checks if the given element is a child + , has: function(element) { + return this.index(element) >= 0 + } + // Gets index of given element + , index: function(element) { + return [].slice.call(this.node.childNodes).indexOf(element.node) + } + // Get a element at the given index + , get: function(i) { + return SVG.adopt(this.node.childNodes[i]) + } + // Get first child + , first: function() { + return this.get(0) + } + // Get the last child + , last: function() { + return this.get(this.node.childNodes.length - 1) + } + // Iterates over all children and invokes a given block + , each: function(block, deep) { + var i, il + , children = this.children() + + for (i = 0, il = children.length; i < il; i++) { + if (children[i] instanceof SVG.Element) + block.apply(children[i], [i, children]) + + if (deep && (children[i] instanceof SVG.Container)) + children[i].each(block, deep) + } + + return this + } + // Remove a given child + , removeElement: function(element) { + this.node.removeChild(element.node) + + return this + } + // Remove all elements in this container + , clear: function() { + // remove children + while(this.node.hasChildNodes()) + this.node.removeChild(this.node.lastChild) + + // remove defs reference + delete this._defs + + return this + } + , // Get defs + defs: function() { + return this.doc().defs() + } + } + +}) + +SVG.extend(SVG.Parent, { + + ungroup: function(parent, depth) { + if(depth === 0 || this instanceof SVG.Defs || this.node == SVG.parser.draw) return this + + parent = parent || (this instanceof SVG.Doc ? this : this.parent(SVG.Parent)) + depth = depth || Infinity + + this.each(function(){ + if(this instanceof SVG.Defs) return this + if(this instanceof SVG.Parent) return this.ungroup(parent, depth-1) + return this.toParent(parent) + }) + + this.node.firstChild || this.remove() + + return this + }, + + flatten: function(parent, depth) { + return this.ungroup(parent, depth) + } + +}) +SVG.Container = SVG.invent({ + // Initialize node + create: function(element) { + this.constructor.call(this, element) + } + + // Inherit from +, inherit: SVG.Parent + +}) + +SVG.ViewBox = SVG.invent({ + + create: function(source) { + var i, base = [0, 0, 0, 0] + + var x, y, width, height, box, view, we, he + , wm = 1 // width multiplier + , hm = 1 // height multiplier + , reg = /[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?/gi + + if(source instanceof SVG.Element){ + + we = source + he = source + view = (source.attr('viewBox') || '').match(reg) + box = source.bbox + + // get dimensions of current node + width = new SVG.Number(source.width()) + height = new SVG.Number(source.height()) + + // find nearest non-percentual dimensions + while (width.unit == '%') { + wm *= width.value + width = new SVG.Number(we instanceof SVG.Doc ? we.parent().offsetWidth : we.parent().width()) + we = we.parent() + } + while (height.unit == '%') { + hm *= height.value + height = new SVG.Number(he instanceof SVG.Doc ? he.parent().offsetHeight : he.parent().height()) + he = he.parent() + } + + // ensure defaults + this.x = 0 + this.y = 0 + this.width = width * wm + this.height = height * hm + this.zoom = 1 + + if (view) { + // get width and height from viewbox + x = parseFloat(view[0]) + y = parseFloat(view[1]) + width = parseFloat(view[2]) + height = parseFloat(view[3]) + + // calculate zoom accoring to viewbox + this.zoom = ((this.width / this.height) > (width / height)) ? + this.height / height : + this.width / width + + // calculate real pixel dimensions on parent SVG.Doc element + this.x = x + this.y = y + this.width = width + this.height = height + + } + + }else{ + + // ensure source as object + source = typeof source === 'string' ? + source.match(reg).map(function(el){ return parseFloat(el) }) : + Array.isArray(source) ? + source : + typeof source == 'object' ? + [source.x, source.y, source.width, source.height] : + arguments.length == 4 ? + [].slice.call(arguments) : + base + + this.x = source[0] + this.y = source[1] + this.width = source[2] + this.height = source[3] + } + + + } + +, extend: { + + toString: function() { + return this.x + ' ' + this.y + ' ' + this.width + ' ' + this.height + } + , morph: function(x, y, width, height){ + this.destination = new SVG.ViewBox(x, y, width, height) + return this + } + + , at: function(pos) { + + if(!this.destination) return this + + return new SVG.ViewBox([ + this.x + (this.destination.x - this.x) * pos + , this.y + (this.destination.y - this.y) * pos + , this.width + (this.destination.width - this.width) * pos + , this.height + (this.destination.height - this.height) * pos + ]) + + } + + } + + // Define parent +, parent: SVG.Container + + // Add parent method +, construct: { + + // get/set viewbox + viewbox: function(x, y, width, height) { + if (arguments.length == 0) + // act as a getter if there are no arguments + return new SVG.ViewBox(this) + + // otherwise act as a setter + return this.attr('viewBox', new SVG.ViewBox(x, y, width, height)) + } + + } + +}) +// Add events to elements +;[ 'click' + , 'dblclick' + , 'mousedown' + , 'mouseup' + , 'mouseover' + , 'mouseout' + , 'mousemove' + // , 'mouseenter' -> not supported by IE + // , 'mouseleave' -> not supported by IE + , 'touchstart' + , 'touchmove' + , 'touchleave' + , 'touchend' + , 'touchcancel' ].forEach(function(event) { + + // add event to SVG.Element + SVG.Element.prototype[event] = function(f) { + // bind event to element rather than element node + SVG.on(this.node, event, f) + return this + } +}) + +// Initialize listeners stack +SVG.listeners = [] +SVG.handlerMap = [] +SVG.listenerId = 0 + +// Add event binder in the SVG namespace +SVG.on = function(node, event, listener, binding, options) { + // create listener, get object-index + var l = listener.bind(binding || node.instance || node) + , index = (SVG.handlerMap.indexOf(node) + 1 || SVG.handlerMap.push(node)) - 1 + , ev = event.split('.')[0] + , ns = event.split('.')[1] || '*' + + + // ensure valid object + SVG.listeners[index] = SVG.listeners[index] || {} + SVG.listeners[index][ev] = SVG.listeners[index][ev] || {} + SVG.listeners[index][ev][ns] = SVG.listeners[index][ev][ns] || {} + + if(!listener._svgjsListenerId) + listener._svgjsListenerId = ++SVG.listenerId + + // reference listener + SVG.listeners[index][ev][ns][listener._svgjsListenerId] = l + + // add listener + node.addEventListener(ev, l, options || false) +} + +// Add event unbinder in the SVG namespace +SVG.off = function(node, event, listener) { + var index = SVG.handlerMap.indexOf(node) + , ev = event && event.split('.')[0] + , ns = event && event.split('.')[1] + , namespace = '' + + if(index == -1) return + + if (listener) { + if(typeof listener == 'function') listener = listener._svgjsListenerId + if(!listener) return + + // remove listener reference + if (SVG.listeners[index][ev] && SVG.listeners[index][ev][ns || '*']) { + // remove listener + node.removeEventListener(ev, SVG.listeners[index][ev][ns || '*'][listener], false) + + delete SVG.listeners[index][ev][ns || '*'][listener] + } + + } else if (ns && ev) { + // remove all listeners for a namespaced event + if (SVG.listeners[index][ev] && SVG.listeners[index][ev][ns]) { + for (listener in SVG.listeners[index][ev][ns]) + SVG.off(node, [ev, ns].join('.'), listener) + + delete SVG.listeners[index][ev][ns] + } + + } else if (ns){ + // remove all listeners for a specific namespace + for(event in SVG.listeners[index]){ + for(namespace in SVG.listeners[index][event]){ + if(ns === namespace){ + SVG.off(node, [event, ns].join('.')) + } + } + } + + } else if (ev) { + // remove all listeners for the event + if (SVG.listeners[index][ev]) { + for (namespace in SVG.listeners[index][ev]) + SVG.off(node, [ev, namespace].join('.')) + + delete SVG.listeners[index][ev] + } + + } else { + // remove all listeners on a given node + for (event in SVG.listeners[index]) + SVG.off(node, event) + + delete SVG.listeners[index] + delete SVG.handlerMap[index] + + } +} + +// +SVG.extend(SVG.Element, { + // Bind given event to listener + on: function(event, listener, binding, options) { + SVG.on(this.node, event, listener, binding, options) + + return this + } + // Unbind event from listener +, off: function(event, listener) { + SVG.off(this.node, event, listener) + + return this + } + // Fire given event +, fire: function(event, data) { + + // Dispatch event + if(event instanceof window.Event){ + this.node.dispatchEvent(event) + }else{ + this.node.dispatchEvent(event = new window.CustomEvent(event, {detail:data, cancelable: true})) + } + + this._event = event + return this + } +, event: function() { + return this._event + } +}) + + +SVG.Defs = SVG.invent({ + // Initialize node + create: 'defs' + + // Inherit from +, inherit: SVG.Container + +}) +SVG.G = SVG.invent({ + // Initialize node + create: 'g' + + // Inherit from +, inherit: SVG.Container + + // Add class methods +, extend: { + // Move over x-axis + x: function(x) { + return x == null ? this.transform('x') : this.transform({ x: x - this.x() }, true) + } + // Move over y-axis + , y: function(y) { + return y == null ? this.transform('y') : this.transform({ y: y - this.y() }, true) + } + // Move by center over x-axis + , cx: function(x) { + return x == null ? this.gbox().cx : this.x(x - this.gbox().width / 2) + } + // Move by center over y-axis + , cy: function(y) { + return y == null ? this.gbox().cy : this.y(y - this.gbox().height / 2) + } + , gbox: function() { + + var bbox = this.bbox() + , trans = this.transform() + + bbox.x += trans.x + bbox.x2 += trans.x + bbox.cx += trans.x + + bbox.y += trans.y + bbox.y2 += trans.y + bbox.cy += trans.y + + return bbox + } + } + + // Add parent method +, construct: { + // Create a group element + group: function() { + return this.put(new SVG.G) + } + } +}) + +// ### This module adds backward / forward functionality to elements. + +// +SVG.extend(SVG.Element, { + // Get all siblings, including myself + siblings: function() { + return this.parent().children() + } + // Get the curent position siblings +, position: function() { + return this.parent().index(this) + } + // Get the next element (will return null if there is none) +, next: function() { + return this.siblings()[this.position() + 1] + } + // Get the next element (will return null if there is none) +, previous: function() { + return this.siblings()[this.position() - 1] + } + // Send given element one step forward +, forward: function() { + var i = this.position() + 1 + , p = this.parent() + + // move node one step forward + p.removeElement(this).add(this, i) + + // make sure defs node is always at the top + if (p instanceof SVG.Doc) + p.node.appendChild(p.defs().node) + + return this + } + // Send given element one step backward +, backward: function() { + var i = this.position() + + if (i > 0) + this.parent().removeElement(this).add(this, i - 1) + + return this + } + // Send given element all the way to the front +, front: function() { + var p = this.parent() + + // Move node forward + p.node.appendChild(this.node) + + // Make sure defs node is always at the top + if (p instanceof SVG.Doc) + p.node.appendChild(p.defs().node) + + return this + } + // Send given element all the way to the back +, back: function() { + if (this.position() > 0) + this.parent().removeElement(this).add(this, 0) + + return this + } + // Inserts a given element before the targeted element +, before: function(element) { + element.remove() + + var i = this.position() + + this.parent().add(element, i) + + return this + } + // Insters a given element after the targeted element +, after: function(element) { + element.remove() + + var i = this.position() + + this.parent().add(element, i + 1) + + return this + } + +}) +SVG.Mask = SVG.invent({ + // Initialize node + create: function() { + this.constructor.call(this, SVG.create('mask')) + + // keep references to masked elements + this.targets = [] + } + + // Inherit from +, inherit: SVG.Container + + // Add class methods +, extend: { + // Unmask all masked elements and remove itself + remove: function() { + // unmask all targets + for (var i = this.targets.length - 1; i >= 0; i--) + if (this.targets[i]) + this.targets[i].unmask() + this.targets = [] + + // remove mask from parent + this.parent().removeElement(this) + + return this + } + } + + // Add parent method +, construct: { + // Create masking element + mask: function() { + return this.defs().put(new SVG.Mask) + } + } +}) + + +SVG.extend(SVG.Element, { + // Distribute mask to svg element + maskWith: function(element) { + // use given mask or create a new one + this.masker = element instanceof SVG.Mask ? element : this.parent().mask().add(element) + + // store reverence on self in mask + this.masker.targets.push(this) + + // apply mask + return this.attr('mask', 'url("#' + this.masker.attr('id') + '")') + } + // Unmask element +, unmask: function() { + delete this.masker + return this.attr('mask', null) + } + +}) + +SVG.ClipPath = SVG.invent({ + // Initialize node + create: function() { + this.constructor.call(this, SVG.create('clipPath')) + + // keep references to clipped elements + this.targets = [] + } + + // Inherit from +, inherit: SVG.Container + + // Add class methods +, extend: { + // Unclip all clipped elements and remove itself + remove: function() { + // unclip all targets + for (var i = this.targets.length - 1; i >= 0; i--) + if (this.targets[i]) + this.targets[i].unclip() + this.targets = [] + + // remove clipPath from parent + this.parent().removeElement(this) + + return this + } + } + + // Add parent method +, construct: { + // Create clipping element + clip: function() { + return this.defs().put(new SVG.ClipPath) + } + } +}) + +// +SVG.extend(SVG.Element, { + // Distribute clipPath to svg element + clipWith: function(element) { + // use given clip or create a new one + this.clipper = element instanceof SVG.ClipPath ? element : this.parent().clip().add(element) + + // store reverence on self in mask + this.clipper.targets.push(this) + + // apply mask + return this.attr('clip-path', 'url("#' + this.clipper.attr('id') + '")') + } + // Unclip element +, unclip: function() { + delete this.clipper + return this.attr('clip-path', null) + } + +}) +SVG.Gradient = SVG.invent({ + // Initialize node + create: function(type) { + this.constructor.call(this, SVG.create(type + 'Gradient')) + + // store type + this.type = type + } + + // Inherit from +, inherit: SVG.Container + + // Add class methods +, extend: { + // Add a color stop + at: function(offset, color, opacity) { + return this.put(new SVG.Stop).update(offset, color, opacity) + } + // Update gradient + , update: function(block) { + // remove all stops + this.clear() + + // invoke passed block + if (typeof block == 'function') + block.call(this, this) + + return this + } + // Return the fill id + , fill: function() { + return 'url(#' + this.id() + ')' + } + // Alias string convertion to fill + , toString: function() { + return this.fill() + } + // custom attr to handle transform + , attr: function(a, b, c) { + if(a == 'transform') a = 'gradientTransform' + return SVG.Container.prototype.attr.call(this, a, b, c) + } + } + + // Add parent method +, construct: { + // Create gradient element in defs + gradient: function(type, block) { + return this.defs().gradient(type, block) + } + } +}) + +// Add animatable methods to both gradient and fx module +SVG.extend(SVG.Gradient, SVG.FX, { + // From position + from: function(x, y) { + return (this._target || this).type == 'radial' ? + this.attr({ fx: new SVG.Number(x), fy: new SVG.Number(y) }) : + this.attr({ x1: new SVG.Number(x), y1: new SVG.Number(y) }) + } + // To position +, to: function(x, y) { + return (this._target || this).type == 'radial' ? + this.attr({ cx: new SVG.Number(x), cy: new SVG.Number(y) }) : + this.attr({ x2: new SVG.Number(x), y2: new SVG.Number(y) }) + } +}) + +// Base gradient generation +SVG.extend(SVG.Defs, { + // define gradient + gradient: function(type, block) { + return this.put(new SVG.Gradient(type)).update(block) + } + +}) + +SVG.Stop = SVG.invent({ + // Initialize node + create: 'stop' + + // Inherit from +, inherit: SVG.Element + + // Add class methods +, extend: { + // add color stops + update: function(o) { + if (typeof o == 'number' || o instanceof SVG.Number) { + o = { + offset: arguments[0] + , color: arguments[1] + , opacity: arguments[2] + } + } + + // set attributes + if (o.opacity != null) this.attr('stop-opacity', o.opacity) + if (o.color != null) this.attr('stop-color', o.color) + if (o.offset != null) this.attr('offset', new SVG.Number(o.offset)) + + return this + } + } + +}) + +SVG.Pattern = SVG.invent({ + // Initialize node + create: 'pattern' + + // Inherit from +, inherit: SVG.Container + + // Add class methods +, extend: { + // Return the fill id + fill: function() { + return 'url(#' + this.id() + ')' + } + // Update pattern by rebuilding + , update: function(block) { + // remove content + this.clear() + + // invoke passed block + if (typeof block == 'function') + block.call(this, this) + + return this + } + // Alias string convertion to fill + , toString: function() { + return this.fill() + } + // custom attr to handle transform + , attr: function(a, b, c) { + if(a == 'transform') a = 'patternTransform' + return SVG.Container.prototype.attr.call(this, a, b, c) + } + + } + + // Add parent method +, construct: { + // Create pattern element in defs + pattern: function(width, height, block) { + return this.defs().pattern(width, height, block) + } + } +}) + +SVG.extend(SVG.Defs, { + // Define gradient + pattern: function(width, height, block) { + return this.put(new SVG.Pattern).update(block).attr({ + x: 0 + , y: 0 + , width: width + , height: height + , patternUnits: 'userSpaceOnUse' + }) + } + +}) +SVG.Doc = SVG.invent({ + // Initialize node + create: function(element) { + if (element) { + // ensure the presence of a dom element + element = typeof element == 'string' ? + document.getElementById(element) : + element + + // If the target is an svg element, use that element as the main wrapper. + // This allows svg.js to work with svg documents as well. + if (element.nodeName == 'svg') { + this.constructor.call(this, element) + } else { + this.constructor.call(this, SVG.create('svg')) + element.appendChild(this.node) + this.size('100%', '100%') + } + + // set svg element attributes and ensure defs node + this.namespace().defs() + } + } + + // Inherit from +, inherit: SVG.Container + + // Add class methods +, extend: { + // Add namespaces + namespace: function() { + return this + .attr({ xmlns: SVG.ns, version: '1.1' }) + .attr('xmlns:xlink', SVG.xlink, SVG.xmlns) + .attr('xmlns:svgjs', SVG.svgjs, SVG.xmlns) + } + // Creates and returns defs element + , defs: function() { + if (!this._defs) { + var defs + + // Find or create a defs element in this instance + if (defs = this.node.getElementsByTagName('defs')[0]) + this._defs = SVG.adopt(defs) + else + this._defs = new SVG.Defs + + // Make sure the defs node is at the end of the stack + this.node.appendChild(this._defs.node) + } + + return this._defs + } + // custom parent method + , parent: function() { + return this.node.parentNode.nodeName == '#document' ? null : this.node.parentNode + } + // Fix for possible sub-pixel offset. See: + // https://bugzilla.mozilla.org/show_bug.cgi?id=608812 + , spof: function(spof) { + var pos = this.node.getScreenCTM() + + if (pos) + this + .style('left', (-pos.e % 1) + 'px') + .style('top', (-pos.f % 1) + 'px') + + return this + } + + // Removes the doc from the DOM + , remove: function() { + if(this.parent()) { + this.parent().removeChild(this.node) + } + + return this + } + , clear: function() { + // remove children + while(this.node.hasChildNodes()) + this.node.removeChild(this.node.lastChild) + + // remove defs reference + delete this._defs + + // add back parser + if(!SVG.parser.draw.parentNode) + this.node.appendChild(SVG.parser.draw) + + return this + } + } + +}) + +SVG.Shape = SVG.invent({ + // Initialize node + create: function(element) { + this.constructor.call(this, element) + } + + // Inherit from +, inherit: SVG.Element + +}) + +SVG.Bare = SVG.invent({ + // Initialize + create: function(element, inherit) { + // construct element + this.constructor.call(this, SVG.create(element)) + + // inherit custom methods + if (inherit) + for (var method in inherit.prototype) + if (typeof inherit.prototype[method] === 'function') + this[method] = inherit.prototype[method] + } + + // Inherit from +, inherit: SVG.Element + + // Add methods +, extend: { + // Insert some plain text + words: function(text) { + // remove contents + while (this.node.hasChildNodes()) + this.node.removeChild(this.node.lastChild) + + // create text node + this.node.appendChild(document.createTextNode(text)) + + return this + } + } +}) + + +SVG.extend(SVG.Parent, { + // Create an element that is not described by SVG.js + element: function(element, inherit) { + return this.put(new SVG.Bare(element, inherit)) + } +}) + +SVG.Symbol = SVG.invent({ + // Initialize node + create: 'symbol' + + // Inherit from +, inherit: SVG.Container + +, construct: { + // create symbol + symbol: function() { + return this.put(new SVG.Symbol) + } + } +}) + +SVG.Use = SVG.invent({ + // Initialize node + create: 'use' + + // Inherit from +, inherit: SVG.Shape + + // Add class methods +, extend: { + // Use element as a reference + element: function(element, file) { + // Set lined element + return this.attr('href', (file || '') + '#' + element, SVG.xlink) + } + } + + // Add parent method +, construct: { + // Create a use element + use: function(element, file) { + return this.put(new SVG.Use).element(element, file) + } + } +}) +SVG.Rect = SVG.invent({ + // Initialize node + create: 'rect' + + // Inherit from +, inherit: SVG.Shape + + // Add parent method +, construct: { + // Create a rect element + rect: function(width, height) { + return this.put(new SVG.Rect()).size(width, height) + } + } +}) +SVG.Circle = SVG.invent({ + // Initialize node + create: 'circle' + + // Inherit from +, inherit: SVG.Shape + + // Add parent method +, construct: { + // Create circle element, based on ellipse + circle: function(size) { + return this.put(new SVG.Circle).rx(new SVG.Number(size).divide(2)).move(0, 0) + } + } +}) + +SVG.extend(SVG.Circle, SVG.FX, { + // Radius x value + rx: function(rx) { + return this.attr('r', rx) + } + // Alias radius x value +, ry: function(ry) { + return this.rx(ry) + } +}) + +SVG.Ellipse = SVG.invent({ + // Initialize node + create: 'ellipse' + + // Inherit from +, inherit: SVG.Shape + + // Add parent method +, construct: { + // Create an ellipse + ellipse: function(width, height) { + return this.put(new SVG.Ellipse).size(width, height).move(0, 0) + } + } +}) + +SVG.extend(SVG.Ellipse, SVG.Rect, SVG.FX, { + // Radius x value + rx: function(rx) { + return this.attr('rx', rx) + } + // Radius y value +, ry: function(ry) { + return this.attr('ry', ry) + } +}) + +// Add common method +SVG.extend(SVG.Circle, SVG.Ellipse, { + // Move over x-axis + x: function(x) { + return x == null ? this.cx() - this.rx() : this.cx(x + this.rx()) + } + // Move over y-axis + , y: function(y) { + return y == null ? this.cy() - this.ry() : this.cy(y + this.ry()) + } + // Move by center over x-axis + , cx: function(x) { + return x == null ? this.attr('cx') : this.attr('cx', x) + } + // Move by center over y-axis + , cy: function(y) { + return y == null ? this.attr('cy') : this.attr('cy', y) + } + // Set width of element + , width: function(width) { + return width == null ? this.rx() * 2 : this.rx(new SVG.Number(width).divide(2)) + } + // Set height of element + , height: function(height) { + return height == null ? this.ry() * 2 : this.ry(new SVG.Number(height).divide(2)) + } + // Custom size function + , size: function(width, height) { + var p = proportionalSize(this, width, height) + + return this + .rx(new SVG.Number(p.width).divide(2)) + .ry(new SVG.Number(p.height).divide(2)) + } +}) +SVG.Line = SVG.invent({ + // Initialize node + create: 'line' + + // Inherit from +, inherit: SVG.Shape + + // Add class methods +, extend: { + // Get array + array: function() { + return new SVG.PointArray([ + [ this.attr('x1'), this.attr('y1') ] + , [ this.attr('x2'), this.attr('y2') ] + ]) + } + // Overwrite native plot() method + , plot: function(x1, y1, x2, y2) { + if (x1 == null) + return this.array() + else if (typeof y1 !== 'undefined') + x1 = { x1: x1, y1: y1, x2: x2, y2: y2 } + else + x1 = new SVG.PointArray(x1).toLine() + + return this.attr(x1) + } + // Move by left top corner + , move: function(x, y) { + return this.attr(this.array().move(x, y).toLine()) + } + // Set element size to given width and height + , size: function(width, height) { + var p = proportionalSize(this, width, height) + + return this.attr(this.array().size(p.width, p.height).toLine()) + } + } + + // Add parent method +, construct: { + // Create a line element + line: function(x1, y1, x2, y2) { + // make sure plot is called as a setter + // x1 is not necessarily a number, it can also be an array, a string and a SVG.PointArray + return SVG.Line.prototype.plot.apply( + this.put(new SVG.Line) + , x1 != null ? [x1, y1, x2, y2] : [0, 0, 0, 0] + ) + } + } +}) + +SVG.Polyline = SVG.invent({ + // Initialize node + create: 'polyline' + + // Inherit from +, inherit: SVG.Shape + + // Add parent method +, construct: { + // Create a wrapped polyline element + polyline: function(p) { + // make sure plot is called as a setter + return this.put(new SVG.Polyline).plot(p || new SVG.PointArray) + } + } +}) + +SVG.Polygon = SVG.invent({ + // Initialize node + create: 'polygon' + + // Inherit from +, inherit: SVG.Shape + + // Add parent method +, construct: { + // Create a wrapped polygon element + polygon: function(p) { + // make sure plot is called as a setter + return this.put(new SVG.Polygon).plot(p || new SVG.PointArray) + } + } +}) + +// Add polygon-specific functions +SVG.extend(SVG.Polyline, SVG.Polygon, { + // Get array + array: function() { + return this._array || (this._array = new SVG.PointArray(this.attr('points'))) + } + // Plot new path +, plot: function(p) { + return (p == null) ? + this.array() : + this.clear().attr('points', typeof p == 'string' ? p : (this._array = new SVG.PointArray(p))) + } + // Clear array cache +, clear: function() { + delete this._array + return this + } + // Move by left top corner +, move: function(x, y) { + return this.attr('points', this.array().move(x, y)) + } + // Set element size to given width and height +, size: function(width, height) { + var p = proportionalSize(this, width, height) + + return this.attr('points', this.array().size(p.width, p.height)) + } + +}) + +// unify all point to point elements +SVG.extend(SVG.Line, SVG.Polyline, SVG.Polygon, { + // Define morphable array + morphArray: SVG.PointArray + // Move by left top corner over x-axis +, x: function(x) { + return x == null ? this.bbox().x : this.move(x, this.bbox().y) + } + // Move by left top corner over y-axis +, y: function(y) { + return y == null ? this.bbox().y : this.move(this.bbox().x, y) + } + // Set width of element +, width: function(width) { + var b = this.bbox() + + return width == null ? b.width : this.size(width, b.height) + } + // Set height of element +, height: function(height) { + var b = this.bbox() + + return height == null ? b.height : this.size(b.width, height) + } +}) +SVG.Path = SVG.invent({ + // Initialize node + create: 'path' + + // Inherit from +, inherit: SVG.Shape + + // Add class methods +, extend: { + // Define morphable array + morphArray: SVG.PathArray + // Get array + , array: function() { + return this._array || (this._array = new SVG.PathArray(this.attr('d'))) + } + // Plot new path + , plot: function(d) { + return (d == null) ? + this.array() : + this.clear().attr('d', typeof d == 'string' ? d : (this._array = new SVG.PathArray(d))) + } + // Clear array cache + , clear: function() { + delete this._array + return this + } + // Move by left top corner + , move: function(x, y) { + return this.attr('d', this.array().move(x, y)) + } + // Move by left top corner over x-axis + , x: function(x) { + return x == null ? this.bbox().x : this.move(x, this.bbox().y) + } + // Move by left top corner over y-axis + , y: function(y) { + return y == null ? this.bbox().y : this.move(this.bbox().x, y) + } + // Set element size to given width and height + , size: function(width, height) { + var p = proportionalSize(this, width, height) + + return this.attr('d', this.array().size(p.width, p.height)) + } + // Set width of element + , width: function(width) { + return width == null ? this.bbox().width : this.size(width, this.bbox().height) + } + // Set height of element + , height: function(height) { + return height == null ? this.bbox().height : this.size(this.bbox().width, height) + } + + } + + // Add parent method +, construct: { + // Create a wrapped path element + path: function(d) { + // make sure plot is called as a setter + return this.put(new SVG.Path).plot(d || new SVG.PathArray) + } + } +}) + +SVG.Image = SVG.invent({ + // Initialize node + create: 'image' + + // Inherit from +, inherit: SVG.Shape + + // Add class methods +, extend: { + // (re)load image + load: function(url) { + if (!url) return this + + var self = this + , img = new window.Image() + + // preload image + SVG.on(img, 'load', function() { + var p = self.parent(SVG.Pattern) + + if(p === null) return + + // ensure image size + if (self.width() == 0 && self.height() == 0) + self.size(img.width, img.height) + + // ensure pattern size if not set + if (p && p.width() == 0 && p.height() == 0) + p.size(self.width(), self.height()) + + // callback + if (typeof self._loaded === 'function') + self._loaded.call(self, { + width: img.width + , height: img.height + , ratio: img.width / img.height + , url: url + }) + }) + + SVG.on(img, 'error', function(e){ + if (typeof self._error === 'function'){ + self._error.call(self, e) + } + }) + + return this.attr('href', (img.src = this.src = url), SVG.xlink) + } + // Add loaded callback + , loaded: function(loaded) { + this._loaded = loaded + return this + } + + , error: function(error) { + this._error = error + return this + } + } + + // Add parent method +, construct: { + // create image element, load image and set its size + image: function(source, width, height) { + return this.put(new SVG.Image).load(source).size(width || 0, height || width || 0) + } + } + +}) +SVG.Text = SVG.invent({ + // Initialize node + create: function() { + this.constructor.call(this, SVG.create('text')) + + this.dom.leading = new SVG.Number(1.3) // store leading value for rebuilding + this._rebuild = true // enable automatic updating of dy values + this._build = false // disable build mode for adding multiple lines + + // set default font + this.attr('font-family', SVG.defaults.attrs['font-family']) + } + + // Inherit from +, inherit: SVG.Shape + + // Add class methods +, extend: { + // Move over x-axis + x: function(x) { + // act as getter + if (x == null) + return this.attr('x') + + return this.attr('x', x) + } + // Move over y-axis + , y: function(y) { + var oy = this.attr('y') + , o = typeof oy === 'number' ? oy - this.bbox().y : 0 + + // act as getter + if (y == null) + return typeof oy === 'number' ? oy - o : oy + + return this.attr('y', typeof y === 'number' ? y + o : y) + } + // Move center over x-axis + , cx: function(x) { + return x == null ? this.bbox().cx : this.x(x - this.bbox().width / 2) + } + // Move center over y-axis + , cy: function(y) { + return y == null ? this.bbox().cy : this.y(y - this.bbox().height / 2) + } + // Set the text content + , text: function(text) { + // act as getter + if (typeof text === 'undefined'){ + var text = '' + var children = this.node.childNodes + for(var i = 0, len = children.length; i < len; ++i){ + + // add newline if its not the first child and newLined is set to true + if(i != 0 && children[i].nodeType != 3 && SVG.adopt(children[i]).dom.newLined == true){ + text += '\n' + } + + // add content of this node + text += children[i].textContent + } + + return text + } + + // remove existing content + this.clear().build(true) + + if (typeof text === 'function') { + // call block + text.call(this, this) + + } else { + // store text and make sure text is not blank + text = text.split('\n') + + // build new lines + for (var i = 0, il = text.length; i < il; i++) + this.tspan(text[i]).newLine() + } + + // disable build mode and rebuild lines + return this.build(false).rebuild() + } + // Set font size + , size: function(size) { + return this.attr('font-size', size).rebuild() + } + // Set / get leading + , leading: function(value) { + // act as getter + if (value == null) + return this.dom.leading + + // act as setter + this.dom.leading = new SVG.Number(value) + + return this.rebuild() + } + // Get all the first level lines + , lines: function() { + var node = (this.textPath && this.textPath() || this).node + + // filter tspans and map them to SVG.js instances + var lines = SVG.utils.map(SVG.utils.filterSVGElements(node.childNodes), function(el){ + return SVG.adopt(el) + }) + + // return an instance of SVG.set + return new SVG.Set(lines) + } + // Rebuild appearance type + , rebuild: function(rebuild) { + // store new rebuild flag if given + if (typeof rebuild == 'boolean') + this._rebuild = rebuild + + // define position of all lines + if (this._rebuild) { + var self = this + , blankLineOffset = 0 + , dy = this.dom.leading * new SVG.Number(this.attr('font-size')) + + this.lines().each(function() { + if (this.dom.newLined) { + if (!self.textPath()) + this.attr('x', self.attr('x')) + if(this.text() == '\n') { + blankLineOffset += dy + }else{ + this.attr('dy', dy + blankLineOffset) + blankLineOffset = 0 + } + } + }) + + this.fire('rebuild') + } + + return this + } + // Enable / disable build mode + , build: function(build) { + this._build = !!build + return this + } + // overwrite method from parent to set data properly + , setData: function(o){ + this.dom = o + this.dom.leading = new SVG.Number(o.leading || 1.3) + return this + } + } + + // Add parent method +, construct: { + // Create text element + text: function(text) { + return this.put(new SVG.Text).text(text) + } + // Create plain text element + , plain: function(text) { + return this.put(new SVG.Text).plain(text) + } + } + +}) + +SVG.Tspan = SVG.invent({ + // Initialize node + create: 'tspan' + + // Inherit from +, inherit: SVG.Shape + + // Add class methods +, extend: { + // Set text content + text: function(text) { + if(text == null) return this.node.textContent + (this.dom.newLined ? '\n' : '') + + typeof text === 'function' ? text.call(this, this) : this.plain(text) + + return this + } + // Shortcut dx + , dx: function(dx) { + return this.attr('dx', dx) + } + // Shortcut dy + , dy: function(dy) { + return this.attr('dy', dy) + } + // Create new line + , newLine: function() { + // fetch text parent + var t = this.parent(SVG.Text) + + // mark new line + this.dom.newLined = true + + // apply new hy¡n + return this.dy(t.dom.leading * t.attr('font-size')).attr('x', t.x()) + } + } + +}) + +SVG.extend(SVG.Text, SVG.Tspan, { + // Create plain text node + plain: function(text) { + // clear if build mode is disabled + if (this._build === false) + this.clear() + + // create text node + this.node.appendChild(document.createTextNode(text)) + + return this + } + // Create a tspan +, tspan: function(text) { + var node = (this.textPath && this.textPath() || this).node + , tspan = new SVG.Tspan + + // clear if build mode is disabled + if (this._build === false) + this.clear() + + // add new tspan + node.appendChild(tspan.node) + + return tspan.text(text) + } + // Clear all lines +, clear: function() { + var node = (this.textPath && this.textPath() || this).node + + // remove existing child nodes + while (node.hasChildNodes()) + node.removeChild(node.lastChild) + + return this + } + // Get length of text element +, length: function() { + return this.node.getComputedTextLength() + } +}) + +SVG.TextPath = SVG.invent({ + // Initialize node + create: 'textPath' + + // Inherit from +, inherit: SVG.Parent + + // Define parent class +, parent: SVG.Text + + // Add parent method +, construct: { + // Create path for text to run on + path: function(d) { + // create textPath element + var path = new SVG.TextPath + , track = this.doc().defs().path(d) + + // move lines to textpath + while (this.node.hasChildNodes()) + path.node.appendChild(this.node.firstChild) + + // add textPath element as child node + this.node.appendChild(path.node) + + // link textPath to path and add content + path.attr('href', '#' + track, SVG.xlink) + + return this + } + // return the array of the path track element + , array: function() { + var track = this.track() + + return track ? track.array() : null + } + // Plot path if any + , plot: function(d) { + var track = this.track() + , pathArray = null + + if (track) { + pathArray = track.plot(d) + } + + return (d == null) ? pathArray : this + } + // Get the path track element + , track: function() { + var path = this.textPath() + + if (path) + return path.reference('href') + } + // Get the textPath child + , textPath: function() { + if (this.node.firstChild && this.node.firstChild.nodeName == 'textPath') + return SVG.adopt(this.node.firstChild) + } + } +}) + +SVG.Nested = SVG.invent({ + // Initialize node + create: function() { + this.constructor.call(this, SVG.create('svg')) + + this.style('overflow', 'visible') + } + + // Inherit from +, inherit: SVG.Container + + // Add parent method +, construct: { + // Create nested svg document + nested: function() { + return this.put(new SVG.Nested) + } + } +}) +SVG.A = SVG.invent({ + // Initialize node + create: 'a' + + // Inherit from +, inherit: SVG.Container + + // Add class methods +, extend: { + // Link url + to: function(url) { + return this.attr('href', url, SVG.xlink) + } + // Link show attribute + , show: function(target) { + return this.attr('show', target, SVG.xlink) + } + // Link target attribute + , target: function(target) { + return this.attr('target', target) + } + } + + // Add parent method +, construct: { + // Create a hyperlink element + link: function(url) { + return this.put(new SVG.A).to(url) + } + } +}) + +SVG.extend(SVG.Element, { + // Create a hyperlink element + linkTo: function(url) { + var link = new SVG.A + + if (typeof url == 'function') + url.call(link, link) + else + link.to(url) + + return this.parent().put(link).put(this) + } + +}) +SVG.Marker = SVG.invent({ + // Initialize node + create: 'marker' + + // Inherit from +, inherit: SVG.Container + + // Add class methods +, extend: { + // Set width of element + width: function(width) { + return this.attr('markerWidth', width) + } + // Set height of element + , height: function(height) { + return this.attr('markerHeight', height) + } + // Set marker refX and refY + , ref: function(x, y) { + return this.attr('refX', x).attr('refY', y) + } + // Update marker + , update: function(block) { + // remove all content + this.clear() + + // invoke passed block + if (typeof block == 'function') + block.call(this, this) + + return this + } + // Return the fill id + , toString: function() { + return 'url(#' + this.id() + ')' + } + } + + // Add parent method +, construct: { + marker: function(width, height, block) { + // Create marker element in defs + return this.defs().marker(width, height, block) + } + } + +}) + +SVG.extend(SVG.Defs, { + // Create marker + marker: function(width, height, block) { + // Set default viewbox to match the width and height, set ref to cx and cy and set orient to auto + return this.put(new SVG.Marker) + .size(width, height) + .ref(width / 2, height / 2) + .viewbox(0, 0, width, height) + .attr('orient', 'auto') + .update(block) + } + +}) + +SVG.extend(SVG.Line, SVG.Polyline, SVG.Polygon, SVG.Path, { + // Create and attach markers + marker: function(marker, width, height, block) { + var attr = ['marker'] + + // Build attribute name + if (marker != 'all') attr.push(marker) + attr = attr.join('-') + + // Set marker attribute + marker = arguments[1] instanceof SVG.Marker ? + arguments[1] : + this.doc().marker(width, height, block) + + return this.attr(attr, marker) + } + +}) +// Define list of available attributes for stroke and fill +var sugar = { + stroke: ['color', 'width', 'opacity', 'linecap', 'linejoin', 'miterlimit', 'dasharray', 'dashoffset'] +, fill: ['color', 'opacity', 'rule'] +, prefix: function(t, a) { + return a == 'color' ? t : t + '-' + a + } +} + +// Add sugar for fill and stroke +;['fill', 'stroke'].forEach(function(m) { + var i, extension = {} + + extension[m] = function(o) { + if (typeof o == 'undefined') + return this + if (typeof o == 'string' || SVG.Color.isRgb(o) || (o && typeof o.fill === 'function')) + this.attr(m, o) + + else + // set all attributes from sugar.fill and sugar.stroke list + for (i = sugar[m].length - 1; i >= 0; i--) + if (o[sugar[m][i]] != null) + this.attr(sugar.prefix(m, sugar[m][i]), o[sugar[m][i]]) + + return this + } + + SVG.extend(SVG.Element, SVG.FX, extension) + +}) + +SVG.extend(SVG.Element, SVG.FX, { + // Map rotation to transform + rotate: function(d, cx, cy) { + return this.transform({ rotation: d, cx: cx, cy: cy }) + } + // Map skew to transform +, skew: function(x, y, cx, cy) { + return arguments.length == 1 || arguments.length == 3 ? + this.transform({ skew: x, cx: y, cy: cx }) : + this.transform({ skewX: x, skewY: y, cx: cx, cy: cy }) + } + // Map scale to transform +, scale: function(x, y, cx, cy) { + return arguments.length == 1 || arguments.length == 3 ? + this.transform({ scale: x, cx: y, cy: cx }) : + this.transform({ scaleX: x, scaleY: y, cx: cx, cy: cy }) + } + // Map translate to transform +, translate: function(x, y) { + return this.transform({ x: x, y: y }) + } + // Map flip to transform +, flip: function(a, o) { + o = typeof a == 'number' ? a : o + return this.transform({ flip: a || 'both', offset: o }) + } + // Map matrix to transform +, matrix: function(m) { + return this.attr('transform', new SVG.Matrix(arguments.length == 6 ? [].slice.call(arguments) : m)) + } + // Opacity +, opacity: function(value) { + return this.attr('opacity', value) + } + // Relative move over x axis +, dx: function(x) { + return this.x(new SVG.Number(x).plus(this instanceof SVG.FX ? 0 : this.x()), true) + } + // Relative move over y axis +, dy: function(y) { + return this.y(new SVG.Number(y).plus(this instanceof SVG.FX ? 0 : this.y()), true) + } + // Relative move over x and y axes +, dmove: function(x, y) { + return this.dx(x).dy(y) + } +}) + +SVG.extend(SVG.Rect, SVG.Ellipse, SVG.Circle, SVG.Gradient, SVG.FX, { + // Add x and y radius + radius: function(x, y) { + var type = (this._target || this).type; + return type == 'radial' || type == 'circle' ? + this.attr('r', new SVG.Number(x)) : + this.rx(x).ry(y == null ? x : y) + } +}) + +SVG.extend(SVG.Path, { + // Get path length + length: function() { + return this.node.getTotalLength() + } + // Get point at length +, pointAt: function(length) { + return this.node.getPointAtLength(length) + } +}) + +SVG.extend(SVG.Parent, SVG.Text, SVG.Tspan, SVG.FX, { + // Set font + font: function(a, v) { + if (typeof a == 'object') { + for (v in a) this.font(v, a[v]) + } + + return a == 'leading' ? + this.leading(v) : + a == 'anchor' ? + this.attr('text-anchor', v) : + a == 'size' || a == 'family' || a == 'weight' || a == 'stretch' || a == 'variant' || a == 'style' ? + this.attr('font-'+ a, v) : + this.attr(a, v) + } +}) + +SVG.Set = SVG.invent({ + // Initialize + create: function(members) { + // Set initial state + Array.isArray(members) ? this.members = members : this.clear() + } + + // Add class methods +, extend: { + // Add element to set + add: function() { + var i, il, elements = [].slice.call(arguments) + + for (i = 0, il = elements.length; i < il; i++) + this.members.push(elements[i]) + + return this + } + // Remove element from set + , remove: function(element) { + var i = this.index(element) + + // remove given child + if (i > -1) + this.members.splice(i, 1) + + return this + } + // Iterate over all members + , each: function(block) { + for (var i = 0, il = this.members.length; i < il; i++) + block.apply(this.members[i], [i, this.members]) + + return this + } + // Restore to defaults + , clear: function() { + // initialize store + this.members = [] + + return this + } + // Get the length of a set + , length: function() { + return this.members.length + } + // Checks if a given element is present in set + , has: function(element) { + return this.index(element) >= 0 + } + // retuns index of given element in set + , index: function(element) { + return this.members.indexOf(element) + } + // Get member at given index + , get: function(i) { + return this.members[i] + } + // Get first member + , first: function() { + return this.get(0) + } + // Get last member + , last: function() { + return this.get(this.members.length - 1) + } + // Default value + , valueOf: function() { + return this.members + } + // Get the bounding box of all members included or empty box if set has no items + , bbox: function(){ + // return an empty box of there are no members + if (this.members.length == 0) + return new SVG.RBox() + + // get the first rbox and update the target bbox + var rbox = this.members[0].rbox(this.members[0].doc()) + + this.each(function() { + // user rbox for correct position and visual representation + rbox = rbox.merge(this.rbox(this.doc())) + }) + + return rbox + } + } + + // Add parent method +, construct: { + // Create a new set + set: function(members) { + return new SVG.Set(members) + } + } +}) + +SVG.FX.Set = SVG.invent({ + // Initialize node + create: function(set) { + // store reference to set + this.set = set + } + +}) + +// Alias methods +SVG.Set.inherit = function() { + var m + , methods = [] + + // gather shape methods + for(var m in SVG.Shape.prototype) + if (typeof SVG.Shape.prototype[m] == 'function' && typeof SVG.Set.prototype[m] != 'function') + methods.push(m) + + // apply shape aliasses + methods.forEach(function(method) { + SVG.Set.prototype[method] = function() { + for (var i = 0, il = this.members.length; i < il; i++) + if (this.members[i] && typeof this.members[i][method] == 'function') + this.members[i][method].apply(this.members[i], arguments) + + return method == 'animate' ? (this.fx || (this.fx = new SVG.FX.Set(this))) : this + } + }) + + // clear methods for the next round + methods = [] + + // gather fx methods + for(var m in SVG.FX.prototype) + if (typeof SVG.FX.prototype[m] == 'function' && typeof SVG.FX.Set.prototype[m] != 'function') + methods.push(m) + + // apply fx aliasses + methods.forEach(function(method) { + SVG.FX.Set.prototype[method] = function() { + for (var i = 0, il = this.set.members.length; i < il; i++) + this.set.members[i].fx[method].apply(this.set.members[i].fx, arguments) + + return this + } + }) +} + + + + +SVG.extend(SVG.Element, { + // Store data values on svg nodes + data: function(a, v, r) { + if (typeof a == 'object') { + for (v in a) + this.data(v, a[v]) + + } else if (arguments.length < 2) { + try { + return JSON.parse(this.attr('data-' + a)) + } catch(e) { + return this.attr('data-' + a) + } + + } else { + this.attr( + 'data-' + a + , v === null ? + null : + r === true || typeof v === 'string' || typeof v === 'number' ? + v : + JSON.stringify(v) + ) + } + + return this + } +}) +SVG.extend(SVG.Element, { + // Remember arbitrary data + remember: function(k, v) { + // remember every item in an object individually + if (typeof arguments[0] == 'object') + for (var v in k) + this.remember(v, k[v]) + + // retrieve memory + else if (arguments.length == 1) + return this.memory()[k] + + // store memory + else + this.memory()[k] = v + + return this + } + + // Erase a given memory +, forget: function() { + if (arguments.length == 0) + this._memory = {} + else + for (var i = arguments.length - 1; i >= 0; i--) + delete this.memory()[arguments[i]] + + return this + } + + // Initialize or return local memory object +, memory: function() { + return this._memory || (this._memory = {}) + } + +}) +// Method for getting an element by id +SVG.get = function(id) { + var node = document.getElementById(idFromReference(id) || id) + return SVG.adopt(node) +} + +// Select elements by query string +SVG.select = function(query, parent) { + return new SVG.Set( + SVG.utils.map((parent || document).querySelectorAll(query), function(node) { + return SVG.adopt(node) + }) + ) +} + +SVG.extend(SVG.Parent, { + // Scoped select method + select: function(query) { + return SVG.select(query, this.node) + } + +}) +function pathRegReplace(a, b, c, d) { + return c + d.replace(SVG.regex.dots, ' .') +} + +// creates deep clone of array +function array_clone(arr){ + var clone = arr.slice(0) + for(var i = clone.length; i--;){ + if(Array.isArray(clone[i])){ + clone[i] = array_clone(clone[i]) + } + } + return clone +} + +// tests if a given element is instance of an object +function is(el, obj){ + return el instanceof obj +} + +// tests if a given selector matches an element +function matches(el, selector) { + return (el.matches || el.matchesSelector || el.msMatchesSelector || el.mozMatchesSelector || el.webkitMatchesSelector || el.oMatchesSelector).call(el, selector); +} + +// Convert dash-separated-string to camelCase +function camelCase(s) { + return s.toLowerCase().replace(/-(.)/g, function(m, g) { + return g.toUpperCase() + }) +} + +// Capitalize first letter of a string +function capitalize(s) { + return s.charAt(0).toUpperCase() + s.slice(1) +} + +// Ensure to six-based hex +function fullHex(hex) { + return hex.length == 4 ? + [ '#', + hex.substring(1, 2), hex.substring(1, 2) + , hex.substring(2, 3), hex.substring(2, 3) + , hex.substring(3, 4), hex.substring(3, 4) + ].join('') : hex +} + +// Component to hex value +function compToHex(comp) { + var hex = comp.toString(16) + return hex.length == 1 ? '0' + hex : hex +} + +// Calculate proportional width and height values when necessary +function proportionalSize(element, width, height) { + if (width == null || height == null) { + var box = element.bbox() + + if (width == null) + width = box.width / box.height * height + else if (height == null) + height = box.height / box.width * width + } + + return { + width: width + , height: height + } +} + +// Delta transform point +function deltaTransformPoint(matrix, x, y) { + return { + x: x * matrix.a + y * matrix.c + 0 + , y: x * matrix.b + y * matrix.d + 0 + } +} + +// Map matrix array to object +function arrayToMatrix(a) { + return { a: a[0], b: a[1], c: a[2], d: a[3], e: a[4], f: a[5] } +} + +// Parse matrix if required +function parseMatrix(matrix) { + if (!(matrix instanceof SVG.Matrix)) + matrix = new SVG.Matrix(matrix) + + return matrix +} + +// Add centre point to transform object +function ensureCentre(o, target) { + o.cx = o.cx == null ? target.bbox().cx : o.cx + o.cy = o.cy == null ? target.bbox().cy : o.cy +} + +// PathArray Helpers +function arrayToString(a) { + for (var i = 0, il = a.length, s = ''; i < il; i++) { + s += a[i][0] + + if (a[i][1] != null) { + s += a[i][1] + + if (a[i][2] != null) { + s += ' ' + s += a[i][2] + + if (a[i][3] != null) { + s += ' ' + s += a[i][3] + s += ' ' + s += a[i][4] + + if (a[i][5] != null) { + s += ' ' + s += a[i][5] + s += ' ' + s += a[i][6] + + if (a[i][7] != null) { + s += ' ' + s += a[i][7] + } + } + } + } + } + } + + return s + ' ' +} + +// Deep new id assignment +function assignNewId(node) { + // do the same for SVG child nodes as well + for (var i = node.childNodes.length - 1; i >= 0; i--) + if (node.childNodes[i] instanceof window.SVGElement) + assignNewId(node.childNodes[i]) + + return SVG.adopt(node).id(SVG.eid(node.nodeName)) +} + +// Add more bounding box properties +function fullBox(b) { + if (b.x == null) { + b.x = 0 + b.y = 0 + b.width = 0 + b.height = 0 + } + + b.w = b.width + b.h = b.height + b.x2 = b.x + b.width + b.y2 = b.y + b.height + b.cx = b.x + b.width / 2 + b.cy = b.y + b.height / 2 + + return b +} + +// Get id from reference string +function idFromReference(url) { + var m = url.toString().match(SVG.regex.reference) + + if (m) return m[1] +} + +// Create matrix array for looping +var abcdef = 'abcdef'.split('') +// Add CustomEvent to IE9 and IE10 +if (typeof window.CustomEvent !== 'function') { + // Code from: https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent + var CustomEvent = function(event, options) { + options = options || { bubbles: false, cancelable: false, detail: undefined } + var e = document.createEvent('CustomEvent') + e.initCustomEvent(event, options.bubbles, options.cancelable, options.detail) + return e + } + + CustomEvent.prototype = window.Event.prototype + + window.CustomEvent = CustomEvent +} + +// requestAnimationFrame / cancelAnimationFrame Polyfill with fallback based on Paul Irish +(function(w) { + var lastTime = 0 + var vendors = ['moz', 'webkit'] + + for(var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { + w.requestAnimationFrame = w[vendors[x] + 'RequestAnimationFrame'] + w.cancelAnimationFrame = w[vendors[x] + 'CancelAnimationFrame'] || + w[vendors[x] + 'CancelRequestAnimationFrame'] + } + + w.requestAnimationFrame = w.requestAnimationFrame || + function(callback) { + var currTime = new Date().getTime() + var timeToCall = Math.max(0, 16 - (currTime - lastTime)) + + var id = w.setTimeout(function() { + callback(currTime + timeToCall) + }, timeToCall) + + lastTime = currTime + timeToCall + return id + } + + w.cancelAnimationFrame = w.cancelAnimationFrame || w.clearTimeout; + +}(window)) + +return SVG + +})); \ No newline at end of file diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/libs/svg.js/dist/svg.min.js b/files/plugin-HeatmapSessionRecording-5.6.0/libs/svg.js/dist/svg.min.js new file mode 100644 index 0000000..29ce812 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/libs/svg.js/dist/svg.min.js @@ -0,0 +1,3 @@ +/*! svg.js v2.6.1 MIT*/;!function(t,e){"function"==typeof define&&define.amd?define(function(){return e(t,t.document)}):"object"==typeof exports?module.exports=t.document?e(t,t.document):function(t){return e(t,t.document)}:t.SVG=e(t,t.document)}("undefined"!=typeof window?window:this,function(t,e){function i(t,e,i,n){return i+n.replace(g.regex.dots," .")}function n(t){for(var e=t.slice(0),i=e.length;i--;)Array.isArray(e[i])&&(e[i]=n(e[i]));return e}function r(t,e){return t instanceof e}function s(t,e){return(t.matches||t.matchesSelector||t.msMatchesSelector||t.mozMatchesSelector||t.webkitMatchesSelector||t.oMatchesSelector).call(t,e)}function o(t){return t.toLowerCase().replace(/-(.)/g,function(t,e){return e.toUpperCase()})}function a(t){return t.charAt(0).toUpperCase()+t.slice(1)}function h(t){return 4==t.length?["#",t.substring(1,2),t.substring(1,2),t.substring(2,3),t.substring(2,3),t.substring(3,4),t.substring(3,4)].join(""):t}function u(t){var e=t.toString(16);return 1==e.length?"0"+e:e}function l(t,e,i){if(null==e||null==i){var n=t.bbox();null==e?e=n.width/n.height*i:null==i&&(i=n.height/n.width*e)}return{width:e,height:i}}function c(t,e,i){return{x:e*t.a+i*t.c+0,y:e*t.b+i*t.d+0}}function f(t){return{a:t[0],b:t[1],c:t[2],d:t[3],e:t[4],f:t[5]}}function d(t){return t instanceof g.Matrix||(t=new g.Matrix(t)),t}function p(t,e){t.cx=null==t.cx?e.bbox().cx:t.cx,t.cy=null==t.cy?e.bbox().cy:t.cy}function m(t){for(var e=0,i=t.length,n="";e=0;i--)e.childNodes[i]instanceof t.SVGElement&&x(e.childNodes[i]);return g.adopt(e).id(g.eid(e.nodeName))}function y(t){return null==t.x&&(t.x=0,t.y=0,t.width=0,t.height=0),t.w=t.width,t.h=t.height,t.x2=t.x+t.width,t.y2=t.y+t.height,t.cx=t.x+t.width/2,t.cy=t.y+t.height/2,t}function v(t){var e=t.toString().match(g.regex.reference);if(e)return e[1]}var g=this.SVG=function(t){if(g.supported)return t=new g.Doc(t),g.parser.draw||g.prepare(),t};if(g.ns="http://www.w3.org/2000/svg",g.xmlns="http://www.w3.org/2000/xmlns/",g.xlink="http://www.w3.org/1999/xlink",g.svgjs="http://svgjs.com/svgjs",g.supported=function(){return!!e.createElementNS&&!!e.createElementNS(g.ns,"svg").createSVGRect}(),!g.supported)return!1;g.did=1e3,g.eid=function(t){return"Svgjs"+a(t)+g.did++},g.create=function(t){var i=e.createElementNS(this.ns,t);return i.setAttribute("id",this.eid(t)),i},g.extend=function(){var t,e,i,n;for(t=[].slice.call(arguments),e=t.pop(),n=t.length-1;n>=0;n--)if(t[n])for(i in e)t[n].prototype[i]=e[i];g.Set&&g.Set.inherit&&g.Set.inherit()},g.invent=function(t){var e="function"==typeof t.create?t.create:function(){this.constructor.call(this,g.create(t.create))};return t.inherit&&(e.prototype=new t.inherit),t.extend&&g.extend(e,t.extend),t.construct&&g.extend(t.parent||g.Container,t.construct),e},g.adopt=function(e){if(!e)return null;if(e.instance)return e.instance;var i;return i="svg"==e.nodeName?e.parentNode instanceof t.SVGElement?new g.Nested:new g.Doc:"linearGradient"==e.nodeName?new g.Gradient("linear"):"radialGradient"==e.nodeName?new g.Gradient("radial"):g[a(e.nodeName)]?new(g[a(e.nodeName)]):new g.Element(e),i.type=e.nodeName,i.node=e,e.instance=i,i instanceof g.Doc&&i.namespace().defs(),i.setData(JSON.parse(e.getAttribute("svgjs:data"))||{}),i},g.prepare=function(){var t=e.getElementsByTagName("body")[0],i=(t?new g.Doc(t):g.adopt(e.documentElement).nested()).size(2,0);g.parser={body:t||e.documentElement,draw:i.style("opacity:0;position:absolute;left:-100%;top:-100%;overflow:hidden").node,poly:i.polyline().node,path:i.path().node,native:g.create("svg")}},g.parser={native:g.create("svg")},e.addEventListener("DOMContentLoaded",function(){g.parser.draw||g.prepare()},!1),g.regex={numberAndUnit:/^([+-]?(\d+(\.\d*)?|\.\d+)(e[+-]?\d+)?)([a-z%]*)$/i,hex:/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i,rgb:/rgb\((\d+),(\d+),(\d+)\)/,reference:/#([a-z0-9\-_]+)/i,transforms:/\)\s*,?\s*/,whitespace:/\s/g,isHex:/^#[a-f0-9]{3,6}$/i,isRgb:/^rgb\(/,isCss:/[^:]+:[^;]+;?/,isBlank:/^(\s+)?$/,isNumber:/^[+-]?(\d+(\.\d*)?|\.\d+)(e[+-]?\d+)?$/i,isPercent:/^-?[\d\.]+%$/,isImage:/\.(jpg|jpeg|png|gif|svg)(\?[^=]+.*)?/i,delimiter:/[\s,]+/,hyphen:/([^e])\-/gi,pathLetters:/[MLHVCSQTAZ]/gi,isPathLetter:/[MLHVCSQTAZ]/i,numbersWithDots:/((\d?\.\d+(?:e[+-]?\d+)?)((?:\.\d+(?:e[+-]?\d+)?)+))+/gi,dots:/\./g},g.utils={map:function(t,e){var i,n=t.length,r=[];for(i=0;i1?1:t,new g.Color({r:~~(this.r+(this.destination.r-this.r)*t),g:~~(this.g+(this.destination.g-this.g)*t),b:~~(this.b+(this.destination.b-this.b)*t)})):this}}),g.Color.test=function(t){return t+="",g.regex.isHex.test(t)||g.regex.isRgb.test(t)},g.Color.isRgb=function(t){return t&&"number"==typeof t.r&&"number"==typeof t.g&&"number"==typeof t.b},g.Color.isColor=function(t){return g.Color.isRgb(t)||g.Color.test(t)},g.Array=function(t,e){t=(t||[]).valueOf(),0==t.length&&e&&(t=e.valueOf()),this.value=this.parse(t)},g.extend(g.Array,{morph:function(t){if(this.destination=this.parse(t),this.value.length!=this.destination.length){for(var e=this.value[this.value.length-1],i=this.destination[this.destination.length-1];this.value.length>this.destination.length;)this.destination.push(i);for(;this.value.length=0;n--)this.value[n]=[this.value[n][0]+t,this.value[n][1]+e];return this},size:function(t,e){var i,n=this.bbox();for(i=this.value.length-1;i>=0;i--)n.width&&(this.value[i][0]=(this.value[i][0]-n.x)*t/n.width+n.x),n.height&&(this.value[i][1]=(this.value[i][1]-n.y)*e/n.height+n.y);return this},bbox:function(){return g.parser.poly.setAttribute("points",this.toString()),g.parser.poly.getBBox()}});for(var w={M:function(t,e,i){return e.x=i.x=t[0],e.y=i.y=t[1],["M",e.x,e.y]},L:function(t,e){return e.x=t[0],e.y=t[1],["L",t[0],t[1]]},H:function(t,e){return e.x=t[0],["H",t[0]]},V:function(t,e){return e.y=t[0],["V",t[0]]},C:function(t,e){return e.x=t[4],e.y=t[5],["C",t[0],t[1],t[2],t[3],t[4],t[5]]},S:function(t,e){return e.x=t[2],e.y=t[3],["S",t[0],t[1],t[2],t[3]]},Q:function(t,e){return e.x=t[2],e.y=t[3],["Q",t[0],t[1],t[2],t[3]]},T:function(t,e){return e.x=t[0],e.y=t[1],["T",t[0],t[1]]},Z:function(t,e,i){return e.x=i.x,e.y=i.y,["Z"]},A:function(t,e){return e.x=t[5],e.y=t[6],["A",t[0],t[1],t[2],t[3],t[4],t[5],t[6]]}},b="mlhvqtcsaz".split(""),C=0,N=b.length;C=0;r--)n=this.value[r][0],"M"==n||"L"==n||"T"==n?(this.value[r][1]+=t,this.value[r][2]+=e):"H"==n?this.value[r][1]+=t:"V"==n?this.value[r][1]+=e:"C"==n||"S"==n||"Q"==n?(this.value[r][1]+=t,this.value[r][2]+=e,this.value[r][3]+=t,this.value[r][4]+=e,"C"==n&&(this.value[r][5]+=t,this.value[r][6]+=e)):"A"==n&&(this.value[r][6]+=t,this.value[r][7]+=e);return this},size:function(t,e){var i,n,r=this.bbox();for(i=this.value.length-1;i>=0;i--)n=this.value[i][0],"M"==n||"L"==n||"T"==n?(this.value[i][1]=(this.value[i][1]-r.x)*t/r.width+r.x,this.value[i][2]=(this.value[i][2]-r.y)*e/r.height+r.y):"H"==n?this.value[i][1]=(this.value[i][1]-r.x)*t/r.width+r.x:"V"==n?this.value[i][1]=(this.value[i][1]-r.y)*e/r.height+r.y:"C"==n||"S"==n||"Q"==n?(this.value[i][1]=(this.value[i][1]-r.x)*t/r.width+r.x,this.value[i][2]=(this.value[i][2]-r.y)*e/r.height+r.y,this.value[i][3]=(this.value[i][3]-r.x)*t/r.width+r.x,this.value[i][4]=(this.value[i][4]-r.y)*e/r.height+r.y,"C"==n&&(this.value[i][5]=(this.value[i][5]-r.x)*t/r.width+r.x,this.value[i][6]=(this.value[i][6]-r.y)*e/r.height+r.y)):"A"==n&&(this.value[i][1]=this.value[i][1]*t/r.width,this.value[i][2]=this.value[i][2]*e/r.height,this.value[i][6]=(this.value[i][6]-r.x)*t/r.width+r.x,this.value[i][7]=(this.value[i][7]-r.y)*e/r.height+r.y);return this},equalCommands:function(t){var e,i,n;for(t=new g.PathArray(t),n=this.value.length===t.value.length,e=0,i=this.value.length;n&&ea);return n},bbox:function(){return g.parser.path.setAttribute("d",this.toString()),g.parser.path.getBBox()}}),g.Number=g.invent({create:function(t,e){this.value=0,this.unit=e||"","number"==typeof t?this.value=isNaN(t)?0:isFinite(t)?t:t<0?-3.4e38:3.4e38:"string"==typeof t?(e=t.match(g.regex.numberAndUnit),e&&(this.value=parseFloat(e[1]),"%"==e[5]?this.value/=100:"s"==e[5]&&(this.value*=1e3),this.unit=e[5])):t instanceof g.Number&&(this.value=t.valueOf(),this.unit=t.unit)},extend:{toString:function(){return("%"==this.unit?~~(1e8*this.value)/1e6:"s"==this.unit?this.value/1e3:this.value)+this.unit},toJSON:function(){return this.toString()},valueOf:function(){return this.value},plus:function(t){return t=new g.Number(t),new g.Number(this+t,this.unit||t.unit)},minus:function(t){return t=new g.Number(t),new g.Number(this-t,this.unit||t.unit)},times:function(t){return t=new g.Number(t),new g.Number(this*t,this.unit||t.unit)},divide:function(t){return t=new g.Number(t),new g.Number(this/t,this.unit||t.unit)},to:function(t){var e=new g.Number(this);return"string"==typeof t&&(e.unit=t),e},morph:function(t){return this.destination=new g.Number(t),t.relative&&(this.destination.value+=this.value),this},at:function(t){return this.destination?new g.Number(this.destination).minus(this).times(t).plus(this):this}}}),g.Element=g.invent({create:function(t){this._stroke=g.defaults.attrs.stroke,this._event=null,this.dom={},(this.node=t)&&(this.type=t.nodeName,this.node.instance=this,this._stroke=t.getAttribute("stroke")||this._stroke)},extend:{x:function(t){return this.attr("x",t)},y:function(t){return this.attr("y",t)},cx:function(t){return null==t?this.x()+this.width()/2:this.x(t-this.width()/2)},cy:function(t){return null==t?this.y()+this.height()/2:this.y(t-this.height()/2)},move:function(t,e){return this.x(t).y(e)},center:function(t,e){return this.cx(t).cy(e)},width:function(t){return this.attr("width",t)},height:function(t){return this.attr("height",t)},size:function(t,e){var i=l(this,t,e);return this.width(new g.Number(i.width)).height(new g.Number(i.height))},clone:function(t,e){this.writeDataToDom();var i=x(this.node.cloneNode(!0));return t?t.add(i):this.after(i),i},remove:function(){return this.parent()&&this.parent().removeElement(this),this},replace:function(t){return this.after(t).remove(),t},addTo:function(t){return t.put(this)},putIn:function(t){return t.add(this)},id:function(t){return this.attr("id",t)},inside:function(t,e){var i=this.bbox();return t>i.x&&e>i.y&&t/,"").replace(/<\/svg>$/,"");i.innerHTML=""+t.replace(/\n/,"").replace(/<(\w+)([^<]+?)\/>/g,"<$1$2>")+"";for(var n=0,r=i.firstChild.childNodes.length;n":function(t){return-Math.cos(t*Math.PI)/2+.5},">":function(t){return Math.sin(t*Math.PI/2)},"<":function(t){return-Math.cos(t*Math.PI/2)+1}},g.morph=function(t){return function(e,i){return new g.MorphObj(e,i).at(t)}},g.Situation=g.invent({create:function(t){this.init=!1,this.reversed=!1,this.reversing=!1,this.duration=new g.Number(t.duration).valueOf(),this.delay=new g.Number(t.delay).valueOf(),this.start=+new Date+this.delay,this.finish=this.start+this.duration,this.ease=t.ease,this.loop=0,this.loops=!1,this.animations={},this.attrs={},this.styles={},this.transforms=[],this.once={}}}),g.FX=g.invent({create:function(t){this._target=t,this.situations=[],this.active=!1,this.situation=null,this.paused=!1,this.lastPos=0,this.pos=0,this.absPos=0,this._speed=1},extend:{animate:function(t,e,i){"object"==typeof t&&(e=t.ease,i=t.delay,t=t.duration);var n=new g.Situation({duration:t||1e3,delay:i||0,ease:g.easing[e||"-"]||e});return this.queue(n),this},delay:function(t){var e=new g.Situation({duration:t,delay:0,ease:g.easing["-"]});return this.queue(e)},target:function(t){return t&&t instanceof g.Element?(this._target=t,this):this._target},timeToAbsPos:function(t){return(t-this.situation.start)/(this.situation.duration/this._speed)},absPosToTime:function(t){return this.situation.duration/this._speed*t+this.situation.start},startAnimFrame:function(){this.stopAnimFrame(),this.animationFrame=t.requestAnimationFrame(function(){this.step()}.bind(this))},stopAnimFrame:function(){t.cancelAnimationFrame(this.animationFrame)},start:function(){return!this.active&&this.situation&&(this.active=!0,this.startCurrent()),this},startCurrent:function(){return this.situation.start=+new Date+this.situation.delay/this._speed,this.situation.finish=this.situation.start+this.situation.duration/this._speed,this.initAnimations().step()},queue:function(t){return("function"==typeof t||t instanceof g.Situation)&&this.situations.push(t),this.situation||(this.situation=this.situations.shift()),this},dequeue:function(){return this.stop(),this.situation=this.situations.shift(),this.situation&&(this.situation instanceof g.Situation?this.start():this.situation.call(this)),this},initAnimations:function(){var t,e,i=this.situation;if(i.init)return this;for(t in i.animations)e=this.target()[t](),i.animations[t]instanceof g.Number&&(e=new g.Number(e)),i.animations[t]=e.morph(i.animations[t]);for(t in i.attrs)i.attrs[t]=new g.MorphObj(this.target().attr(t),i.attrs[t]);for(t in i.styles)i.styles[t]=new g.MorphObj(this.target().style(t),i.styles[t]);return i.initialTransformation=this.target().matrixify(),i.init=!0,this},clearQueue:function(){return this.situations=[],this},clearCurrent:function(){return this.situation=null,this},stop:function(t,e){var i=this.active;return this.active=!1,e&&this.clearQueue(),t&&this.situation&&(!i&&this.startCurrent(),this.atEnd()),this.stopAnimFrame(),this.clearCurrent()},reset:function(){if(this.situation){var t=this.situation;this.stop(),this.situation=t,this.atStart()}return this},finish:function(){for(this.stop(!0,!1);this.dequeue().situation&&this.stop(!0,!1););return this.clearQueue().clearCurrent(),this},atStart:function(){return this.at(0,!0)},atEnd:function(){return this.situation.loops===!0&&(this.situation.loops=this.situation.loop+1),"number"==typeof this.situation.loops?this.at(this.situation.loops,!0):this.at(1,!0)},at:function(t,e){var i=this.situation.duration/this._speed;return this.absPos=t,e||(this.situation.reversed&&(this.absPos=1-this.absPos),this.absPos+=this.situation.loop),this.situation.start=+new Date-this.absPos*i,this.situation.finish=this.situation.start+i,this.step(!0)},speed:function(t){return 0===t?this.pause():t?(this._speed=t,this.at(this.absPos,!0)):this._speed},loop:function(t,e){var i=this.last();return i.loops=null==t||t,i.loop=0,e&&(i.reversing=!0),this},pause:function(){return this.paused=!0,this.stopAnimFrame(),this},play:function(){return this.paused?(this.paused=!1,this.at(this.absPos,!0)):this},reverse:function(t){var e=this.last();return"undefined"==typeof t?e.reversed=!e.reversed:e.reversed=t,this},progress:function(t){return t?this.situation.ease(this.pos):this.pos},after:function(t){var e=this.last(),i=function i(n){n.detail.situation==e&&(t.call(this,e),this.off("finished.fx",i))};return this.target().on("finished.fx",i),this._callStart()},during:function(t){var e=this.last(),i=function(i){i.detail.situation==e&&t.call(this,i.detail.pos,g.morph(i.detail.pos),i.detail.eased,e)};return this.target().off("during.fx",i).on("during.fx",i),this.after(function(){this.off("during.fx",i)}),this._callStart()},afterAll:function(t){var e=function e(i){t.call(this),this.off("allfinished.fx",e)};return this.target().off("allfinished.fx",e).on("allfinished.fx",e),this._callStart()},duringAll:function(t){var e=function(e){t.call(this,e.detail.pos,g.morph(e.detail.pos),e.detail.eased,e.detail.situation)};return this.target().off("during.fx",e).on("during.fx",e),this.afterAll(function(){this.off("during.fx",e)}),this._callStart()},last:function(){return this.situations.length?this.situations[this.situations.length-1]:this.situation},add:function(t,e,i){return this.last()[i||"animations"][t]=e,this._callStart()},step:function(t){if(t||(this.absPos=this.timeToAbsPos(+new Date)),this.situation.loops!==!1){var e,i,n;e=Math.max(this.absPos,0),i=Math.floor(e),this.situation.loops===!0||ithis.lastPos&&s<=r&&(this.situation.once[s].call(this.target(),this.pos,r),delete this.situation.once[s]);return this.active&&this.target().fire("during",{pos:this.pos,eased:r,fx:this,situation:this.situation}),this.situation?(this.eachAt(),1==this.pos&&!this.situation.reversed||this.situation.reversed&&0==this.pos?(this.stopAnimFrame(),this.target().fire("finished",{fx:this,situation:this.situation}),this.situations.length||(this.target().fire("allfinished"),this.target().off(".fx"),this.active=!1),this.active?this.dequeue():this.clearCurrent()):!this.paused&&this.active&&this.startAnimFrame(),this.lastPos=r,this):this},eachAt:function(){var t,e,i,n=this,r=this.target(),s=this.situation;for(t in s.animations)i=[].concat(s.animations[t]).map(function(t){return"string"!=typeof t&&t.at?t.at(s.ease(n.pos),n.pos):t}),r[t].apply(r,i);for(t in s.attrs)i=[t].concat(s.attrs[t]).map(function(t){return"string"!=typeof t&&t.at?t.at(s.ease(n.pos),n.pos):t}),r.attr.apply(r,i);for(t in s.styles)i=[t].concat(s.styles[t]).map(function(t){return"string"!=typeof t&&t.at?t.at(s.ease(n.pos),n.pos):t}),r.style.apply(r,i);if(s.transforms.length){for(i=s.initialTransformation,t=0,e=s.transforms.length;t1?[].slice.call(arguments):arguments[0])},leading:function(t){return this.target().leading?this.add("leading",new g.Number(t)):this},viewbox:function(t,e,i,n){return this.target()instanceof g.Container&&this.add("viewbox",new g.ViewBox(t,e,i,n)),this},update:function(t){if(this.target()instanceof g.Stop){if("number"==typeof t||t instanceof g.Number)return this.update({offset:arguments[0],color:arguments[1],opacity:arguments[2]});null!=t.opacity&&this.attr("stop-opacity",t.opacity),null!=t.color&&this.attr("stop-color",t.color),null!=t.offset&&this.attr("offset",t.offset)}return this}}),g.Box=g.invent({create:function(t,e,i,n){return"object"!=typeof t||t instanceof g.Element?(4==arguments.length&&(this.x=t,this.y=e,this.width=i,this.height=n),void y(this)):g.Box.call(this,null!=t.left?t.left:t.x,null!=t.top?t.top:t.y,t.width,t.height)},extend:{merge:function(t){var e=new this.constructor;return e.x=Math.min(this.x,t.x),e.y=Math.min(this.y,t.y),e.width=Math.max(this.x+this.width,t.x+t.width)-e.x,e.height=Math.max(this.y+this.height,t.y+t.height)-e.y,y(e)},transform:function(t){var e,i=1/0,n=-(1/0),r=1/0,s=-(1/0),o=[new g.Point(this.x,this.y),new g.Point(this.x2,this.y),new g.Point(this.x,this.y2),new g.Point(this.x2,this.y2)];return o.forEach(function(e){e=e.transform(t),i=Math.min(i,e.x),n=Math.max(n,e.x),r=Math.min(r,e.y),s=Math.max(s,e.y)}),e=new this.constructor,e.x=i,e.width=n-i,e.y=r,e.height=s-r,y(e),e}}}),g.BBox=g.invent({create:function(t){if(g.Box.apply(this,[].slice.call(arguments)),t instanceof g.Element){var i;try{if(e.documentElement.contains){if(!e.documentElement.contains(t.node))throw new Exception("Element not in the dom")}else{for(var n=t.node;n.parentNode;)n=n.parentNode;if(n!=e)throw new Exception("Element not in the dom")}i=t.node.getBBox()}catch(e){if(t instanceof g.Shape){var r=t.clone(g.parser.draw.instance).show();i=r.node.getBBox(),r.remove()}else i={x:t.node.clientLeft,y:t.node.clientTop,width:t.node.clientWidth,height:t.node.clientHeight}}g.Box.call(this,i)}},inherit:g.Box,parent:g.Element,construct:{bbox:function(){return new g.BBox(this)}}}),g.BBox.prototype.constructor=g.BBox,g.extend(g.Element,{tbox:function(){return console.warn("Use of TBox is deprecated and mapped to RBox. Use .rbox() instead."),this.rbox(this.doc())}}),g.RBox=g.invent({create:function(t){g.Box.apply(this,[].slice.call(arguments)),t instanceof g.Element&&g.Box.call(this,t.node.getBoundingClientRect())},inherit:g.Box,parent:g.Element,extend:{addOffset:function(){return this.x+=t.pageXOffset,this.y+=t.pageYOffset,this}},construct:{rbox:function(t){return t?new g.RBox(this).transform(t.screenCTM().inverse()):new g.RBox(this).addOffset()}}}),g.RBox.prototype.constructor=g.RBox,g.Matrix=g.invent({create:function(t){var e,i=f([1,0,0,1,0,0]);for(t=t instanceof g.Element?t.matrixify():"string"==typeof t?f(t.split(g.regex.delimiter).map(parseFloat)):6==arguments.length?f([].slice.call(arguments)):Array.isArray(t)?f(t):"object"==typeof t?t:i,e=P.length-1;e>=0;--e)this[P[e]]=t&&"number"==typeof t[P[e]]?t[P[e]]:i[P[e]]},extend:{extract:function(){var t=c(this,0,1),e=c(this,1,0),i=180/Math.PI*Math.atan2(t.y,t.x)-90;return{x:this.e,y:this.f,transformedX:(this.e*Math.cos(i*Math.PI/180)+this.f*Math.sin(i*Math.PI/180))/Math.sqrt(this.a*this.a+this.b*this.b),transformedY:(this.f*Math.cos(i*Math.PI/180)+this.e*Math.sin(-i*Math.PI/180))/Math.sqrt(this.c*this.c+this.d*this.d),skewX:-i,skewY:180/Math.PI*Math.atan2(e.y,e.x),scaleX:Math.sqrt(this.a*this.a+this.b*this.b),scaleY:Math.sqrt(this.c*this.c+this.d*this.d),rotation:i,a:this.a,b:this.b,c:this.c,d:this.d,e:this.e,f:this.f,matrix:new g.Matrix(this)}},clone:function(){return new g.Matrix(this)},morph:function(t){return this.destination=new g.Matrix(t),this},at:function(t){if(!this.destination)return this;var e=new g.Matrix({a:this.a+(this.destination.a-this.a)*t,b:this.b+(this.destination.b-this.b)*t,c:this.c+(this.destination.c-this.c)*t,d:this.d+(this.destination.d-this.d)*t,e:this.e+(this.destination.e-this.e)*t,f:this.f+(this.destination.f-this.f)*t});return e},multiply:function(t){return new g.Matrix(this.native().multiply(d(t).native()))},inverse:function(){return new g.Matrix(this.native().inverse())},translate:function(t,e){return new g.Matrix(this.native().translate(t||0,e||0))},scale:function(t,e,i,n){return 1==arguments.length?e=t:3==arguments.length&&(n=i,i=e,e=t),this.around(i,n,new g.Matrix(t,0,0,e,0,0))},rotate:function(t,e,i){return t=g.utils.radians(t),this.around(e,i,new g.Matrix(Math.cos(t),Math.sin(t),-Math.sin(t),Math.cos(t),0,0))},flip:function(t,e){return"x"==t?this.scale(-1,1,e,0):"y"==t?this.scale(1,-1,0,e):this.scale(-1,-1,t,null!=e?e:t)},skew:function(t,e,i,n){return 1==arguments.length?e=t:3==arguments.length&&(n=i,i=e,e=t),t=g.utils.radians(t),e=g.utils.radians(e),this.around(i,n,new g.Matrix(1,Math.tan(e),Math.tan(t),1,0,0))},skewX:function(t,e,i){return this.skew(t,0,e,i)},skewY:function(t,e,i){return this.skew(0,t,e,i)},around:function(t,e,i){return this.multiply(new g.Matrix(1,0,0,1,t||0,e||0)).multiply(i).multiply(new g.Matrix(1,0,0,1,-t||0,-e||0))},native:function(){for(var t=g.parser.native.createSVGMatrix(),e=P.length-1;e>=0;e--)t[P[e]]=this[P[e]];return t},toString:function(){return"matrix("+this.a+","+this.b+","+this.c+","+this.d+","+this.e+","+this.f+")"}},parent:g.Element,construct:{ctm:function(){return new g.Matrix(this.node.getCTM()); +},screenCTM:function(){if(this instanceof g.Nested){var t=this.rect(1,1),e=t.node.getScreenCTM();return t.remove(),new g.Matrix(e)}return new g.Matrix(this.node.getScreenCTM())}}}),g.Point=g.invent({create:function(t,e){var i,n={x:0,y:0};i=Array.isArray(t)?{x:t[0],y:t[1]}:"object"==typeof t?{x:t.x,y:t.y}:null!=t?{x:t,y:null!=e?e:t}:n,this.x=i.x,this.y=i.y},extend:{clone:function(){return new g.Point(this)},morph:function(t,e){return this.destination=new g.Point(t,e),this},at:function(t){if(!this.destination)return this;var e=new g.Point({x:this.x+(this.destination.x-this.x)*t,y:this.y+(this.destination.y-this.y)*t});return e},native:function(){var t=g.parser.native.createSVGPoint();return t.x=this.x,t.y=this.y,t},transform:function(t){return new g.Point(this.native().matrixTransform(t.native()))}}}),g.extend(g.Element,{point:function(t,e){return new g.Point(t,e).transform(this.screenCTM().inverse())}}),g.extend(g.Element,{attr:function(t,e,i){if(null==t){for(t={},e=this.node.attributes,i=e.length-1;i>=0;i--)t[e[i].nodeName]=g.regex.isNumber.test(e[i].nodeValue)?parseFloat(e[i].nodeValue):e[i].nodeValue;return t}if("object"==typeof t)for(e in t)this.attr(e,t[e]);else if(null===e)this.node.removeAttribute(t);else{if(null==e)return e=this.node.getAttribute(t),null==e?g.defaults.attrs[t]:g.regex.isNumber.test(e)?parseFloat(e):e;"stroke-width"==t?this.attr("stroke",parseFloat(e)>0?this._stroke:null):"stroke"==t&&(this._stroke=e),"fill"!=t&&"stroke"!=t||(g.regex.isImage.test(e)&&(e=this.doc().defs().image(e,0,0)),e instanceof g.Image&&(e=this.doc().defs().pattern(0,0,function(){this.add(e)}))),"number"==typeof e?e=new g.Number(e):g.Color.isColor(e)?e=new g.Color(e):Array.isArray(e)&&(e=new g.Array(e)),"leading"==t?this.leading&&this.leading(e):"string"==typeof i?this.node.setAttributeNS(i,t,e.toString()):this.node.setAttribute(t,e.toString()),!this.rebuild||"font-size"!=t&&"x"!=t||this.rebuild(t,e)}return this}}),g.extend(g.Element,{transform:function(t,e){var i,n,r=this;if("object"!=typeof t)return i=new g.Matrix(r).extract(),"string"==typeof t?i[t]:i;if(i=new g.Matrix(r),e=!!e||!!t.relative,null!=t.a)i=e?i.multiply(new g.Matrix(t)):new g.Matrix(t);else if(null!=t.rotation)p(t,r),i=e?i.rotate(t.rotation,t.cx,t.cy):i.rotate(t.rotation-i.extract().rotation,t.cx,t.cy);else if(null!=t.scale||null!=t.scaleX||null!=t.scaleY){if(p(t,r),t.scaleX=null!=t.scale?t.scale:null!=t.scaleX?t.scaleX:1,t.scaleY=null!=t.scale?t.scale:null!=t.scaleY?t.scaleY:1,!e){var s=i.extract();t.scaleX=1*t.scaleX/s.scaleX,t.scaleY=1*t.scaleY/s.scaleY}i=i.scale(t.scaleX,t.scaleY,t.cx,t.cy)}else if(null!=t.skew||null!=t.skewX||null!=t.skewY){if(p(t,r),t.skewX=null!=t.skew?t.skew:null!=t.skewX?t.skewX:0,t.skewY=null!=t.skew?t.skew:null!=t.skewY?t.skewY:0,!e){var s=i.extract();i=i.multiply((new g.Matrix).skew(s.skewX,s.skewY,t.cx,t.cy).inverse())}i=i.skew(t.skewX,t.skewY,t.cx,t.cy)}else t.flip?("x"==t.flip||"y"==t.flip?t.offset=null==t.offset?r.bbox()["c"+t.flip]:t.offset:null==t.offset?(n=r.bbox(),t.flip=n.cx,t.offset=n.cy):t.flip=t.offset,i=(new g.Matrix).flip(t.flip,t.offset)):null==t.x&&null==t.y||(e?i=i.translate(t.x,t.y):(null!=t.x&&(i.e=t.x),null!=t.y&&(i.f=t.y)));return this.attr("transform",i)}}),g.extend(g.FX,{transform:function(t,e){var i,n,r=this.target();return"object"!=typeof t?(i=new g.Matrix(r).extract(),"string"==typeof t?i[t]:i):(e=!!e||!!t.relative,null!=t.a?i=new g.Matrix(t):null!=t.rotation?(p(t,r),i=new g.Rotate(t.rotation,t.cx,t.cy)):null!=t.scale||null!=t.scaleX||null!=t.scaleY?(p(t,r),t.scaleX=null!=t.scale?t.scale:null!=t.scaleX?t.scaleX:1,t.scaleY=null!=t.scale?t.scale:null!=t.scaleY?t.scaleY:1,i=new g.Scale(t.scaleX,t.scaleY,t.cx,t.cy)):null!=t.skewX||null!=t.skewY?(p(t,r),t.skewX=null!=t.skewX?t.skewX:0,t.skewY=null!=t.skewY?t.skewY:0,i=new g.Skew(t.skewX,t.skewY,t.cx,t.cy)):t.flip?("x"==t.flip||"y"==t.flip?t.offset=null==t.offset?r.bbox()["c"+t.flip]:t.offset:null==t.offset?(n=r.bbox(),t.flip=n.cx,t.offset=n.cy):t.flip=t.offset,i=(new g.Matrix).flip(t.flip,t.offset)):null==t.x&&null==t.y||(i=new g.Translate(t.x,t.y)),i?(i.relative=e,this.last().transforms.push(i),this._callStart()):this)}}),g.extend(g.Element,{untransform:function(){return this.attr("transform",null)},matrixify:function(){var t=(this.attr("transform")||"").split(g.regex.transforms).slice(0,-1).map(function(t){var e=t.trim().split("(");return[e[0],e[1].split(g.regex.delimiter).map(function(t){return parseFloat(t)})]}).reduce(function(t,e){return"matrix"==e[0]?t.multiply(f(e[1])):t[e[0]].apply(t,e[1])},new g.Matrix);return t},toParent:function(t){if(this==t)return this;var e=this.screenCTM(),i=t.screenCTM().inverse();return this.addTo(t).untransform().transform(i.multiply(e)),this},toDoc:function(){return this.toParent(this.doc())}}),g.Transformation=g.invent({create:function(t,e){if(arguments.length>1&&"boolean"!=typeof e)return this.constructor.call(this,[].slice.call(arguments));if(Array.isArray(t))for(var i=0,n=this.arguments.length;i=0},index:function(t){return[].slice.call(this.node.childNodes).indexOf(t.node)},get:function(t){return g.adopt(this.node.childNodes[t])},first:function(){return this.get(0)},last:function(){return this.get(this.node.childNodes.length-1)},each:function(t,e){var i,n,r=this.children();for(i=0,n=r.length;in/r?this.height/r:this.width/n,this.x=e,this.y=i,this.width=n,this.height=r)}else t="string"==typeof t?t.match(f).map(function(t){return parseFloat(t)}):Array.isArray(t)?t:"object"==typeof t?[t.x,t.y,t.width,t.height]:4==arguments.length?[].slice.call(arguments):u,this.x=t[0],this.y=t[1],this.width=t[2],this.height=t[3]},extend:{toString:function(){return this.x+" "+this.y+" "+this.width+" "+this.height},morph:function(t,e,i,n){return this.destination=new g.ViewBox(t,e,i,n),this},at:function(t){return this.destination?new g.ViewBox([this.x+(this.destination.x-this.x)*t,this.y+(this.destination.y-this.y)*t,this.width+(this.destination.width-this.width)*t,this.height+(this.destination.height-this.height)*t]):this}},parent:g.Container,construct:{viewbox:function(t,e,i,n){return 0==arguments.length?new g.ViewBox(this):this.attr("viewBox",new g.ViewBox(t,e,i,n))}}}),["click","dblclick","mousedown","mouseup","mouseover","mouseout","mousemove","touchstart","touchmove","touchleave","touchend","touchcancel"].forEach(function(t){g.Element.prototype[t]=function(e){return g.on(this.node,t,e),this}}),g.listeners=[],g.handlerMap=[],g.listenerId=0,g.on=function(t,e,i,n,r){var s=i.bind(n||t.instance||t),o=(g.handlerMap.indexOf(t)+1||g.handlerMap.push(t))-1,a=e.split(".")[0],h=e.split(".")[1]||"*";g.listeners[o]=g.listeners[o]||{},g.listeners[o][a]=g.listeners[o][a]||{},g.listeners[o][a][h]=g.listeners[o][a][h]||{},i._svgjsListenerId||(i._svgjsListenerId=++g.listenerId),g.listeners[o][a][h][i._svgjsListenerId]=s,t.addEventListener(a,s,r||!1)},g.off=function(t,e,i){var n=g.handlerMap.indexOf(t),r=e&&e.split(".")[0],s=e&&e.split(".")[1],o="";if(n!=-1)if(i){if("function"==typeof i&&(i=i._svgjsListenerId),!i)return;g.listeners[n][r]&&g.listeners[n][r][s||"*"]&&(t.removeEventListener(r,g.listeners[n][r][s||"*"][i],!1),delete g.listeners[n][r][s||"*"][i])}else if(s&&r){if(g.listeners[n][r]&&g.listeners[n][r][s]){for(i in g.listeners[n][r][s])g.off(t,[r,s].join("."),i);delete g.listeners[n][r][s]}}else if(s)for(e in g.listeners[n])for(o in g.listeners[n][e])s===o&&g.off(t,[e,s].join("."));else if(r){if(g.listeners[n][r]){for(o in g.listeners[n][r])g.off(t,[r,o].join("."));delete g.listeners[n][r]}}else{for(e in g.listeners[n])g.off(t,e);delete g.listeners[n],delete g.handlerMap[n]}},g.extend(g.Element,{on:function(t,e,i,n){return g.on(this.node,t,e,i,n),this},off:function(t,e){return g.off(this.node,t,e),this},fire:function(e,i){return e instanceof t.Event?this.node.dispatchEvent(e):this.node.dispatchEvent(e=new t.CustomEvent(e,{detail:i,cancelable:!0})),this._event=e,this},event:function(){return this._event}}),g.Defs=g.invent({create:"defs",inherit:g.Container}),g.G=g.invent({create:"g",inherit:g.Container,extend:{x:function(t){return null==t?this.transform("x"):this.transform({x:t-this.x()},!0)},y:function(t){return null==t?this.transform("y"):this.transform({y:t-this.y()},!0)},cx:function(t){return null==t?this.gbox().cx:this.x(t-this.gbox().width/2)},cy:function(t){return null==t?this.gbox().cy:this.y(t-this.gbox().height/2)},gbox:function(){var t=this.bbox(),e=this.transform();return t.x+=e.x,t.x2+=e.x,t.cx+=e.x,t.y+=e.y,t.y2+=e.y,t.cy+=e.y,t}},construct:{group:function(){return this.put(new g.G)}}}),g.extend(g.Element,{siblings:function(){return this.parent().children()},position:function(){return this.parent().index(this)},next:function(){return this.siblings()[this.position()+1]},previous:function(){return this.siblings()[this.position()-1]},forward:function(){var t=this.position()+1,e=this.parent();return e.removeElement(this).add(this,t),e instanceof g.Doc&&e.node.appendChild(e.defs().node),this},backward:function(){var t=this.position();return t>0&&this.parent().removeElement(this).add(this,t-1),this},front:function(){var t=this.parent();return t.node.appendChild(this.node),t instanceof g.Doc&&t.node.appendChild(t.defs().node),this},back:function(){return this.position()>0&&this.parent().removeElement(this).add(this,0),this},before:function(t){t.remove();var e=this.position();return this.parent().add(t,e),this},after:function(t){t.remove();var e=this.position();return this.parent().add(t,e+1),this}}),g.Mask=g.invent({create:function(){this.constructor.call(this,g.create("mask")),this.targets=[]},inherit:g.Container,extend:{remove:function(){for(var t=this.targets.length-1;t>=0;t--)this.targets[t]&&this.targets[t].unmask();return this.targets=[],this.parent().removeElement(this),this}},construct:{mask:function(){return this.defs().put(new g.Mask)}}}),g.extend(g.Element,{maskWith:function(t){return this.masker=t instanceof g.Mask?t:this.parent().mask().add(t),this.masker.targets.push(this),this.attr("mask",'url("#'+this.masker.attr("id")+'")')},unmask:function(){return delete this.masker,this.attr("mask",null)}}),g.ClipPath=g.invent({create:function(){this.constructor.call(this,g.create("clipPath")),this.targets=[]},inherit:g.Container,extend:{remove:function(){for(var t=this.targets.length-1;t>=0;t--)this.targets[t]&&this.targets[t].unclip();return this.targets=[],this.parent().removeElement(this),this}},construct:{clip:function(){return this.defs().put(new g.ClipPath)}}}),g.extend(g.Element,{clipWith:function(t){return this.clipper=t instanceof g.ClipPath?t:this.parent().clip().add(t),this.clipper.targets.push(this),this.attr("clip-path",'url("#'+this.clipper.attr("id")+'")')},unclip:function(){return delete this.clipper,this.attr("clip-path",null)}}),g.Gradient=g.invent({create:function(t){this.constructor.call(this,g.create(t+"Gradient")),this.type=t},inherit:g.Container,extend:{at:function(t,e,i){return this.put(new g.Stop).update(t,e,i)},update:function(t){return this.clear(),"function"==typeof t&&t.call(this,this),this},fill:function(){return"url(#"+this.id()+")"},toString:function(){return this.fill()},attr:function(t,e,i){return"transform"==t&&(t="gradientTransform"),g.Container.prototype.attr.call(this,t,e,i)}},construct:{gradient:function(t,e){return this.defs().gradient(t,e)}}}),g.extend(g.Gradient,g.FX,{from:function(t,e){return"radial"==(this._target||this).type?this.attr({fx:new g.Number(t),fy:new g.Number(e)}):this.attr({x1:new g.Number(t),y1:new g.Number(e)})},to:function(t,e){return"radial"==(this._target||this).type?this.attr({cx:new g.Number(t),cy:new g.Number(e)}):this.attr({x2:new g.Number(t),y2:new g.Number(e)})}}),g.extend(g.Defs,{gradient:function(t,e){return this.put(new g.Gradient(t)).update(e)}}),g.Stop=g.invent({create:"stop",inherit:g.Element,extend:{update:function(t){return("number"==typeof t||t instanceof g.Number)&&(t={offset:arguments[0],color:arguments[1],opacity:arguments[2]}),null!=t.opacity&&this.attr("stop-opacity",t.opacity),null!=t.color&&this.attr("stop-color",t.color),null!=t.offset&&this.attr("offset",new g.Number(t.offset)),this}}}),g.Pattern=g.invent({create:"pattern",inherit:g.Container,extend:{fill:function(){return"url(#"+this.id()+")"},update:function(t){return this.clear(),"function"==typeof t&&t.call(this,this),this},toString:function(){return this.fill()},attr:function(t,e,i){return"transform"==t&&(t="patternTransform"),g.Container.prototype.attr.call(this,t,e,i)}},construct:{pattern:function(t,e,i){return this.defs().pattern(t,e,i)}}}),g.extend(g.Defs,{pattern:function(t,e,i){return this.put(new g.Pattern).update(i).attr({x:0,y:0,width:t,height:e,patternUnits:"userSpaceOnUse"})}}),g.Doc=g.invent({create:function(t){t&&(t="string"==typeof t?e.getElementById(t):t,"svg"==t.nodeName?this.constructor.call(this,t):(this.constructor.call(this,g.create("svg")),t.appendChild(this.node),this.size("100%","100%")),this.namespace().defs())},inherit:g.Container,extend:{namespace:function(){return this.attr({xmlns:g.ns,version:"1.1"}).attr("xmlns:xlink",g.xlink,g.xmlns).attr("xmlns:svgjs",g.svgjs,g.xmlns)},defs:function(){if(!this._defs){var t;(t=this.node.getElementsByTagName("defs")[0])?this._defs=g.adopt(t):this._defs=new g.Defs,this.node.appendChild(this._defs.node)}return this._defs},parent:function(){return"#document"==this.node.parentNode.nodeName?null:this.node.parentNode},spof:function(t){var e=this.node.getScreenCTM();return e&&this.style("left",-e.e%1+"px").style("top",-e.f%1+"px"),this},remove:function(){return this.parent()&&this.parent().removeChild(this.node),this},clear:function(){for(;this.node.hasChildNodes();)this.node.removeChild(this.node.lastChild);return delete this._defs,g.parser.draw.parentNode||this.node.appendChild(g.parser.draw),this}}}),g.Shape=g.invent({create:function(t){this.constructor.call(this,t)},inherit:g.Element}),g.Bare=g.invent({create:function(t,e){if(this.constructor.call(this,g.create(t)),e)for(var i in e.prototype)"function"==typeof e.prototype[i]&&(this[i]=e.prototype[i])},inherit:g.Element,extend:{words:function(t){for(;this.node.hasChildNodes();)this.node.removeChild(this.node.lastChild);return this.node.appendChild(e.createTextNode(t)),this}}}),g.extend(g.Parent,{element:function(t,e){return this.put(new g.Bare(t,e))}}),g.Symbol=g.invent({create:"symbol",inherit:g.Container,construct:{symbol:function(){return this.put(new g.Symbol)}}}),g.Use=g.invent({create:"use",inherit:g.Shape,extend:{element:function(t,e){return this.attr("href",(e||"")+"#"+t,g.xlink)}},construct:{use:function(t,e){return this.put(new g.Use).element(t,e)}}}),g.Rect=g.invent({create:"rect",inherit:g.Shape,construct:{rect:function(t,e){return this.put(new g.Rect).size(t,e)}}}),g.Circle=g.invent({create:"circle",inherit:g.Shape,construct:{circle:function(t){return this.put(new g.Circle).rx(new g.Number(t).divide(2)).move(0,0)}}}),g.extend(g.Circle,g.FX,{rx:function(t){return this.attr("r",t)},ry:function(t){return this.rx(t)}}),g.Ellipse=g.invent({create:"ellipse",inherit:g.Shape,construct:{ellipse:function(t,e){return this.put(new g.Ellipse).size(t,e).move(0,0)}}}),g.extend(g.Ellipse,g.Rect,g.FX,{rx:function(t){return this.attr("rx",t)},ry:function(t){return this.attr("ry",t)}}),g.extend(g.Circle,g.Ellipse,{x:function(t){return null==t?this.cx()-this.rx():this.cx(t+this.rx())},y:function(t){return null==t?this.cy()-this.ry():this.cy(t+this.ry())},cx:function(t){return null==t?this.attr("cx"):this.attr("cx",t)},cy:function(t){return null==t?this.attr("cy"):this.attr("cy",t)},width:function(t){return null==t?2*this.rx():this.rx(new g.Number(t).divide(2))},height:function(t){return null==t?2*this.ry():this.ry(new g.Number(t).divide(2))},size:function(t,e){var i=l(this,t,e);return this.rx(new g.Number(i.width).divide(2)).ry(new g.Number(i.height).divide(2))}}),g.Line=g.invent({create:"line",inherit:g.Shape,extend:{array:function(){return new g.PointArray([[this.attr("x1"),this.attr("y1")],[this.attr("x2"),this.attr("y2")]])},plot:function(t,e,i,n){return null==t?this.array():(t="undefined"!=typeof e?{x1:t,y1:e,x2:i,y2:n}:new g.PointArray(t).toLine(),this.attr(t))},move:function(t,e){return this.attr(this.array().move(t,e).toLine())},size:function(t,e){var i=l(this,t,e);return this.attr(this.array().size(i.width,i.height).toLine())}},construct:{line:function(t,e,i,n){return g.Line.prototype.plot.apply(this.put(new g.Line),null!=t?[t,e,i,n]:[0,0,0,0])}}}),g.Polyline=g.invent({create:"polyline",inherit:g.Shape,construct:{polyline:function(t){return this.put(new g.Polyline).plot(t||new g.PointArray)}}}),g.Polygon=g.invent({create:"polygon",inherit:g.Shape,construct:{polygon:function(t){return this.put(new g.Polygon).plot(t||new g.PointArray)}}}),g.extend(g.Polyline,g.Polygon,{array:function(){return this._array||(this._array=new g.PointArray(this.attr("points")))},plot:function(t){return null==t?this.array():this.clear().attr("points","string"==typeof t?t:this._array=new g.PointArray(t))},clear:function(){return delete this._array,this},move:function(t,e){return this.attr("points",this.array().move(t,e))},size:function(t,e){var i=l(this,t,e);return this.attr("points",this.array().size(i.width,i.height))}}),g.extend(g.Line,g.Polyline,g.Polygon,{morphArray:g.PointArray,x:function(t){return null==t?this.bbox().x:this.move(t,this.bbox().y)},y:function(t){return null==t?this.bbox().y:this.move(this.bbox().x,t)},width:function(t){var e=this.bbox();return null==t?e.width:this.size(t,e.height)},height:function(t){var e=this.bbox();return null==t?e.height:this.size(e.width,t)}}),g.Path=g.invent({create:"path",inherit:g.Shape,extend:{morphArray:g.PathArray,array:function(){return this._array||(this._array=new g.PathArray(this.attr("d")))},plot:function(t){return null==t?this.array():this.clear().attr("d","string"==typeof t?t:this._array=new g.PathArray(t))},clear:function(){return delete this._array,this},move:function(t,e){return this.attr("d",this.array().move(t,e))},x:function(t){return null==t?this.bbox().x:this.move(t,this.bbox().y)},y:function(t){return null==t?this.bbox().y:this.move(this.bbox().x,t)},size:function(t,e){var i=l(this,t,e);return this.attr("d",this.array().size(i.width,i.height))},width:function(t){return null==t?this.bbox().width:this.size(t,this.bbox().height)},height:function(t){return null==t?this.bbox().height:this.size(this.bbox().width,t)}},construct:{path:function(t){return this.put(new g.Path).plot(t||new g.PathArray)}}}),g.Image=g.invent({create:"image",inherit:g.Shape,extend:{load:function(e){if(!e)return this;var i=this,n=new t.Image;return g.on(n,"load",function(){var t=i.parent(g.Pattern);null!==t&&(0==i.width()&&0==i.height()&&i.size(n.width,n.height),t&&0==t.width()&&0==t.height()&&t.size(i.width(),i.height()),"function"==typeof i._loaded&&i._loaded.call(i,{width:n.width,height:n.height,ratio:n.width/n.height,url:e}))}),g.on(n,"error",function(t){"function"==typeof i._error&&i._error.call(i,t)}),this.attr("href",n.src=this.src=e,g.xlink)},loaded:function(t){return this._loaded=t,this},error:function(t){return this._error=t,this}},construct:{image:function(t,e,i){return this.put(new g.Image).load(t).size(e||0,i||e||0)}}}),g.Text=g.invent({create:function(){this.constructor.call(this,g.create("text")),this.dom.leading=new g.Number(1.3),this._rebuild=!0,this._build=!1,this.attr("font-family",g.defaults.attrs["font-family"])},inherit:g.Shape,extend:{x:function(t){return null==t?this.attr("x"):this.attr("x",t)},y:function(t){var e=this.attr("y"),i="number"==typeof e?e-this.bbox().y:0;return null==t?"number"==typeof e?e-i:e:this.attr("y","number"==typeof t?t+i:t)},cx:function(t){return null==t?this.bbox().cx:this.x(t-this.bbox().width/2)},cy:function(t){return null==t?this.bbox().cy:this.y(t-this.bbox().height/2)},text:function(t){if("undefined"==typeof t){for(var t="",e=this.node.childNodes,i=0,n=e.length;i=0;e--)null!=i[M[t][e]]&&this.attr(M.prefix(t,M[t][e]),i[M[t][e]]);return this},g.extend(g.Element,g.FX,i)}),g.extend(g.Element,g.FX,{rotate:function(t,e,i){return this.transform({rotation:t,cx:e,cy:i})},skew:function(t,e,i,n){return 1==arguments.length||3==arguments.length?this.transform({skew:t,cx:e,cy:i}):this.transform({skewX:t,skewY:e,cx:i,cy:n})},scale:function(t,e,i,n){return 1==arguments.length||3==arguments.length?this.transform({scale:t,cx:e,cy:i}):this.transform({scaleX:t,scaleY:e,cx:i,cy:n})},translate:function(t,e){return this.transform({x:t,y:e})},flip:function(t,e){return e="number"==typeof t?t:e,this.transform({flip:t||"both",offset:e})},matrix:function(t){return this.attr("transform",new g.Matrix(6==arguments.length?[].slice.call(arguments):t))},opacity:function(t){return this.attr("opacity",t)},dx:function(t){return this.x(new g.Number(t).plus(this instanceof g.FX?0:this.x()),!0)},dy:function(t){return this.y(new g.Number(t).plus(this instanceof g.FX?0:this.y()),!0)},dmove:function(t,e){return this.dx(t).dy(e)}}),g.extend(g.Rect,g.Ellipse,g.Circle,g.Gradient,g.FX,{radius:function(t,e){var i=(this._target||this).type;return"radial"==i||"circle"==i?this.attr("r",new g.Number(t)):this.rx(t).ry(null==e?t:e)}}),g.extend(g.Path,{length:function(){return this.node.getTotalLength()},pointAt:function(t){return this.node.getPointAtLength(t)}}),g.extend(g.Parent,g.Text,g.Tspan,g.FX,{font:function(t,e){if("object"==typeof t)for(e in t)this.font(e,t[e]);return"leading"==t?this.leading(e):"anchor"==t?this.attr("text-anchor",e):"size"==t||"family"==t||"weight"==t||"stretch"==t||"variant"==t||"style"==t?this.attr("font-"+t,e):this.attr(t,e)}}),g.Set=g.invent({create:function(t){Array.isArray(t)?this.members=t:this.clear()},extend:{add:function(){var t,e,i=[].slice.call(arguments);for(t=0,e=i.length;t-1&&this.members.splice(e,1),this},each:function(t){for(var e=0,i=this.members.length;e=0},index:function(t){return this.members.indexOf(t)},get:function(t){return this.members[t]},first:function(){return this.get(0)},last:function(){return this.get(this.members.length-1)},valueOf:function(){return this.members},bbox:function(){if(0==this.members.length)return new g.RBox;var t=this.members[0].rbox(this.members[0].doc());return this.each(function(){t=t.merge(this.rbox(this.doc()))}),t}},construct:{set:function(t){return new g.Set(t)}}}),g.FX.Set=g.invent({create:function(t){this.set=t}}),g.Set.inherit=function(){var t,e=[];for(var t in g.Shape.prototype)"function"==typeof g.Shape.prototype[t]&&"function"!=typeof g.Set.prototype[t]&&e.push(t);e.forEach(function(t){g.Set.prototype[t]=function(){for(var e=0,i=this.members.length;e=0;t--)delete this.memory()[arguments[t]];return this},memory:function(){return this._memory||(this._memory={})}}),g.get=function(t){var i=e.getElementById(v(t)||t);return g.adopt(i)},g.select=function(t,i){return new g.Set(g.utils.map((i||e).querySelectorAll(t),function(t){return g.adopt(t)}))},g.extend(g.Parent,{select:function(t){return g.select(t,this.node)}});var P="abcdef".split("");if("function"!=typeof t.CustomEvent){var A=function(t,i){i=i||{bubbles:!1,cancelable:!1,detail:void 0};var n=e.createEvent("CustomEvent");return n.initCustomEvent(t,i.bubbles,i.cancelable,i.detail),n};A.prototype=t.Event.prototype,t.CustomEvent=A}return function(e){for(var i=0,n=["moz","webkit"],r=0;r", + "main": "dist/svg.js", + "jam": { + "include": [ + "dist/svg.js", + "README.md", + "LICENSE.txt" + ] + }, + "maintainers": [ + { + "name": "Wout Fierens", + "email": "wout@mick-wout.com", + "web": "https://svgdotjs.github.io/" + }, + { + "name": "Alex Ewerlöf", + "email": "alex@userpixel.com", + "web": "http://www.ewerlof.name" + }, + { + "name": "Ulrich-Matthias Schäfer", + "email": "ulima.ums@googlemail.com" + }, + { + "name": "Jon Ege Ronnenberg", + "email": "jon@svgjs.com", + "url": "https://keybase.io/dotnetcarpenter" + } + ], + "licenses": [ + { + "type": "MIT", + "url": "http://www.opensource.org/licenses/mit-license.php" + } + ], + "repository": { + "type": "git", + "url": "https://github.com/svgdotjs/svg.js.git" + }, + "github": "https://github.com/svgdotjs/svg.js", + "license": "MIT", + "typings": "./svg.js.d.ts", + "scripts": { + "build": "gulp", + "build:test": "gulp unify", + "test": "karma start .config/karma.conf.js --single-run", + "test:quick": "karma start .config/karma.quick.js" + }, + "devDependencies": { + "coveralls": "^2.11.15", + "del": "^2.2.0", + "gulp": "^3.8.6", + "gulp-chmod": "^2.0.0", + "gulp-cli": "^1.2.2", + "gulp-concat": "^2.3.3", + "gulp-header": "^1.0.5", + "gulp-rename": "^1.2.0", + "gulp-size": "^2.1.0", + "gulp-trimlines": "^1.0.0", + "gulp-uglify": "^2.0.0", + "gulp-wrap": "^0.13.0", + "jasmine-core": "^2.5.2", + "karma": "^1.3.0", + "karma-coverage": "^1.1.1", + "karma-firefox-launcher": "^1.0.0", + "karma-jasmine": "^1.0.2", + "karma-phantomjs-launcher": "^1.0.2", + "request": "^2.78.0", + "svgdom": "latest" + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/package-lock.json b/files/plugin-HeatmapSessionRecording-5.6.0/package-lock.json new file mode 100644 index 0000000..c48e32e --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/package-lock.json @@ -0,0 +1,1243 @@ +{ + "name": "heatmapsessionrecording", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "heatmapsessionrecording", + "version": "1.0.0", + "hasInstallScript": true, + "license": "InnoCraft EULA", + "dependencies": { + "@types/heatmap.js": "^2.0.37", + "heatmap.js": "^2.0.5", + "html-to-image": "^1.11.13" + }, + "devDependencies": { + "patch-package": "^8.0.1" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.8", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz", + "integrity": "sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA==" + }, + "node_modules/@types/heatmap.js": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/@types/heatmap.js/-/heatmap.js-2.0.37.tgz", + "integrity": "sha512-Zd1m6WaRSPnXcR1fETGnIvyRSE2rcQK21S0zIU/LWjwsrNyKBA3xdckrQhQpIdG+UTeu7WODv237s30Ky7IVXg==", + "dependencies": { + "@types/leaflet": "^0" + } + }, + "node_modules/@types/leaflet": { + "version": "0.7.35", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-0.7.35.tgz", + "integrity": "sha512-BK+pa9a9dYC1qJyYQulqkRI9N+ZnV4ycAmNSOUmom7C6xaAdmrhOoiCiDMhSQklyjPpasy3KWRTkTRTJuDbBSw==", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "engines": { + "node": ">=8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "dependencies": { + "micromatch": "^4.0.2" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/heatmap.js": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/heatmap.js/-/heatmap.js-2.0.5.tgz", + "integrity": "sha1-Rm07hlE/XUkRKknSVwCrJzAUkVM=" + }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==" + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/patch-package": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", + "dev": true, + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^10.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.2.4", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + }, + "dependencies": { + "@types/geojson": { + "version": "7946.0.8", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz", + "integrity": "sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA==" + }, + "@types/heatmap.js": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/@types/heatmap.js/-/heatmap.js-2.0.37.tgz", + "integrity": "sha512-Zd1m6WaRSPnXcR1fETGnIvyRSE2rcQK21S0zIU/LWjwsrNyKBA3xdckrQhQpIdG+UTeu7WODv237s30Ky7IVXg==", + "requires": { + "@types/leaflet": "^0" + } + }, + "@types/leaflet": { + "version": "0.7.35", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-0.7.35.tgz", + "integrity": "sha512-BK+pa9a9dYC1qJyYQulqkRI9N+ZnV4ycAmNSOUmom7C6xaAdmrhOoiCiDMhSQklyjPpasy3KWRTkTRTJuDbBSw==", + "requires": { + "@types/geojson": "*" + } + }, + "@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "requires": { + "fill-range": "^7.1.1" + } + }, + "call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "requires": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + } + }, + "call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + } + }, + "call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "requires": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "requires": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + } + }, + "dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "requires": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + } + }, + "es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true + }, + "es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true + }, + "es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "requires": { + "es-errors": "^1.3.0" + } + }, + "fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "requires": { + "micromatch": "^4.0.2" + } + }, + "fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + } + }, + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true + }, + "get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "requires": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + } + }, + "get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "requires": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + } + }, + "gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true + }, + "graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "requires": { + "es-define-property": "^1.0.0" + } + }, + "has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true + }, + "hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "requires": { + "function-bind": "^1.1.2" + } + }, + "heatmap.js": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/heatmap.js/-/heatmap.js-2.0.5.tgz", + "integrity": "sha1-Rm07hlE/XUkRKknSVwCrJzAUkVM=" + }, + "html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==" + }, + "is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "requires": { + "is-docker": "^2.0.0" + } + }, + "isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "requires": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + } + }, + "jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^2.0.0" + } + }, + "jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true + }, + "klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.11" + } + }, + "math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true + }, + "micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "requires": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + } + }, + "minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + }, + "open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "requires": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + } + }, + "patch-package": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", + "dev": true, + "requires": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^10.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.2.4", + "yaml": "^2.2.2" + } + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true + }, + "semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true + }, + "set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "requires": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + } + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true + } + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/package.json b/files/plugin-HeatmapSessionRecording-5.6.0/package.json new file mode 100644 index 0000000..b561ef0 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/package.json @@ -0,0 +1,32 @@ +{ + "name": "heatmapsessionrecording", + "version": "1.0.0", + "description": "## Description", + "main": "tracker.js", + "directories": { + "doc": "docs", + "test": "tests" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "postinstall": "patch-package" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/innocraft/plugin-HeatmapSessionRecording.git" + }, + "author": "", + "license": "InnoCraft EULA", + "bugs": { + "url": "https://github.com/innocraft/plugin-HeatmapSessionRecording/issues" + }, + "homepage": "https://github.com/innocraft/plugin-HeatmapSessionRecording#readme", + "dependencies": { + "@types/heatmap.js": "^2.0.37", + "heatmap.js": "^2.0.5", + "html-to-image": "^1.11.13" + }, + "devDependencies": { + "patch-package": "^8.0.1" + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/patches/html-to-image+1.11.13.patch b/files/plugin-HeatmapSessionRecording-5.6.0/patches/html-to-image+1.11.13.patch new file mode 100644 index 0000000..4baeaf4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/patches/html-to-image+1.11.13.patch @@ -0,0 +1,26 @@ +diff --git a/node_modules/html-to-image/es/clone-node.js b/node_modules/html-to-image/es/clone-node.js +index 114cbf2..023d585 100644 +--- a/node_modules/html-to-image/es/clone-node.js ++++ b/node_modules/html-to-image/es/clone-node.js +@@ -61,7 +61,7 @@ async function cloneChildren(nativeNode, clonedNode, options) { + } + else if (isInstanceOfElement(nativeNode, HTMLIFrameElement) && + ((_a = nativeNode.contentDocument) === null || _a === void 0 ? void 0 : _a.body)) { +- children = toArray(nativeNode.contentDocument.body.childNodes); ++ children = []; + } + else { + children = toArray(((_b = nativeNode.shadowRoot) !== null && _b !== void 0 ? _b : nativeNode).childNodes); +diff --git a/node_modules/html-to-image/lib/clone-node.js b/node_modules/html-to-image/lib/clone-node.js +index 214f4d3..0f0555b 100644 +--- a/node_modules/html-to-image/lib/clone-node.js ++++ b/node_modules/html-to-image/lib/clone-node.js +@@ -136,7 +136,7 @@ function cloneChildren(nativeNode, clonedNode, options) { + } + else if ((0, util_1.isInstanceOfElement)(nativeNode, HTMLIFrameElement) && + ((_a = nativeNode.contentDocument) === null || _a === void 0 ? void 0 : _a.body)) { +- children = (0, util_1.toArray)(nativeNode.contentDocument.body.childNodes); ++ children = []; + } + else { + children = (0, util_1.toArray)(((_b = nativeNode.shadowRoot) !== null && _b !== void 0 ? _b : nativeNode).childNodes); diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/phpcs.xml b/files/plugin-HeatmapSessionRecording-5.6.0/phpcs.xml new file mode 100644 index 0000000..269d510 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/phpcs.xml @@ -0,0 +1,37 @@ + + + + Matomo Coding Standard for HeatmapSessionRecording plugin + + + + . + + tests/javascript/* + */vendor/* + + + + + + + + tests/* + + + + + Updates/* + + + + + tests/* + + + + + tests/* + Tracker/Configs.php + + \ No newline at end of file diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/plugin.json b/files/plugin-HeatmapSessionRecording-5.6.0/plugin.json new file mode 100644 index 0000000..bee306e --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/plugin.json @@ -0,0 +1,41 @@ +{ + "name": "HeatmapSessionRecording", + "description": "Visualise behaviour with Heatmaps and replay recorded visitor sessions to identify friction, validate design decisions, and improve conversion paths.", + "version": "5.6.0", + "theme": false, + "require": { + "matomo": ">=5.0.0-rc1,<6.0.0-b1" + }, + "authors": [ + { + "name": "InnoCraft", + "email": "contact@innocraft.com", + "homepage": "https:\/\/www.innocraft.com" + } + ], + "price": { + "base": 220 + }, + "archive": { + "exclude": ["/tracker.js"] + }, + "preview": { + "video_url": "https://www.youtube-nocookie.com/embed/WyDygNx0t4w" + }, + "homepage": "https:\/\/www.heatmap-analytics.com", + "license": "InnoCraft EULA", + "keywords": [ + "heatmap", + "session recording", + "session", + "recording", + "move", + "scroll", + "hover", + "click", + "user", + "visitor", + "video", + "visit" + ] +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/pull_request_template.md b/files/plugin-HeatmapSessionRecording-5.6.0/pull_request_template.md new file mode 100644 index 0000000..211089b --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/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/files/plugin-HeatmapSessionRecording-5.6.0/stylesheets/edit-entities.less b/files/plugin-HeatmapSessionRecording-5.6.0/stylesheets/edit-entities.less new file mode 100644 index 0000000..63f02cb --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/stylesheets/edit-entities.less @@ -0,0 +1,101 @@ + +.editHsr { + .icon-help { + color: @theme-color-text-lighter; + cursor: help; + &:hover { + color: @theme-color-text; + } + } + .icon-minus, + .icon-plus { + cursor: pointer; + } + .icon-minus { + margin-left: 8px; + } + + .matchPageRules > .row { + width: ~"calc(100% - 60px)"; + margin: 0 !important; + + > .col { + padding-left: 0; + } + } + + .form-group { + margin-left: -.75rem; + margin-right: -.75rem; + + &.hsrTargetTest { + margin-left: 0; + margin-right: 0; + } + } + + .matchPageRules { + margin-top: 1em; + + hr { + margin-bottom: 3em; + } + + .form-group { + margin: 0; + } + .input-field { + margin-top: 0.2em; + + input, select{ + width: 100% !important; + } + } + } + + .targetingToggle { + // align with the field content, which Materialize insets by .75rem (.col padding) + margin: 0 0 1.25em; + padding-left: .75rem; + + a { + color: @color-green-matomo; + cursor: pointer; + } + } + + .advancedSettingsToggle { + padding: 1.75em 0 1.25em; + + h3 { + margin: 0 0 .9em; + font-weight: 600; + } + + p:last-child { + margin: 0; + } + + a { + color: @color-green-matomo; + } + } + + .heatmapAdvancedSection { + margin-bottom: 2.5em; + + h3 { + margin-bottom: .25em; + font-weight: 600; + } + + .sectionDescription { + color: @theme-color-text-lighter; + margin-bottom: 1.25em; + } + } + + .heatmapAdvancedSection:last-of-type { + margin-bottom: 1em; + } +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/stylesheets/list-entities.less b/files/plugin-HeatmapSessionRecording-5.6.0/stylesheets/list-entities.less new file mode 100644 index 0000000..0cf6f63 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/stylesheets/list-entities.less @@ -0,0 +1,51 @@ +.manageHsr { + .filterStatus, .hsrSearchFilter { + display: inline-block; + width:200px; + } + + div.filterStatus { + margin: 0 -0.75em; + display: inline-block; + + .theWidgetContent & { + margin: 0; + } + } + + th.action, td.action { + width: 250px; + + a { + color: black; + } + } + + th.action.duplicate-available, td.action.duplicate-available { + width: 280px; + } + + .index { + width: 60px; + } + + a.table-action { + margin-right: 3.5px; + } + + a.table-action:last-child { + margin-right: 0; + } + + .autoRepeat { + width: 130px; + + // override: the icon font class is the only hook the shared Morpheus icon markup exposes + .icon-info { + cursor: help; + margin-left: 3.5px; + position: relative; + top: 1px; + } + } +} \ No newline at end of file diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/stylesheets/recordings.less b/files/plugin-HeatmapSessionRecording-5.6.0/stylesheets/recordings.less new file mode 100644 index 0000000..a16bbc4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/stylesheets/recordings.less @@ -0,0 +1,154 @@ +#recordingPlayer { + display: block; + border: 1px solid #ccc; + pointer-events: none !important; + background: white; +} + +.scrollHeatmapLeaf { + position:absolute; + z-index: 10; + opacity: 0.4; +} +#listOfPageviews { + table .inactive { + cursor: pointer; + } +} +.sessionRecording { + padding: 16px; + font-size: 14px; + + .recordingPageviews { + cursor: pointer; + color: @theme-color-link; + } + + .recordingPageviews, + .recordingResolution, + .recordingLogos, + .recordingUrl { + margin-left: 16px; + } + + .recordingLogos { + img { + margin-right: 6px; + height: 14px; + } + .countryFlag { + border: 1px solid #d3d3d3; + height: 15px; + } + } + + .openVisitorProfile { + cursor:pointer; + height: 15px !important; + } +} + +.visitorLogReplaySession { + margin-top: 10px; + padding-bottom: 5px; + display: block; + width: auto!important; + &:hover { + color: @theme-color-brand !important; + } + + .visitor-profile & { + display: none; + } +} + +.visitorLogIconReplaySession { + display: block; + float: left; + font-size: 18px; + margin: 4px 10px 0 0; + + .dataTableVizVisitorLog &, .visitor-profile-header & { + display: none; + } + + &:hover { + text-decoration: none; + color: @theme-color-brand !important; + } +} + +[data-report="HeatmapSessionRecording.getRecordedSessions"] { + .dataTableRowActions { + + .actionHsrPlayRecording, .actionHsrVisitorProfile { + padding-right: 1rem; + } + + .icon-play { + color: @theme-color-brand !important; + display: inline-block; + margin-top: 1px; + } + + .icon-visitor-profile { + font-size: 21px !important; + } + } + + table.subDataTable tr .label.column { + width: 400px; + } + + .countryFlag { + border: 1px solid #d3d3d3; + } +} + +.hsrLoadingOuter { + position: absolute; + z-index: 2; + + .loadingUnderlay { + background: #000; + width:100%; + height:100%; + position: relative; + opacity: 0.6; + } + + .loadingInner { + margin: 0 auto; + font-size: 28px; + color: white; + text-align:center; + top: 50px; + position: absolute; + width: 100%; + } + + .loadingContent { + margin: 0 auto; + } +} + +.heatmapVis .btn-flat { + background-color: @theme-color-brand; + opacity: 0.6; + color: #fff; + border: 0; + box-shadow: 0 2px 3px 0 rgba(0,0,0,0.16), 0 0 3px 0 rgba(0,0,0,0.12); + + &:hover { + opacity: 1 !important; + } + + img { + filter: invert(1); + } +} + +.heatmapVis .visActive { + background-color: @theme-color-brand !important; + opacity: 1 !important; +} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/templates/_detectAdBlocker.twig b/files/plugin-HeatmapSessionRecording-5.6.0/templates/_detectAdBlocker.twig new file mode 100644 index 0000000..dfb74c4 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/templates/_detectAdBlocker.twig @@ -0,0 +1,26 @@ +{% block content %} +
    ') + } + var UI = require('piwik/UI'); + var notification = new UI.Notification(); + notification.show(_pk_translate('HeatmapSessionRecording_AdBlockerDetected',["{{ type }}"]),{context: 'warning',id:'HeatmapAdBlocker'}); + } + document.getElementById("ad-box").remove(); + }, 2000) + } + +{% endblock %} \ No newline at end of file diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/templates/_finishedEmail.twig b/files/plugin-HeatmapSessionRecording-5.6.0/templates/_finishedEmail.twig new file mode 100644 index 0000000..36a049f --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/templates/_finishedEmail.twig @@ -0,0 +1,4 @@ +

    {{ greeting }}

    +

    {{ bodyText|raw }}

    +

    {{ regards }}
    {{ team }}

    +

    {{ unsubscribeText|raw }}

    diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/templates/embedPage.twig b/files/plugin-HeatmapSessionRecording-5.6.0/templates/embedPage.twig new file mode 100644 index 0000000..49c91d3 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/templates/embedPage.twig @@ -0,0 +1,43 @@ +{% extends 'empty.twig' %} + +{% set title=('HeatmapSessionRecording_ReplayX'|translate('HeatmapSessionRecording_SessionRecording'|translate)) %} + +{% block content %} + + + + + + + + + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/templates/gettingStartedHeatmaps.twig b/files/plugin-HeatmapSessionRecording-5.6.0/templates/gettingStartedHeatmaps.twig new file mode 100644 index 0000000..1ec5079 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/templates/gettingStartedHeatmaps.twig @@ -0,0 +1,2 @@ +

    {{ 'HeatmapSessionRecording_NoHeatmapsConfiguredInfo'|translate }}

    +

    {{ 'HeatmapSessionRecording_HeatmapUsageBenefits'|translate }}

    \ No newline at end of file diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/templates/gettingStartedSessions.twig b/files/plugin-HeatmapSessionRecording-5.6.0/templates/gettingStartedSessions.twig new file mode 100644 index 0000000..234c4ee --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/templates/gettingStartedSessions.twig @@ -0,0 +1,2 @@ +

    {{ 'HeatmapSessionRecording_NoSessionRecordingsConfiguredInfo'|translate }}

    +

    {{ 'HeatmapSessionRecording_SessionRecordingsUsageBenefits'|translate }}

    \ No newline at end of file diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/templates/manageHeatmap.twig b/files/plugin-HeatmapSessionRecording-5.6.0/templates/manageHeatmap.twig new file mode 100644 index 0000000..ee18072 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/templates/manageHeatmap.twig @@ -0,0 +1,22 @@ +{% extends 'admin.twig' %} + +{% block topcontrols %} +
    +
    +
    +{% endblock %} + +{% block content %} +
    +{% endblock %} \ No newline at end of file diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/templates/manageSessions.twig b/files/plugin-HeatmapSessionRecording-5.6.0/templates/manageSessions.twig new file mode 100644 index 0000000..4fe301c --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/templates/manageSessions.twig @@ -0,0 +1,20 @@ +{% extends 'admin.twig' %} + +{% block topcontrols %} +
    +
    +
    +{% endblock %} + +{% block content %} +
    +{% endblock %} \ No newline at end of file diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/templates/replayRecording.twig b/files/plugin-HeatmapSessionRecording-5.6.0/templates/replayRecording.twig new file mode 100644 index 0000000..c382d09 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/templates/replayRecording.twig @@ -0,0 +1,60 @@ +{% extends 'layout.twig' %} + +{% set title=('HeatmapSessionRecording_ReplayX'|translate('HeatmapSessionRecording_SessionRecording'|translate)) %} + +{% block root %} + {% include '@HeatmapSessionRecording/_detectAdBlocker.twig' with {type: 'Session recordings'} %} + +
    +
    + {{ recording.server_time_pretty }} + + {% if recording.url %}{{ recording.url|truncate(50) }}{% endif %} + + {{ recording.viewport_w_px }}x{{ recording.viewport_h_px }} + {% if recording.numPageviews == 1 %}{{ 'HeatmapSessionRecording_OnePageview'|translate }}{% else %}{{ 'HeatmapSessionRecording_PageviewXofY'|translate(currentPage, recording.numPageviews) }} {% endif %} + + + {% if recording.location_logo %}{% endif %} + {% if recording.device_logo %}{% endif %} + {% if recording.os_logo %}{% endif %} + {% if recording.browser_logo %}{% endif %} + {% if recording.idvisitor and visitorProfileEnabled %}{% endif %} + +
    + + + +
    +
    + +
    +
    +
    + +{% endblock %} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/templates/showHeatmap.twig b/files/plugin-HeatmapSessionRecording-5.6.0/templates/showHeatmap.twig new file mode 100644 index 0000000..fe83494 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/templates/showHeatmap.twig @@ -0,0 +1,22 @@ +{% include '@HeatmapSessionRecording/_detectAdBlocker.twig' with {type: 'Heatmaps'} %} +
    +
    diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/templates/unsubscribe.twig b/files/plugin-HeatmapSessionRecording-5.6.0/templates/unsubscribe.twig new file mode 100644 index 0000000..04b8ab0 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/templates/unsubscribe.twig @@ -0,0 +1,72 @@ +{% extends '@Morpheus/layout.twig' %} + +{% block meta %} + + +{% endblock %} + +{% set title %}{{ 'HeatmapSessionRecording_UnsubscribeTitle'|translate }}{% endset %} + +{% set bodyId = 'loginPage' %} +{% set bodyClass = 'heatmapSessionRecordingUnsubscribePage' %} + +{% block body %} + + {{ postEvent("Template.beforeTopBar", "login") }} + {{ postEvent("Template.beforeContent", "login") }} + + {% include "_iframeBuster.twig" %} + +
    +
    + + +
    +
    + +
    + {% embed 'contentBlock.twig' with {'title': 'HeatmapSessionRecording_UnsubscribeTitle'|translate} %} + {% block content %} + {% if error is defined %} +
    + {{ 'General_Error'|translate }}: {{ error|raw }}
    +
    + {% elseif success is defined %} +
    + {{ 'HeatmapSessionRecording_UnsubscribeSuccess'|translate }} +
    + {% else %} +
    +
    +
    +
    +

    {{ 'HeatmapSessionRecording_UnsubscribeConfirmation'|translate }}

    +

    {{ 'HeatmapSessionRecording_UnsubscribeConfirmationDetail'|translate }}

    +


    + + +
    +
    + +
    + {% endif %} + {% if isCustomLogo %} +

    + {{ linkTitle }} +

    + {% endif %} + {% endblock %} + {% endembed %} +
    +
    +
    + +{% endblock %} diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/tracker.min.js b/files/plugin-HeatmapSessionRecording-5.6.0/tracker.min.js new file mode 100644 index 0000000..c2c4440 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/tracker.min.js @@ -0,0 +1,125 @@ +(function(){var N=1;var aH=9;var o=10;var P=8;var w=3;var ax=["button","submit","reset"]; +/*!! + * Copyright (C) 2015 Pavel Savshenko + * Copyright (C) 2011 Google Inc. All rights reserved. + * Copyright (C) 2007, 2008 Apple Inc. All rights reserved. + * Copyright (C) 2008 Matt Lilek + * Copyright (C) 2009 Joseph Pecoraro + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of Apple Computer, Inc. ("Apple") nor the names of + * its contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF + * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +; +var i={};i.cssPath=function(aT,aR){if(aT.nodeType!==N){return""}var aQ=[];var aP=aT;while(aP){var aS=i._cssPathStep(aP,!!aR,aP===aT);if(!aS){break}aQ.push(aS);if(aS.optimized){break}aP=aP.parentNode}aQ.reverse();return aQ.join(" > ")};i._cssPathStep=function(a4,aW,a3){if(a4.nodeType!==N){return null}var a2=a4.getAttribute("id");if(aW){if(a2){return new i.DOMNodePathStep(ba(a2),true)}var aQ=a4.nodeName.toLowerCase();if(aQ==="body"||aQ==="head"||aQ==="html"){return new i.DOMNodePathStep(a4.nodeName.toLowerCase(),true)}}var aP=a4.nodeName.toLowerCase();if(a2){return new i.DOMNodePathStep(aP.toLowerCase()+ba(a2),true)}var aX=a4.parentNode;if(!aX||aX.nodeType===aH){return new i.DOMNodePathStep(aP.toLowerCase(),true)}function bg(bi){var bj=bi.getAttribute("class");if(!bj){return[]}return bj.split(/\s+/g).filter(Boolean).map(function(bk){return"$"+bk})}function ba(bi){return"#"+bf(bi)}function bf(bj){if(bb(bj)){return bj}var bi=/^(?:[0-9]|-[0-9-]?)/.test(bj);var bk=bj.length-1;return bj.replace(/./g,function(bm,bl){return((bi&&bl===0)||!aR(bm))?aT(bm,bl===bk):bm +})}function aT(bj,bi){return"\\"+a1(bj)+(bi?"":" ")}function a1(bj){var bi=bj.charCodeAt(0).toString(16);if(bi.length===1){bi="0"+bi}return bi}function aR(bi){if(/[a-zA-Z0-9_\-]/.test(bi)){return true}return bi.charCodeAt(0)>=160}function bb(bi){return/^-?[a-zA-Z_][a-zA-Z0-9_\-]*$/.test(bi)}function a6(bi){var bk={},bj;for(bj=0;bj>>0};aP.prototype.nodeId=function(aQ){var aR=aQ[aP.ID_PROP];if(!aR){aR=aQ[aP.ID_PROP]=aP.nextId_++}return aR};aP.prototype.set=function(aQ,aR){var aS=this.nodeId(aQ);this.nodes[aS]=aQ;this.values[aS]=aR};aP.prototype.get=function(aQ){var aR=this.nodeId(aQ);return this.values[aR]};aP.prototype.has=function(aQ){return this.nodeId(aQ) in this.nodes};aP.prototype["delete"]=function(aQ){var aR=this.nodeId(aQ);delete this.nodes[aR];this.values[aR]=undefined};aP.prototype.keys=function(){var aQ=[];for(var aR in this.nodes){if(!this.isIndex(aR)){continue}aQ.push(this.nodes[aR])}return aQ};aP.ID_PROP="__mutation_summary_node_map_id__";aP.nextId_=1;return aP})();var aC; +(function(aP){aP[aP.STAYED_OUT=0]="STAYED_OUT";aP[aP.ENTERED=1]="ENTERED";aP[aP.STAYED_IN=2]="STAYED_IN";aP[aP.REPARENTED=3]="REPARENTED";aP[aP.REORDERED=4]="REORDERED";aP[aP.EXITED=5]="EXITED"})(aC||(aC={}));function B(aP){return aP===aC.ENTERED||aP===aC.EXITED}var ab=(function(){function aP(aW,aV,aR,aT,aS,aU,aQ,aX){if(aV===void 0){aV=false}if(aR===void 0){aR=false}if(aT===void 0){aT=false}if(aS===void 0){aS=null}if(aU===void 0){aU=false}if(aQ===void 0){aQ=null}if(aX===void 0){aX=null}this.node=aW;this.childList=aV;this.attributes=aR;this.characterData=aT;this.oldParentNode=aS;this.added=aU;this.attributeOldValues=aQ;this.characterDataOldValue=aX;this.isCaseInsensitive=this.node.nodeType===N&&this.node instanceof HTMLElement&&this.node.ownerDocument instanceof HTMLDocument}aP.prototype.getAttributeOldValue=function(aQ){if(!this.attributeOldValues){return undefined}if(this.isCaseInsensitive){aQ=aQ.toLowerCase()}return this.attributeOldValues[aQ]};aP.prototype.getAttributeNamesMutated=function(){var aR=[]; +if(!this.attributeOldValues){return aR}for(var aQ in this.attributeOldValues){aR.push(aQ)}return aR};aP.prototype.attributeMutated=function(aR,aQ){this.attributes=true;this.attributeOldValues=this.attributeOldValues||{};if(aR in this.attributeOldValues){return}this.attributeOldValues[aR]=aQ};aP.prototype.characterDataMutated=function(aQ){if(this.characterData){return}this.characterData=true;this.characterDataOldValue=aQ};aP.prototype.removedFromParent=function(aQ){this.childList=true;if(this.added||this.oldParentNode){this.added=false}else{this.oldParentNode=aQ}};aP.prototype.insertedIntoParent=function(){this.childList=true;this.added=true};aP.prototype.getOldParent=function(){if(this.childList){if(this.oldParentNode){return this.oldParentNode}if(this.added){return null}}return this.node.parentNode};return aP})();var ao=(function(){function aP(){this.added=new J();this.removed=new J();this.maybeMoved=new J();this.oldPrevious=new J();this.moved=undefined}return aP})();var W=(function(aQ){b(aP,aQ); +function aP(aU,aS){aQ.call(this);this.rootNode=aU;this.reachableCache=undefined;this.wasReachableCache=undefined;this.anyParentsChanged=false;this.anyAttributesChanged=false;this.anyCharacterDataChanged=false;for(var aR=0;aR1){throw Error("Invalid request option. all has no options.")}aU.queries.push({all:true});continue}if("attribute" in aT){var aV={attribute:j(aT.attribute)};aV.elementFilter=aG.parseSelectors("*["+aV.attribute+"]");if(Object.keys(aT).length>1){throw Error("Invalid request option. attribute has no options.")}aU.queries.push(aV);continue}if("element" in aT){var aS=Object.keys(aT).length;var aV={element:aT.element,elementFilter:aG.parseSelectors(aT.element)};if(aT.hasOwnProperty("elementAttributes")){aV.attributeList=az(aT.elementAttributes);aS--}if(aS>1){throw Error("Invalid request option. element only allows elementAttributes option.")}aU.queries.push(aV);continue}if(aT.characterData){if(Object.keys(aT).length>1){throw Error("Invalid request option. characterData has no options.") +}aU.queries.push({characterData:true});continue}throw Error("Invalid request option. Unknown query request.")}return aU};aP.prototype.createSummaries=function(aR){if(!aR||!aR.length){return[]}var aQ=new K(this.root,aR,this.elementFilter,this.calcReordered,this.options.oldPreviousSibling);var aT=[];for(var aS=0;aS=0){aV={}}else{if(aU.attributes.name){var a3=String(aU.attributes.name.value).toLowerCase();if(a3.indexOf("twitter:")>=0||a3.indexOf("description")>=0||a3.indexOf("keywords")>=0){aV={}}}}}else{if("LINK"===aU.tagName){if(aU.attributes.rel){var a2=String(aU.attributes.rel.value).toLowerCase(); +var a1=["icon","preload","preconnect","dns-prefetch","next","prev","alternate","search"];if(a1.indexOf(a2)>=0){aV={}}}if(aU.attributes.href){var aT=String(aU.attributes.href.value).toLowerCase().indexOf(".scr.kaspersky-labs.com");if(aT>5&&aT<=20){aV={}}}if(aU.href){if(typeof I.URL==="function"){var aS=ak.onCssLoaded(aU.href);var aQ=3;if(!aS){aR(aU.href);function aR(a5){if(aQ>0){setTimeout(function(){aQ--;aS=ak.onCssLoaded(aU.href);if(!aS){aR(aU.href)}},300)}}}}aV.url=aU.href}}}}return aV};aP.prototype.serializeAddedAndMoved=function(aT,aQ,aU){var aW=this;var aS=aT.concat(aQ).concat(aU);var aV=new d.NodeMap();aS.forEach(function(aZ){var aY=aZ.parentNode;var aX=aV.get(aY);if(!aX){aX=new d.NodeMap();aV.set(aY,aX)}aX.set(aZ,true)});var aR=[];aV.keys().forEach(function(aY){var aX=aV.get(aY);var a0=aX.keys();while(a0.length){var aZ=a0[0];while(aZ.previousSibling&&aX.has(aZ.previousSibling)){aZ=aZ.previousSibling}while(aZ&&aX.has(aZ)){var a1=aW.serializeNode(aZ);a1.previousSibling=aW.serializeNode(aZ.previousSibling); +a1.parentNode=aW.serializeNode(aZ.parentNode);aR.push(a1);aX["delete"](aZ);aZ=aZ.nextSibling}var a0=aX.keys()}});return aR};aP.prototype.serializeAttributeChanges=function(aQ){var aS=this;var aR=new d.NodeMap();Object.keys(aQ).forEach(function(aT){aQ[aT].forEach(function(aW){var aU=aR.get(aW);if(!aU){aU=aS.serializeNode(aW);aU.attributes={};aR.set(aW,aU)}var aV=aN.shouldMaskElementRecursive(aW);var aX=aS.getAttributesFromNode(aW,aV.isIgnoredField,aV.isIgnoredContent);aU.attributes[aT]=aT in aX?aX[aT]:null})});return aR.keys().map(function(aT){return aR.get(aT)})};aP.prototype.applyChanged=function(aT){var aW=this;var aR=aT[0];var aU=aR.removed.map(function(aX){return aW.serializeNode(aX)});var aS=this.serializeAddedAndMoved(aR.added,aR.reparented,aR.reordered);var aQ=this.serializeAttributeChanges(aR.attributeChanged);var aV=aR.characterDataChanged.map(function(aY){var aZ=aW.serializeNode(aY);if(aY.nodeType===w&&aY.parentNode){aY=aY.parentNode}var aX=aN.shouldMaskElementRecursive(aY,false,false); +aZ.textContent=aN.getMaskedTextContent(aY,aX.isIgnoredField,aX.isIgnoredContent);return aZ});this.mirror.applyChanged(aU,aS,aQ,aV);aR.removed.forEach(function(aX){aW.forgetNode(aX)})};return aP})()} +/*!! + * Copyright (C) InnoCraft Ltd - All rights reserved. + * + * All information contained herein is, and remains the property of InnoCraft Ltd. + * + * @link https://www.innocraft.com/ + * @license For license details see https://www.innocraft.com/license + */ +;var O=document;var I=window;var F=0;var l=false;var L=!u();var am=true;var n=null;var at=false;var ad="";var T=false;var g=15*60*1000;var aa=30*60*1000;var S=10;var av=(5*60*1000);var aA=2000;var C=1000;var H=100;var Y=500;var G=false;function u(){if("object"!==typeof JSON){return true}if("function"!==typeof Array.prototype.map||"function"!==typeof Array.prototype.filter||"function"!==typeof Array.prototype.indexOf){return true}if("function"!==typeof Element.prototype.getBoundingClientRect){return true}var aP=["cc.bingj.com"]; +if(aP.indexOf(O.domain)!==-1||String(O.domain).indexOf(".googleusercontent.com")!==-1){return true}var aR=/alexa|baidu|bing|bot|crawler|curl|crawling|duckduckgo|facebookexternalhit|feedburner|googlebot|google web preview|linkdex|nagios|postrank|pingdom|robot|slurp|spider|yahoo!|yandex|wget/i.test(navigator.userAgent);if(aR){return true}var aQ=String(O.referrer);if(aQ&&aQ.indexOf("module=Overlay&action=startOverlaySession")>=0){return true}return false}function U(){if(l&&"object"===typeof console){if(typeof console.debug==="function"){console.debug.apply(console,arguments)}else{if(typeof console.log==="function"){console.log.apply(console,arguments)}}}}var D=function(){return true};var s=1;var aJ=2;var h=3;var V=4;var aK=5;var ag=6;var a=7;var k=8;var e=9;var ar=10;var p=11;var ay=12;var aF=13;var aD=0;var af=1;var c=2;var aI=true;var Q=false;var an=false;var aO=true;var M=null;var A=false;var ae={};if("object"===typeof JSON){ae=JSON}var aj=false;var al=[];var aL={hasObserver:function(){if(typeof WebKitMutationObserver!=="undefined"){return true +}else{if(typeof MutationObserver!=="undefined"){return true}}return false}};var ai=aL.hasObserver();var r={getScrollLeft:function(){return I.document.body.scrollLeft||I.document.documentElement.scrollLeft},getScrollTop:function(){return I.document.body.scrollTop||I.document.documentElement.scrollTop},getDocumentHeight:function(){return au.safeMathMax([O.body.offsetHeight,O.body.scrollHeight,O.documentElement.offsetHeight,O.documentElement.clientHeight,O.documentElement.scrollHeight,1])},getDocumentWidth:function(){return au.safeMathMax([O.body.offsetWidth,O.body.scrollWidth,O.documentElement.offsetWidth,O.documentElement.clientWidth,O.documentElement.scrollWidth,1])},getWindowSize:function(){var aP=I.innerHeight||O.documentElement.clientHeight||O.body.clientHeight;var aQ=I.innerWidth||O.documentElement.clientWidth||O.body.clientWidth;return{width:aQ,height:aP}}};var t={namespace:"hsr",set:function(aR,aV,aT){aV=parseInt(aV,10);aT=parseInt(aT,10);var aU="";var aQ=t.getHsrConfigs(aR);var aS=false; +for(var aP=0;aP2){return true}}}if(aY){var aX=aP.parentNode?aP.parentNode:null;var aQ=false;while(aX){if(aN.hasAttribute(aX,"data-piwik-mask")||aN.hasAttribute(aX,"data-matomo-mask")){return true}else{if(!aQ&&aX&&aN.hasAttribute(aX,"data-matomo-unmask")){aQ=true}aX=aX.parentNode?aX.parentNode:null}}if(aQ){return false}}if(aN.hasAttribute(aP,"data-matomo-unmask")){return false}if(aT){return false}return true},shouldMaskContent:function(aR,aQ){if(!aR){return false}if(aR.tagName&&aR.tagName!=="FORM"&&aN.hasAttribute(aR,"data-matomo-mask")){return true}if(aR.tagName&&aR.tagName!=="FORM"&&aN.hasAttribute(aR,"data-matomo-unmask")){return false +}if(aQ){var aP=aR.parentNode?aR.parentNode:null;while(aP){if(aR.nodeName==="#text"&&aN.hasAttribute(aP,"data-matomo-unmask")){return false}else{if(aP.tagName!=="FORM"&&aN.hasAttribute(aP,"data-matomo-mask")){return true}else{aP=aP.parentNode?aP.parentNode:null}}}}return false},isAllowedInputType:function(aP){return(aP.type&&ax.indexOf(aP.type)!==-1&&!aN.hasAttribute(aP,"data-piwik-mask")&&!aN.hasAttribute(aP,"data-matomo-mask"))}};var au={safeMathMax:function(aP){var aQ=[];var aR;for(aR=0;aR0}function x(){return f("pk_hsr_forcesample=1")||f("pk_hsr_capturescreen=1")}function v(){return f("pk_hsr_forcesample=0")}function Z(aP){if(x()){return true}if(v()){return false}if(aP>=100){return true}if(aP<=0){return false}if(aP>=1){return aP>=au.getRandomInt(1,H)}return(aP*10)>=au.getRandomInt(1,H*10)}function q(aP){if("undefined"!==typeof aP.HeatmapSessionRecording){return}aP.HeatmapSessionRecording={myId:au.generateUniqueId(),hasReceivedConfig:false,hasRequestedConfig:false,hasTrackedData:false,hasSentStopTrackingEvent:false,enabled:true,hsrIdsToGetDOM:[],disable:function(){this.enabled=false},enable:function(){this.enabled=true +},isEnabled:function(){return L&&this.enabled},numSentTrackingRequests:0,Heatmap:{data:[],hsrids:[],configs:[],addConfig:function(aQ){if("object"!==typeof aQ||!aQ.id){return}aQ.id=parseInt(aQ.id,10);this.configs.push(aQ);if("undefined"===typeof aQ.sample_rate){aQ.sample_rate=H}else{aQ.sample_rate=Math.min(parseFloat(aQ.sample_rate),H)}if(aQ.id&&Z(aQ.sample_rate)&&D(aQ)){this.addHsrId(aQ.id);if((aQ.getdom&&!aQ.capture_manually)||f("pk_hsr_capturescreen=1")){aP.HeatmapSessionRecording.hsrIdsToGetDOM.push(aQ.id)}}},addHsrId:function(aQ){this.hsrids.push(aQ);if(aP.HeatmapSessionRecording.hasTrackedData){z.recordData(af,{ty:a,id:aQ})}}},Both:{data:[]},Session:{data:[],hsrids:[],configs:[],addConfig:function(aS){if("object"!==typeof aS||!aS.id){return}aS.id=parseInt(aS.id,10);if("undefined"===typeof aS.sample_rate){aS.sample_rate=H}else{aS.sample_rate=Math.min(parseFloat(aS.sample_rate),H)}aS.conditionsMet=false;this.configs.push(aS);var aR=parseInt(aP.getSiteId(),10);var aT=t.get(aP,aS.id);if(1===aT&&!v()){aS.sample_rate=H; +aS.activity=false;aS.min_time=0}else{if(x()){}else{if(0===aT||!Z(aS.sample_rate)){t.set(aP,aS.id,0);return}}}this.checkConditionsMet();if(aS.min_time){var aQ=this;Piwik.DOM.onReady(function(){var aU=(aS.min_time*1000)-au.getTimeSincePageReady()+120;if(aU>=0){setTimeout(function(){aQ.checkConditionsMet()},aU)}else{aQ.checkConditionsMet()}})}},checkConditionsMet:function(){var aR;for(var aS=0;aS=au.roundTimeToSeconds(au.getTimeSincePageReady())){aQ=false}if(aR.activity&&!an){an=r.getDocumentHeight()<=r.getWindowSize().height}if(aR.activity&&(!Q||!an)){aQ=false}if(aQ){aR.conditionsMet=true;if(D(aR)){if("undefined"===typeof aR.keystrokes||!aR.keystrokes||aR.keystrokes==="0"){aI=false}this.addHsrId(aR.id)}}}}},addHsrId:function(aQ){this.hsrids.push(aQ);if(aP.HeatmapSessionRecording.hasTrackedData){z.recordData(c,{ty:a,id:aQ})}var aR=parseInt(aP.getSiteId(),10);t.set(aP,aQ,1)}},addConfig:function(aQ){this.hasRequestedConfig=true; +this.hasReceivedConfig=true;if("undefined"===typeof aQ||!aQ){aM.checkAllConfigsReceived();return}if("object"===typeof aQ.heatmap){this.Heatmap.addConfig(aQ.heatmap)}var aR;if(aQ.heatmaps&&au.isArray(aQ.heatmaps)&&aQ.heatmaps.length){for(aR=0;aR=0;aR--){if(aQ[aR]&&aQ[aR].ty&&aQ[aR].ty===e){aQ.splice(aR,1)}}}}}if(aP.length&&aT.Both.data.length){aQ=aQ.concat(aT.Both.data);aT.Both.data=[]}if("undefined"===typeof aX){aX=this.shouldEndRecording(aW)}if(aX&&aT.hasTrackedData&&!aT.hasSentStopTrackingEvent&&aV){aQ.push({ty:p});aT.hasSentStopTrackingEvent=true}if(!aP||!aP.length||!aQ||!aQ.length){return}if(aW.HeatmapSessionRecording.hsrIdsToGetDOM&&aW.HeatmapSessionRecording.hsrIdsToGetDOM.length){if(!ak.initialDOM&&ai){var aU=new y(O,{initialize:function(aY,aZ){ak.initialDOM=ae.stringify({rootId:aY,children:aZ})}});aU.disconnect()}if(ak.initialDOM&&ai){for(var aS=0;aSM)&&aQ.ty&&aQ.ty!==ag){M=aQ.ti}if(aD===aP){aS.HeatmapSessionRecording.Both.data.push(aQ)}else{if(af===aP){aS.HeatmapSessionRecording.Heatmap.data.push(aQ)}else{if(c===aP){aS.HeatmapSessionRecording.Session.data.push(aQ)}}}}});if(l){U("recorddata",ae.stringify(aQ))}},stopSendingData:function(){var aP=z.getPiwikTrackers();aP.forEach(function(aQ){if(aQ.HeatmapSessionRecording){var aR=aQ.HeatmapSessionRecording; +if("undefined"!==typeof aR.trackingInterval){clearInterval(aR.trackingInterval);delete aR.trackingInterval}}})},startSendingData:function(){var aP=z.getPiwikTrackers();aP.forEach(function(aQ){if(aQ.HeatmapSessionRecording&&"undefined"===typeof aQ.HeatmapSessionRecording.trackingInterval){var aR=au.getRandomInt(10250,11250);aQ.HeatmapSessionRecording.trackingInterval=setInterval(function(){z.sendQueuedData(aQ)},aR);z.sendQueuedData(aQ)}})}};function aE(){ +/*!!! hsrTrackerReadyHook */ +;if(typeof window==="object"&&"function"===typeof I.piwikHeatmapSessionRecordingAsyncInit){I.piwikHeatmapSessionRecordingAsyncInit()}if(typeof window==="object"&&"function"===typeof I.matomoHeatmapSessionRecordingAsyncInit){I.matomoHeatmapSessionRecordingAsyncInit()}var aQ=al;al=[];aj=true;for(var aP=0;aPY){aY=aY.substr(0,Y)}if(aN.shouldMaskField(aS,!aN.hasAttribute(aS,"data-matomo-unmask"))){aY=aN.maskFormField(aY,aN.getAttribute(aS,"type")==="password")}}else{if(aP===ar&&"undefined"!==typeof aS.value){aY=String(aS.value)}}}var aV={ti:aR,ty:aP,s:aU,te:aY};if(aU){z.recordData(c,aV)}else{U("No selector found for text input ",aX) +}},onScroll:function(aP){if(!an){an=true;ak.checkTrackersIfConditionsMet()}var aT=au.getTimeSincePageReady();if(aP&&aP.type&&aP.type==="scroll"&&aP.target&&aP.target!==O){var aZ=aP.target;if("undefined"===typeof aZ.scrollTop){return}var aR=aZ.scrollTop;var aU=aZ.scrollLeft;var aS=aN.getWidth(aZ);var aQ=aN.getHeight(aZ);if(aS<=0||aQ<=0||!aS||!aQ){return}var aV=aN.getSelector(aZ);ak.lastElementScroll={time:aT,selector:aV,scrollY:parseInt((C*aR)/aQ,10),scrollX:parseInt((C*aU)/aS,10)};return}var aX=parseInt(r.getScrollTop(),10);var aW=parseInt(r.getScrollLeft(),10);var a1=r.getDocumentHeight();var aY=r.getDocumentWidth();ak.lastScroll={time:aT,scrollY:parseInt((C*aX)/a1,10),scrollX:parseInt((C*aW)/aY,10)};var a0=parseInt((C*(aX+r.getWindowSize().height))/a1,10);if(a0>ak.scrollMaxPercentage){ak.scrollMaxPercentage=a0}},checkTrackersIfConditionsMet:function(){var aQ=z.getPiwikTrackers();for(var aP=0;aPaa){g=aa}},setMaxTextInputLength:function(aQ){Y=aQ},disableCaptureKeystrokes:function(){aI=false},enableCaptureKeystrokes:function(){aI=true},setMatomoTrackers:function(aQ){this.setPiwikTrackers(aQ)},setPiwikTrackers:function(aQ){if(aQ===null){n=null;return}if(!au.isArray(aQ)){aQ=[aQ] +}n=aQ;n.forEach(q);if(aj){if(A){this.enable()}else{if(L){aM.fetch()}}}},enableDebugMode:function(){l=true}};Piwik.DOM.onReady(function(){F=new Date().getTime()});Piwik.addPlugin("HeatmapSessionRecording",{log:function(aQ){if(aO){if(aQ.tracker&&aQ.tracker.getNumTrackedPageViews&&aQ.tracker.getNumTrackedPageViews()>1){setTimeout(function(){Piwik.HeatmapSessionRecording.setNewPageView(true)},10)}}return""},unload:function(){if(!u()){var aQ=z.getPiwikTrackers();z.stopSendingData();aQ.forEach(function(aS){var aR=false;z.sendQueuedData(aS,aR)})}}});if(I.Piwik.initialized){var aP=Piwik.getAsyncTrackers();aP.forEach(q);Piwik.on("TrackerSetup",q);Piwik.retryMissedPluginCalls();aE();aM.fetch();Piwik.on("TrackerAdded",function(){if(A){Piwik.HeatmapSessionRecording.enable()}else{aM.fetch()}})}else{Piwik.on("TrackerSetup",q);Piwik.on("MatomoInitialized",function(){aE();if(L||A){aM.fetch()}Piwik.on("TrackerAdded",function(){if(L){aM.fetch()}else{if(A){Piwik.HeatmapSessionRecording.enable()}}})})}}ad=au.generateUniqueId(); +if("object"===typeof I.Piwik){ah()}else{if("object"!==typeof I.matomoPluginAsyncInit){I.matomoPluginAsyncInit=[]}I.matomoPluginAsyncInit.push(ah)}})(); \ No newline at end of file diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/tsconfig.json b/files/plugin-HeatmapSessionRecording-5.6.0/tsconfig.json new file mode 100644 index 0000000..a541624 --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "typeRoots": [ + "../../node_modules/@types", + "../../plugins/CoreVue/types/index.d.ts", + "./node_modules/@types" + ], + "types": [ + "jquery", + "heatmap.js" + ] + } +} \ No newline at end of file diff --git a/files/plugin-HeatmapSessionRecording-5.6.0/vue/dist/HeatmapSessionRecording.umd.js b/files/plugin-HeatmapSessionRecording-5.6.0/vue/dist/HeatmapSessionRecording.umd.js new file mode 100644 index 0000000..283f36e --- /dev/null +++ b/files/plugin-HeatmapSessionRecording-5.6.0/vue/dist/HeatmapSessionRecording.umd.js @@ -0,0 +1,8294 @@ +(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["HeatmapSessionRecording"] = factory(require("CoreHome"), require("vue"), require("CorePluginsAdmin")); + else + root["HeatmapSessionRecording"] = 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/HeatmapSessionRecording/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__; + +/***/ }), + +/***/ "246e": +/***/ (function(module, exports, __webpack_require__) { + +var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_RESULT__;/* + * heatmap.js v2.0.5 | JavaScript Heatmap Library + * + * Copyright 2008-2016 Patrick Wied - All rights reserved. + * Dual licensed under MIT and Beerware license + * + * :: 2016-09-05 01:16 + */ +;(function (name, context, factory) { + + // Supports UMD. AMD, CommonJS/Node.js and browser context + if ( true && module.exports) { + module.exports = factory(); + } else if (true) { + !(__WEBPACK_AMD_DEFINE_FACTORY__ = (factory), + __WEBPACK_AMD_DEFINE_RESULT__ = (typeof __WEBPACK_AMD_DEFINE_FACTORY__ === 'function' ? + (__WEBPACK_AMD_DEFINE_FACTORY__.call(exports, __webpack_require__, exports, module)) : + __WEBPACK_AMD_DEFINE_FACTORY__), + __WEBPACK_AMD_DEFINE_RESULT__ !== undefined && (module.exports = __WEBPACK_AMD_DEFINE_RESULT__)); + } else {} + +})("h337", this, function () { + +// Heatmap Config stores default values and will be merged with instance config +var HeatmapConfig = { + defaultRadius: 40, + defaultRenderer: 'canvas2d', + defaultGradient: { 0.25: "rgb(0,0,255)", 0.55: "rgb(0,255,0)", 0.85: "yellow", 1.0: "rgb(255,0,0)"}, + defaultMaxOpacity: 1, + defaultMinOpacity: 0, + defaultBlur: .85, + defaultXField: 'x', + defaultYField: 'y', + defaultValueField: 'value', + plugins: {} +}; +var Store = (function StoreClosure() { + + var Store = function Store(config) { + this._coordinator = {}; + this._data = []; + this._radi = []; + this._min = 10; + this._max = 1; + this._xField = config['xField'] || config.defaultXField; + this._yField = config['yField'] || config.defaultYField; + this._valueField = config['valueField'] || config.defaultValueField; + + if (config["radius"]) { + this._cfgRadius = config["radius"]; + } + }; + + var defaultRadius = HeatmapConfig.defaultRadius; + + Store.prototype = { + // when forceRender = false -> called from setData, omits renderall event + _organiseData: function(dataPoint, forceRender) { + var x = dataPoint[this._xField]; + var y = dataPoint[this._yField]; + var radi = this._radi; + var store = this._data; + var max = this._max; + var min = this._min; + var value = dataPoint[this._valueField] || 1; + var radius = dataPoint.radius || this._cfgRadius || defaultRadius; + + if (!store[x]) { + store[x] = []; + radi[x] = []; + } + + if (!store[x][y]) { + store[x][y] = value; + radi[x][y] = radius; + } else { + store[x][y] += value; + } + var storedVal = store[x][y]; + + if (storedVal > max) { + if (!forceRender) { + this._max = storedVal; + } else { + this.setDataMax(storedVal); + } + return false; + } else if (storedVal < min) { + if (!forceRender) { + this._min = storedVal; + } else { + this.setDataMin(storedVal); + } + return false; + } else { + return { + x: x, + y: y, + value: value, + radius: radius, + min: min, + max: max + }; + } + }, + _unOrganizeData: function() { + var unorganizedData = []; + var data = this._data; + var radi = this._radi; + + for (var x in data) { + for (var y in data[x]) { + + unorganizedData.push({ + x: x, + y: y, + radius: radi[x][y], + value: data[x][y] + }); + + } + } + return { + min: this._min, + max: this._max, + data: unorganizedData + }; + }, + _onExtremaChange: function() { + this._coordinator.emit('extremachange', { + min: this._min, + max: this._max + }); + }, + addData: function() { + if (arguments[0].length > 0) { + var dataArr = arguments[0]; + var dataLen = dataArr.length; + while (dataLen--) { + this.addData.call(this, dataArr[dataLen]); + } + } else { + // add to store + var organisedEntry = this._organiseData(arguments[0], true); + if (organisedEntry) { + // if it's the first datapoint initialize the extremas with it + if (this._data.length === 0) { + this._min = this._max = organisedEntry.value; + } + this._coordinator.emit('renderpartial', { + min: this._min, + max: this._max, + data: [organisedEntry] + }); + } + } + return this; + }, + setData: function(data) { + var dataPoints = data.data; + var pointsLen = dataPoints.length; + + + // reset data arrays + this._data = []; + this._radi = []; + + for(var i = 0; i < pointsLen; i++) { + this._organiseData(dataPoints[i], false); + } + this._max = data.max; + this._min = data.min || 0; + + this._onExtremaChange(); + this._coordinator.emit('renderall', this._getInternalData()); + return this; + }, + removeData: function() { + // TODO: implement + }, + setDataMax: function(max) { + this._max = max; + this._onExtremaChange(); + this._coordinator.emit('renderall', this._getInternalData()); + return this; + }, + setDataMin: function(min) { + this._min = min; + this._onExtremaChange(); + this._coordinator.emit('renderall', this._getInternalData()); + return this; + }, + setCoordinator: function(coordinator) { + this._coordinator = coordinator; + }, + _getInternalData: function() { + return { + max: this._max, + min: this._min, + data: this._data, + radi: this._radi + }; + }, + getData: function() { + return this._unOrganizeData(); + }/*, + + TODO: rethink. + + getValueAt: function(point) { + var value; + var radius = 100; + var x = point.x; + var y = point.y; + var data = this._data; + + if (data[x] && data[x][y]) { + return data[x][y]; + } else { + var values = []; + // radial search for datapoints based on default radius + for(var distance = 1; distance < radius; distance++) { + var neighbors = distance * 2 +1; + var startX = x - distance; + var startY = y - distance; + + for(var i = 0; i < neighbors; i++) { + for (var o = 0; o < neighbors; o++) { + if ((i == 0 || i == neighbors-1) || (o == 0 || o == neighbors-1)) { + if (data[startY+i] && data[startY+i][startX+o]) { + values.push(data[startY+i][startX+o]); + } + } else { + continue; + } + } + } + } + if (values.length > 0) { + return Math.max.apply(Math, values); + } + } + return false; + }*/ + }; + + + return Store; +})(); + +var Canvas2dRenderer = (function Canvas2dRendererClosure() { + + var _getColorPalette = function(config) { + var gradientConfig = config.gradient || config.defaultGradient; + var paletteCanvas = document.createElement('canvas'); + var paletteCtx = paletteCanvas.getContext('2d'); + + paletteCanvas.width = 256; + paletteCanvas.height = 1; + + var gradient = paletteCtx.createLinearGradient(0, 0, 256, 1); + for (var key in gradientConfig) { + gradient.addColorStop(key, gradientConfig[key]); + } + + paletteCtx.fillStyle = gradient; + paletteCtx.fillRect(0, 0, 256, 1); + + return paletteCtx.getImageData(0, 0, 256, 1).data; + }; + + var _getPointTemplate = function(radius, blurFactor) { + var tplCanvas = document.createElement('canvas'); + var tplCtx = tplCanvas.getContext('2d'); + var x = radius; + var y = radius; + tplCanvas.width = tplCanvas.height = radius*2; + + if (blurFactor == 1) { + tplCtx.beginPath(); + tplCtx.arc(x, y, radius, 0, 2 * Math.PI, false); + tplCtx.fillStyle = 'rgba(0,0,0,1)'; + tplCtx.fill(); + } else { + var gradient = tplCtx.createRadialGradient(x, y, radius*blurFactor, x, y, radius); + gradient.addColorStop(0, 'rgba(0,0,0,1)'); + gradient.addColorStop(1, 'rgba(0,0,0,0)'); + tplCtx.fillStyle = gradient; + tplCtx.fillRect(0, 0, 2*radius, 2*radius); + } + + + + return tplCanvas; + }; + + var _prepareData = function(data) { + var renderData = []; + var min = data.min; + var max = data.max; + var radi = data.radi; + var data = data.data; + + var xValues = Object.keys(data); + var xValuesLen = xValues.length; + + while(xValuesLen--) { + var xValue = xValues[xValuesLen]; + var yValues = Object.keys(data[xValue]); + var yValuesLen = yValues.length; + while(yValuesLen--) { + var yValue = yValues[yValuesLen]; + var value = data[xValue][yValue]; + var radius = radi[xValue][yValue]; + renderData.push({ + x: xValue, + y: yValue, + value: value, + radius: radius + }); + } + } + + return { + min: min, + max: max, + data: renderData + }; + }; + + + function Canvas2dRenderer(config) { + var container = config.container; + var shadowCanvas = this.shadowCanvas = document.createElement('canvas'); + var canvas = this.canvas = config.canvas || document.createElement('canvas'); + var renderBoundaries = this._renderBoundaries = [10000, 10000, 0, 0]; + + var computed = getComputedStyle(config.container) || {}; + + canvas.className = 'heatmap-canvas'; + + this._width = canvas.width = shadowCanvas.width = config.width || +(computed.width.replace(/px/,'')); + this._height = canvas.height = shadowCanvas.height = config.height || +(computed.height.replace(/px/,'')); + + this.shadowCtx = shadowCanvas.getContext('2d'); + this.ctx = canvas.getContext('2d'); + + // @TODO: + // conditional wrapper + + canvas.style.cssText = shadowCanvas.style.cssText = 'position:absolute;left:0;top:0;'; + + container.style.position = 'relative'; + container.appendChild(canvas); + + this._palette = _getColorPalette(config); + this._templates = {}; + + this._setStyles(config); + }; + + Canvas2dRenderer.prototype = { + renderPartial: function(data) { + if (data.data.length > 0) { + this._drawAlpha(data); + this._colorize(); + } + }, + renderAll: function(data) { + // reset render boundaries + this._clear(); + if (data.data.length > 0) { + this._drawAlpha(_prepareData(data)); + this._colorize(); + } + }, + _updateGradient: function(config) { + this._palette = _getColorPalette(config); + }, + updateConfig: function(config) { + if (config['gradient']) { + this._updateGradient(config); + } + this._setStyles(config); + }, + setDimensions: function(width, height) { + this._width = width; + this._height = height; + this.canvas.width = this.shadowCanvas.width = width; + this.canvas.height = this.shadowCanvas.height = height; + }, + _clear: function() { + this.shadowCtx.clearRect(0, 0, this._width, this._height); + this.ctx.clearRect(0, 0, this._width, this._height); + }, + _setStyles: function(config) { + this._blur = (config.blur == 0)?0:(config.blur || config.defaultBlur); + + if (config.backgroundColor) { + this.canvas.style.backgroundColor = config.backgroundColor; + } + + this._width = this.canvas.width = this.shadowCanvas.width = config.width || this._width; + this._height = this.canvas.height = this.shadowCanvas.height = config.height || this._height; + + + this._opacity = (config.opacity || 0) * 255; + this._maxOpacity = (config.maxOpacity || config.defaultMaxOpacity) * 255; + this._minOpacity = (config.minOpacity || config.defaultMinOpacity) * 255; + this._useGradientOpacity = !!config.useGradientOpacity; + }, + _drawAlpha: function(data) { + var min = this._min = data.min; + var max = this._max = data.max; + var data = data.data || []; + var dataLen = data.length; + // on a point basis? + var blur = 1 - this._blur; + + while(dataLen--) { + + var point = data[dataLen]; + + var x = point.x; + var y = point.y; + var radius = point.radius; + // if value is bigger than max + // use max as value + var value = Math.min(point.value, max); + var rectX = x - radius; + var rectY = y - radius; + var shadowCtx = this.shadowCtx; + + + + + var tpl; + if (!this._templates[radius]) { + this._templates[radius] = tpl = _getPointTemplate(radius, blur); + } else { + tpl = this._templates[radius]; + } + // value from minimum / value range + // => [0, 1] + var templateAlpha = (value-min)/(max-min); + // this fixes #176: small values are not visible because globalAlpha < .01 cannot be read from imageData + shadowCtx.globalAlpha = templateAlpha < .01 ? .01 : templateAlpha; + + shadowCtx.drawImage(tpl, rectX, rectY); + + // update renderBoundaries + if (rectX < this._renderBoundaries[0]) { + this._renderBoundaries[0] = rectX; + } + if (rectY < this._renderBoundaries[1]) { + this._renderBoundaries[1] = rectY; + } + if (rectX + 2*radius > this._renderBoundaries[2]) { + this._renderBoundaries[2] = rectX + 2*radius; + } + if (rectY + 2*radius > this._renderBoundaries[3]) { + this._renderBoundaries[3] = rectY + 2*radius; + } + + } + }, + _colorize: function() { + var x = this._renderBoundaries[0]; + var y = this._renderBoundaries[1]; + var width = this._renderBoundaries[2] - x; + var height = this._renderBoundaries[3] - y; + var maxWidth = this._width; + var maxHeight = this._height; + var opacity = this._opacity; + var maxOpacity = this._maxOpacity; + var minOpacity = this._minOpacity; + var useGradientOpacity = this._useGradientOpacity; + + if (x < 0) { + x = 0; + } + if (y < 0) { + y = 0; + } + if (x + width > maxWidth) { + width = maxWidth - x; + } + if (y + height > maxHeight) { + height = maxHeight - y; + } + + var img = this.shadowCtx.getImageData(x, y, width, height); + var imgData = img.data; + var len = imgData.length; + var palette = this._palette; + + + for (var i = 3; i < len; i+= 4) { + var alpha = imgData[i]; + var offset = alpha * 4; + + + if (!offset) { + continue; + } + + var finalAlpha; + if (opacity > 0) { + finalAlpha = opacity; + } else { + if (alpha < maxOpacity) { + if (alpha < minOpacity) { + finalAlpha = minOpacity; + } else { + finalAlpha = alpha; + } + } else { + finalAlpha = maxOpacity; + } + } + + imgData[i-3] = palette[offset]; + imgData[i-2] = palette[offset + 1]; + imgData[i-1] = palette[offset + 2]; + imgData[i] = useGradientOpacity ? palette[offset + 3] : finalAlpha; + + } + + img.data = imgData; + this.ctx.putImageData(img, x, y); + + this._renderBoundaries = [1000, 1000, 0, 0]; + + }, + getValueAt: function(point) { + var value; + var shadowCtx = this.shadowCtx; + var img = shadowCtx.getImageData(point.x, point.y, 1, 1); + var data = img.data[3]; + var max = this._max; + var min = this._min; + + value = (Math.abs(max-min) * (data/255)) >> 0; + + return value; + }, + getDataURL: function() { + return this.canvas.toDataURL(); + } + }; + + + return Canvas2dRenderer; +})(); + + +var Renderer = (function RendererClosure() { + + var rendererFn = false; + + if (HeatmapConfig['defaultRenderer'] === 'canvas2d') { + rendererFn = Canvas2dRenderer; + } + + return rendererFn; +})(); + + +var Util = { + merge: function() { + var merged = {}; + var argsLen = arguments.length; + for (var i = 0; i < argsLen; i++) { + var obj = arguments[i] + for (var key in obj) { + merged[key] = obj[key]; + } + } + return merged; + } +}; +// Heatmap Constructor +var Heatmap = (function HeatmapClosure() { + + var Coordinator = (function CoordinatorClosure() { + + function Coordinator() { + this.cStore = {}; + }; + + Coordinator.prototype = { + on: function(evtName, callback, scope) { + var cStore = this.cStore; + + if (!cStore[evtName]) { + cStore[evtName] = []; + } + cStore[evtName].push((function(data) { + return callback.call(scope, data); + })); + }, + emit: function(evtName, data) { + var cStore = this.cStore; + if (cStore[evtName]) { + var len = cStore[evtName].length; + for (var i=0; i { + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("button", { + type: "button", + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(["hsrToggleBtn", [`heatmapType${theHeatmapType.key}`, { + active: theHeatmapType.key === _ctx.heatmapType + }]]), + onClick: $event => _ctx.changeHeatmapType(theHeatmapType.key), + key: theHeatmapType.key + }, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(theHeatmapType.name), 11, _hoisted_5); + }), 128))]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_6, [(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.deviceTypesWithResolution, theDeviceType => { + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("button", { + type: "button", + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(["hsrToggleBtn", [`deviceType${theDeviceType.key}`, { + active: theDeviceType.key === _ctx.deviceType + }]]), + onClick: $event => _ctx.changeDeviceType(theDeviceType.key), + key: theDeviceType.key + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("img", { + height: "15", + src: theDeviceType.logo, + alt: `${_ctx.translate('DevicesDetection_Device')} ${theDeviceType.name}` + }, null, 8, _hoisted_8), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", _hoisted_9, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(theDeviceType.name), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", _hoisted_10, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(theDeviceType.resolution) + "px", 1)], 10, _hoisted_7); + }), 128))]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_11, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { + class: "customIframeWidthLabel", + textContent: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('HeatmapSessionRecording_CustomWidth')) + }, null, 8, _hoisted_12), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_Field, { + uicontrol: "select", + name: "iframewidth", + "model-value": _ctx.customIframeWidth, + "onUpdate:modelValue": _cache[0] || (_cache[0] = $event => { + _ctx.customIframeWidth = $event; + _ctx.changeIframeWidth(_ctx.customIframeWidth, true); + }), + options: _ctx.iframeWidthOptions, + "full-width": true + }, null, 8, ["model-value", "options"])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_13, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", _hoisted_14, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('HeatmapSessionRecording_LegendLow')), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_15, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("img", { + class: "gradient", + alt: "gradient", + src: _ctx.gradientImgData + }, null, 8, _hoisted_16)]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", _hoisted_17, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('HeatmapSessionRecording_LegendHigh')), 1)])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_18, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", { + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(["heatmapScrollArea", { + heatmapScrollAreaEmpty: !_ctx.hasSnapshot + }]), + ref: "heatmapScrollArea", + style: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeStyle"])(_ctx.scrollAreaStyle) + }, [_ctx.hasSnapshot ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", { + key: 0, + class: "heatmapScaleBox", + style: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeStyle"])(_ctx.scaleBoxStyle) + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", { + class: "iframeRecordingContainer", + ref: "iframeRecordingContainer", + style: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeStyle"])(_ctx.containerStyle) + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_19, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_20, null, 512), _hoisted_21]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", { + class: "aboveFoldLine", + ref: "aboveFoldLine", + title: _ctx.translate('HeatmapSessionRecording_AvgAboveFoldDescription'), + style: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeStyle"])({ + width: _ctx.iframeWidth + 'px', + top: _ctx.avgFold + 'px' + }) + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('HeatmapSessionRecording_AvgFold', _ctx.avgFold)), 1)], 12, _hoisted_22), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], _ctx.avgFold]]), _ctx.embedUrl ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("iframe", { + key: 0, + id: "recordingPlayer", + ref: "recordingPlayer", + sandbox: "allow-scripts allow-same-origin", + referrerpolicy: "no-referrer", + onLoad: _cache[1] || (_cache[1] = $event => _ctx.onLoaded()), + height: "400", + src: _ctx.embedUrl, + width: _ctx.iframeWidth + }, null, 40, _hoisted_23)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)], 4)], 4)) : (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createBlock"])(_component_HeatmapEmptyState, { + key: 1, + state: _ctx.emptyStateKey, + "id-site-hsr": _ctx.idSiteHsr + }, null, 8, ["state", "id-site-hsr"])), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_24, [_hoisted_25, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_26, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('HeatmapSessionRecording_LoadingHeatmap')), 1)], 512), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], _ctx.isLoading]])], 6), _ctx.showSummaryCard ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", _hoisted_27, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_28, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_29, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", _hoisted_30, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('HeatmapSessionRecording_Recordings')), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", _hoisted_31, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.selectedDeviceTypeName), 1)]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("h2", _hoisted_32, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.totalRecordings), 1), !_ctx.deviceBreakdown.length ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", _hoisted_33, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('HeatmapSessionRecording_WaitingForFirstRecording')), 1)) : (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", _hoisted_34, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_35, [(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.deviceBreakdown, device => { + 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"])(["deviceBreakdownSegment", `deviceBreakdownDevice${device.key}`]), + key: device.key, + style: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeStyle"])({ + width: `${device.percent}%` + }), + title: `${device.name} ${device.percentLabel}` + }, null, 14, _hoisted_36); + }), 128))]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_37, [(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.deviceBreakdown, device => { + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", { + class: "deviceBreakdownLegendItem", + key: device.key + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(["deviceBreakdownSwatch", `deviceBreakdownDevice${device.key}`]) + }, null, 2), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", _hoisted_38, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(device.name), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", _hoisted_39, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(device.percentLabel), 1)]); + }), 128))])])), _ctx.hasSnapshot ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])(external_commonjs_vue_commonjs2_vue_root_Vue_["Fragment"], { + key: 2 + }, [_hoisted_40, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_41, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('HeatmapSessionRecording_TopClickedElements')), 1), _ctx.topClickedElements.length ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", _hoisted_42, [(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.topClickedElements, element => { + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", { + class: "topClickedElementRow", + key: element.key + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_43, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", { + class: "topClickedElementLabel", + title: element.label + }, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(element.label), 9, _hoisted_44), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_45, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_46, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", { + class: "topClickedElementBarFill", + style: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeStyle"])({ + width: `${element.percent}%` + }) + }, null, 4)])])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", _hoisted_47, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(element.percentLabel), 1)]); + }), 128))])) : (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", _hoisted_48, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('HeatmapSessionRecording_NoClickedElements')), 1)), _hoisted_49, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_50, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", _hoisted_51, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('HeatmapSessionRecording_ScrollReach')), 1), _ctx.summaryAvgFold ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", _hoisted_52, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('HeatmapSessionRecording_AvgFold', _ctx.summaryAvgFold)), 1)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)]), _ctx.scrollReach.length ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", _hoisted_53, [(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.scrollReach, band => { + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", { + class: "scrollReachRow", + key: band.key + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", _hoisted_54, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(band.label), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_55, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", { + class: "topClickedElementBarFill", + style: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeStyle"])({ + width: `${band.percent}%` + }) + }, null, 4)]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", _hoisted_56, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(band.percentLabel), 1)]); + }), 128))])) : (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", _hoisted_57, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('HeatmapSessionRecording_NoScrollData')), 1))], 64)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)])])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_Tooltip, { + ref: "tooltip", + "click-count": _ctx.clickCount, + "click-rate": _ctx.clickRate, + "is-moves": _ctx.heatmapType === 1 + }, null, 8, ["click-count", "click-rate", "is-moves"])]); +} +// CONCATENATED MODULE: ./plugins/HeatmapSessionRecording/vue/src/HeatmapVis/HeatmapVis.vue?vue&type=template&id=7c68c81b + +// EXTERNAL MODULE: ./plugins/HeatmapSessionRecording/node_modules/heatmap.js/build/heatmap.js +var heatmap = __webpack_require__("246e"); +var heatmap_default = /*#__PURE__*/__webpack_require__.n(heatmap); + +// CONCATENATED MODULE: ./plugins/HeatmapSessionRecording/node_modules/html-to-image/es/util.js +function resolveUrl(url, baseUrl) { + // url is absolute already + if (url.match(/^[a-z]+:\/\//i)) { + return url; + } + // url is absolute already, without protocol + if (url.match(/^\/\//)) { + return window.location.protocol + url; + } + // dataURI, mailto:, tel:, etc. + if (url.match(/^[a-z]+:/i)) { + return url; + } + const doc = document.implementation.createHTMLDocument(); + const base = doc.createElement('base'); + const a = doc.createElement('a'); + doc.head.appendChild(base); + doc.body.appendChild(a); + if (baseUrl) { + base.href = baseUrl; + } + a.href = url; + return a.href; +} +const uuid = (() => { + // generate uuid for className of pseudo elements. + // We should not use GUIDs, otherwise pseudo elements sometimes cannot be captured. + let counter = 0; + // ref: http://stackoverflow.com/a/6248722/2519373 + const random = () => + // eslint-disable-next-line no-bitwise + `0000${((Math.random() * 36 ** 4) << 0).toString(36)}`.slice(-4); + return () => { + counter += 1; + return `u${random()}${counter}`; + }; +})(); +function delay(ms) { + return (args) => new Promise((resolve) => { + setTimeout(() => resolve(args), ms); + }); +} +function toArray(arrayLike) { + const arr = []; + for (let i = 0, l = arrayLike.length; i < l; i++) { + arr.push(arrayLike[i]); + } + return arr; +} +let styleProps = null; +function getStyleProperties(options = {}) { + if (styleProps) { + return styleProps; + } + if (options.includeStyleProperties) { + styleProps = options.includeStyleProperties; + return styleProps; + } + styleProps = toArray(window.getComputedStyle(document.documentElement)); + return styleProps; +} +function px(node, styleProperty) { + const win = node.ownerDocument.defaultView || window; + const val = win.getComputedStyle(node).getPropertyValue(styleProperty); + return val ? parseFloat(val.replace('px', '')) : 0; +} +function getNodeWidth(node) { + const leftBorder = px(node, 'border-left-width'); + const rightBorder = px(node, 'border-right-width'); + return node.clientWidth + leftBorder + rightBorder; +} +function getNodeHeight(node) { + const topBorder = px(node, 'border-top-width'); + const bottomBorder = px(node, 'border-bottom-width'); + return node.clientHeight + topBorder + bottomBorder; +} +function getImageSize(targetNode, options = {}) { + const width = options.width || getNodeWidth(targetNode); + const height = options.height || getNodeHeight(targetNode); + return { width, height }; +} +function getPixelRatio() { + let ratio; + let FINAL_PROCESS; + try { + FINAL_PROCESS = process; + } + catch (e) { + // pass + } + const val = FINAL_PROCESS && FINAL_PROCESS.env + ? FINAL_PROCESS.env.devicePixelRatio + : null; + if (val) { + ratio = parseInt(val, 10); + if (Number.isNaN(ratio)) { + ratio = 1; + } + } + return ratio || window.devicePixelRatio || 1; +} +// @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas#maximum_canvas_size +const canvasDimensionLimit = 16384; +function checkCanvasDimensions(canvas) { + if (canvas.width > canvasDimensionLimit || + canvas.height > canvasDimensionLimit) { + if (canvas.width > canvasDimensionLimit && + canvas.height > canvasDimensionLimit) { + if (canvas.width > canvas.height) { + canvas.height *= canvasDimensionLimit / canvas.width; + canvas.width = canvasDimensionLimit; + } + else { + canvas.width *= canvasDimensionLimit / canvas.height; + canvas.height = canvasDimensionLimit; + } + } + else if (canvas.width > canvasDimensionLimit) { + canvas.height *= canvasDimensionLimit / canvas.width; + canvas.width = canvasDimensionLimit; + } + else { + canvas.width *= canvasDimensionLimit / canvas.height; + canvas.height = canvasDimensionLimit; + } + } +} +function canvasToBlob(canvas, options = {}) { + if (canvas.toBlob) { + return new Promise((resolve) => { + canvas.toBlob(resolve, options.type ? options.type : 'image/png', options.quality ? options.quality : 1); + }); + } + return new Promise((resolve) => { + const binaryString = window.atob(canvas + .toDataURL(options.type ? options.type : undefined, options.quality ? options.quality : undefined) + .split(',')[1]); + const len = binaryString.length; + const binaryArray = new Uint8Array(len); + for (let i = 0; i < len; i += 1) { + binaryArray[i] = binaryString.charCodeAt(i); + } + resolve(new Blob([binaryArray], { + type: options.type ? options.type : 'image/png', + })); + }); +} +function createImage(url) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + img.decode().then(() => { + requestAnimationFrame(() => resolve(img)); + }); + }; + img.onerror = reject; + img.crossOrigin = 'anonymous'; + img.decoding = 'async'; + img.src = url; + }); +} +async function svgToDataURL(svg) { + return Promise.resolve() + .then(() => new XMLSerializer().serializeToString(svg)) + .then(encodeURIComponent) + .then((html) => `data:image/svg+xml;charset=utf-8,${html}`); +} +async function nodeToDataURL(node, width, height) { + const xmlns = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(xmlns, 'svg'); + const foreignObject = document.createElementNS(xmlns, 'foreignObject'); + svg.setAttribute('width', `${width}`); + svg.setAttribute('height', `${height}`); + svg.setAttribute('viewBox', `0 0 ${width} ${height}`); + foreignObject.setAttribute('width', '100%'); + foreignObject.setAttribute('height', '100%'); + foreignObject.setAttribute('x', '0'); + foreignObject.setAttribute('y', '0'); + foreignObject.setAttribute('externalResourcesRequired', 'true'); + svg.appendChild(foreignObject); + foreignObject.appendChild(node); + return svgToDataURL(svg); +} +const isInstanceOfElement = (node, instance) => { + if (node instanceof instance) + return true; + const nodePrototype = Object.getPrototypeOf(node); + if (nodePrototype === null) + return false; + return (nodePrototype.constructor.name === instance.name || + isInstanceOfElement(nodePrototype, instance)); +}; +//# sourceMappingURL=util.js.map +// CONCATENATED MODULE: ./plugins/HeatmapSessionRecording/node_modules/html-to-image/es/clone-pseudos.js + +function formatCSSText(style) { + const content = style.getPropertyValue('content'); + return `${style.cssText} content: '${content.replace(/'|"/g, '')}';`; +} +function formatCSSProperties(style, options) { + return getStyleProperties(options) + .map((name) => { + const value = style.getPropertyValue(name); + const priority = style.getPropertyPriority(name); + return `${name}: ${value}${priority ? ' !important' : ''};`; + }) + .join(' '); +} +function getPseudoElementStyle(className, pseudo, style, options) { + const selector = `.${className}:${pseudo}`; + const cssText = style.cssText + ? formatCSSText(style) + : formatCSSProperties(style, options); + return document.createTextNode(`${selector}{${cssText}}`); +} +function clonePseudoElement(nativeNode, clonedNode, pseudo, options) { + const style = window.getComputedStyle(nativeNode, pseudo); + const content = style.getPropertyValue('content'); + if (content === '' || content === 'none') { + return; + } + const className = uuid(); + try { + clonedNode.className = `${clonedNode.className} ${className}`; + } + catch (err) { + return; + } + const styleElement = document.createElement('style'); + styleElement.appendChild(getPseudoElementStyle(className, pseudo, style, options)); + clonedNode.appendChild(styleElement); +} +function clonePseudoElements(nativeNode, clonedNode, options) { + clonePseudoElement(nativeNode, clonedNode, ':before', options); + clonePseudoElement(nativeNode, clonedNode, ':after', options); +} +//# sourceMappingURL=clone-pseudos.js.map +// CONCATENATED MODULE: ./plugins/HeatmapSessionRecording/node_modules/html-to-image/es/mimes.js +const WOFF = 'application/font-woff'; +const JPEG = 'image/jpeg'; +const mimes = { + woff: WOFF, + woff2: WOFF, + ttf: 'application/font-truetype', + eot: 'application/vnd.ms-fontobject', + png: 'image/png', + jpg: JPEG, + jpeg: JPEG, + gif: 'image/gif', + tiff: 'image/tiff', + svg: 'image/svg+xml', + webp: 'image/webp', +}; +function getExtension(url) { + const match = /\.([^./]*?)$/g.exec(url); + return match ? match[1] : ''; +} +function getMimeType(url) { + const extension = getExtension(url).toLowerCase(); + return mimes[extension] || ''; +} +//# sourceMappingURL=mimes.js.map +// CONCATENATED MODULE: ./plugins/HeatmapSessionRecording/node_modules/html-to-image/es/dataurl.js +function getContentFromDataUrl(dataURL) { + return dataURL.split(/,/)[1]; +} +function isDataUrl(url) { + return url.search(/^(data:)/) !== -1; +} +function makeDataUrl(content, mimeType) { + return `data:${mimeType};base64,${content}`; +} +async function fetchAsDataURL(url, init, process) { + const res = await fetch(url, init); + if (res.status === 404) { + throw new Error(`Resource "${res.url}" not found`); + } + const blob = await res.blob(); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = reject; + reader.onloadend = () => { + try { + resolve(process({ res, result: reader.result })); + } + catch (error) { + reject(error); + } + }; + reader.readAsDataURL(blob); + }); +} +const cache = {}; +function getCacheKey(url, contentType, includeQueryParams) { + let key = url.replace(/\?.*/, ''); + if (includeQueryParams) { + key = url; + } + // font resource + if (/ttf|otf|eot|woff2?/i.test(key)) { + key = key.replace(/.*\//, ''); + } + return contentType ? `[${contentType}]${key}` : key; +} +async function resourceToDataURL(resourceUrl, contentType, options) { + const cacheKey = getCacheKey(resourceUrl, contentType, options.includeQueryParams); + if (cache[cacheKey] != null) { + return cache[cacheKey]; + } + // ref: https://developer.mozilla.org/en/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest#Bypassing_the_cache + if (options.cacheBust) { + // eslint-disable-next-line no-param-reassign + resourceUrl += (/\?/.test(resourceUrl) ? '&' : '?') + new Date().getTime(); + } + let dataURL; + try { + const content = await fetchAsDataURL(resourceUrl, options.fetchRequestInit, ({ res, result }) => { + if (!contentType) { + // eslint-disable-next-line no-param-reassign + contentType = res.headers.get('Content-Type') || ''; + } + return getContentFromDataUrl(result); + }); + dataURL = makeDataUrl(content, contentType); + } + catch (error) { + dataURL = options.imagePlaceholder || ''; + let msg = `Failed to fetch resource: ${resourceUrl}`; + if (error) { + msg = typeof error === 'string' ? error : error.message; + } + if (msg) { + console.warn(msg); + } + } + cache[cacheKey] = dataURL; + return dataURL; +} +//# sourceMappingURL=dataurl.js.map +// CONCATENATED MODULE: ./plugins/HeatmapSessionRecording/node_modules/html-to-image/es/clone-node.js + + + + +async function cloneCanvasElement(canvas) { + const dataURL = canvas.toDataURL(); + if (dataURL === 'data:,') { + return canvas.cloneNode(false); + } + return createImage(dataURL); +} +async function cloneVideoElement(video, options) { + if (video.currentSrc) { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + canvas.width = video.clientWidth; + canvas.height = video.clientHeight; + ctx === null || ctx === void 0 ? void 0 : ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + const dataURL = canvas.toDataURL(); + return createImage(dataURL); + } + const poster = video.poster; + const contentType = getMimeType(poster); + const dataURL = await resourceToDataURL(poster, contentType, options); + return createImage(dataURL); +} +async function cloneIFrameElement(iframe, options) { + var _a; + try { + if ((_a = iframe === null || iframe === void 0 ? void 0 : iframe.contentDocument) === null || _a === void 0 ? void 0 : _a.body) { + return (await cloneNode(iframe.contentDocument.body, options, true)); + } + } + catch (_b) { + // Failed to clone iframe + } + return iframe.cloneNode(false); +} +async function cloneSingleNode(node, options) { + if (isInstanceOfElement(node, HTMLCanvasElement)) { + return cloneCanvasElement(node); + } + if (isInstanceOfElement(node, HTMLVideoElement)) { + return cloneVideoElement(node, options); + } + if (isInstanceOfElement(node, HTMLIFrameElement)) { + return cloneIFrameElement(node, options); + } + return node.cloneNode(isSVGElement(node)); +} +const isSlotElement = (node) => node.tagName != null && node.tagName.toUpperCase() === 'SLOT'; +const isSVGElement = (node) => node.tagName != null && node.tagName.toUpperCase() === 'SVG'; +async function cloneChildren(nativeNode, clonedNode, options) { + var _a, _b; + if (isSVGElement(clonedNode)) { + return clonedNode; + } + let children = []; + if (isSlotElement(nativeNode) && nativeNode.assignedNodes) { + children = toArray(nativeNode.assignedNodes()); + } + else if (isInstanceOfElement(nativeNode, HTMLIFrameElement) && + ((_a = nativeNode.contentDocument) === null || _a === void 0 ? void 0 : _a.body)) { + children = []; + } + else { + children = toArray(((_b = nativeNode.shadowRoot) !== null && _b !== void 0 ? _b : nativeNode).childNodes); + } + if (children.length === 0 || + isInstanceOfElement(nativeNode, HTMLVideoElement)) { + return clonedNode; + } + await children.reduce((deferred, child) => deferred + .then(() => cloneNode(child, options)) + .then((clonedChild) => { + if (clonedChild) { + clonedNode.appendChild(clonedChild); + } + }), Promise.resolve()); + return clonedNode; +} +function cloneCSSStyle(nativeNode, clonedNode, options) { + const targetStyle = clonedNode.style; + if (!targetStyle) { + return; + } + const sourceStyle = window.getComputedStyle(nativeNode); + if (sourceStyle.cssText) { + targetStyle.cssText = sourceStyle.cssText; + targetStyle.transformOrigin = sourceStyle.transformOrigin; + } + else { + getStyleProperties(options).forEach((name) => { + let value = sourceStyle.getPropertyValue(name); + if (name === 'font-size' && value.endsWith('px')) { + const reducedFont = Math.floor(parseFloat(value.substring(0, value.length - 2))) - 0.1; + value = `${reducedFont}px`; + } + if (isInstanceOfElement(nativeNode, HTMLIFrameElement) && + name === 'display' && + value === 'inline') { + value = 'block'; + } + if (name === 'd' && clonedNode.getAttribute('d')) { + value = `path(${clonedNode.getAttribute('d')})`; + } + targetStyle.setProperty(name, value, sourceStyle.getPropertyPriority(name)); + }); + } +} +function cloneInputValue(nativeNode, clonedNode) { + if (isInstanceOfElement(nativeNode, HTMLTextAreaElement)) { + clonedNode.innerHTML = nativeNode.value; + } + if (isInstanceOfElement(nativeNode, HTMLInputElement)) { + clonedNode.setAttribute('value', nativeNode.value); + } +} +function cloneSelectValue(nativeNode, clonedNode) { + if (isInstanceOfElement(nativeNode, HTMLSelectElement)) { + const clonedSelect = clonedNode; + const selectedOption = Array.from(clonedSelect.children).find((child) => nativeNode.value === child.getAttribute('value')); + if (selectedOption) { + selectedOption.setAttribute('selected', ''); + } + } +} +function decorate(nativeNode, clonedNode, options) { + if (isInstanceOfElement(clonedNode, Element)) { + cloneCSSStyle(nativeNode, clonedNode, options); + clonePseudoElements(nativeNode, clonedNode, options); + cloneInputValue(nativeNode, clonedNode); + cloneSelectValue(nativeNode, clonedNode); + } + return clonedNode; +} +async function ensureSVGSymbols(clone, options) { + const uses = clone.querySelectorAll ? clone.querySelectorAll('use') : []; + if (uses.length === 0) { + return clone; + } + const processedDefs = {}; + for (let i = 0; i < uses.length; i++) { + const use = uses[i]; + const id = use.getAttribute('xlink:href'); + if (id) { + const exist = clone.querySelector(id); + const definition = document.querySelector(id); + if (!exist && definition && !processedDefs[id]) { + // eslint-disable-next-line no-await-in-loop + processedDefs[id] = (await cloneNode(definition, options, true)); + } + } + } + const nodes = Object.values(processedDefs); + if (nodes.length) { + const ns = 'http://www.w3.org/1999/xhtml'; + const svg = document.createElementNS(ns, 'svg'); + svg.setAttribute('xmlns', ns); + svg.style.position = 'absolute'; + svg.style.width = '0'; + svg.style.height = '0'; + svg.style.overflow = 'hidden'; + svg.style.display = 'none'; + const defs = document.createElementNS(ns, 'defs'); + svg.appendChild(defs); + for (let i = 0; i < nodes.length; i++) { + defs.appendChild(nodes[i]); + } + clone.appendChild(svg); + } + return clone; +} +async function cloneNode(node, options, isRoot) { + if (!isRoot && options.filter && !options.filter(node)) { + return null; + } + return Promise.resolve(node) + .then((clonedNode) => cloneSingleNode(clonedNode, options)) + .then((clonedNode) => cloneChildren(node, clonedNode, options)) + .then((clonedNode) => decorate(node, clonedNode, options)) + .then((clonedNode) => ensureSVGSymbols(clonedNode, options)); +} +//# sourceMappingURL=clone-node.js.map +// CONCATENATED MODULE: ./plugins/HeatmapSessionRecording/node_modules/html-to-image/es/embed-resources.js + + + +const URL_REGEX = /url\((['"]?)([^'"]+?)\1\)/g; +const URL_WITH_FORMAT_REGEX = /url\([^)]+\)\s*format\((["']?)([^"']+)\1\)/g; +const FONT_SRC_REGEX = /src:\s*(?:url\([^)]+\)\s*format\([^)]+\)[,;]\s*)+/g; +function toRegex(url) { + // eslint-disable-next-line no-useless-escape + const escaped = url.replace(/([.*+?^${}()|\[\]\/\\])/g, '\\$1'); + return new RegExp(`(url\\(['"]?)(${escaped})(['"]?\\))`, 'g'); +} +function parseURLs(cssText) { + const urls = []; + cssText.replace(URL_REGEX, (raw, quotation, url) => { + urls.push(url); + return raw; + }); + return urls.filter((url) => !isDataUrl(url)); +} +async function embed_resources_embed(cssText, resourceURL, baseURL, options, getContentFromUrl) { + try { + const resolvedURL = baseURL ? resolveUrl(resourceURL, baseURL) : resourceURL; + const contentType = getMimeType(resourceURL); + let dataURL; + if (getContentFromUrl) { + const content = await getContentFromUrl(resolvedURL); + dataURL = makeDataUrl(content, contentType); + } + else { + dataURL = await resourceToDataURL(resolvedURL, contentType, options); + } + return cssText.replace(toRegex(resourceURL), `$1${dataURL}$3`); + } + catch (error) { + // pass + } + return cssText; +} +function filterPreferredFontFormat(str, { preferredFontFormat }) { + return !preferredFontFormat + ? str + : str.replace(FONT_SRC_REGEX, (match) => { + // eslint-disable-next-line no-constant-condition + while (true) { + const [src, , format] = URL_WITH_FORMAT_REGEX.exec(match) || []; + if (!format) { + return ''; + } + if (format === preferredFontFormat) { + return `src: ${src};`; + } + } + }); +} +function shouldEmbed(url) { + return url.search(URL_REGEX) !== -1; +} +async function embedResources(cssText, baseUrl, options) { + if (!shouldEmbed(cssText)) { + return cssText; + } + const filteredCSSText = filterPreferredFontFormat(cssText, options); + const urls = parseURLs(filteredCSSText); + return urls.reduce((deferred, url) => deferred.then((css) => embed_resources_embed(css, url, baseUrl, options)), Promise.resolve(filteredCSSText)); +} +//# sourceMappingURL=embed-resources.js.map +// CONCATENATED MODULE: ./plugins/HeatmapSessionRecording/node_modules/html-to-image/es/embed-images.js + + + + +async function embedProp(propName, node, options) { + var _a; + const propValue = (_a = node.style) === null || _a === void 0 ? void 0 : _a.getPropertyValue(propName); + if (propValue) { + const cssString = await embedResources(propValue, null, options); + node.style.setProperty(propName, cssString, node.style.getPropertyPriority(propName)); + return true; + } + return false; +} +async function embedBackground(clonedNode, options) { + ; + (await embedProp('background', clonedNode, options)) || + (await embedProp('background-image', clonedNode, options)); + (await embedProp('mask', clonedNode, options)) || + (await embedProp('-webkit-mask', clonedNode, options)) || + (await embedProp('mask-image', clonedNode, options)) || + (await embedProp('-webkit-mask-image', clonedNode, options)); +} +async function embedImageNode(clonedNode, options) { + const isImageElement = isInstanceOfElement(clonedNode, HTMLImageElement); + if (!(isImageElement && !isDataUrl(clonedNode.src)) && + !(isInstanceOfElement(clonedNode, SVGImageElement) && + !isDataUrl(clonedNode.href.baseVal))) { + return; + } + const url = isImageElement ? clonedNode.src : clonedNode.href.baseVal; + const dataURL = await resourceToDataURL(url, getMimeType(url), options); + await new Promise((resolve, reject) => { + clonedNode.onload = resolve; + clonedNode.onerror = options.onImageErrorHandler + ? (...attributes) => { + try { + resolve(options.onImageErrorHandler(...attributes)); + } + catch (error) { + reject(error); + } + } + : reject; + const image = clonedNode; + if (image.decode) { + image.decode = resolve; + } + if (image.loading === 'lazy') { + image.loading = 'eager'; + } + if (isImageElement) { + clonedNode.srcset = ''; + clonedNode.src = dataURL; + } + else { + clonedNode.href.baseVal = dataURL; + } + }); +} +async function embedChildren(clonedNode, options) { + const children = toArray(clonedNode.childNodes); + const deferreds = children.map((child) => embedImages(child, options)); + await Promise.all(deferreds).then(() => clonedNode); +} +async function embedImages(clonedNode, options) { + if (isInstanceOfElement(clonedNode, Element)) { + await embedBackground(clonedNode, options); + await embedImageNode(clonedNode, options); + await embedChildren(clonedNode, options); + } +} +//# sourceMappingURL=embed-images.js.map +// CONCATENATED MODULE: ./plugins/HeatmapSessionRecording/node_modules/html-to-image/es/apply-style.js +function applyStyle(node, options) { + const { style } = node; + if (options.backgroundColor) { + style.backgroundColor = options.backgroundColor; + } + if (options.width) { + style.width = `${options.width}px`; + } + if (options.height) { + style.height = `${options.height}px`; + } + const manual = options.style; + if (manual != null) { + Object.keys(manual).forEach((key) => { + style[key] = manual[key]; + }); + } + return node; +} +//# sourceMappingURL=apply-style.js.map +// CONCATENATED MODULE: ./plugins/HeatmapSessionRecording/node_modules/html-to-image/es/embed-webfonts.js + + + +const cssFetchCache = {}; +async function fetchCSS(url) { + let cache = cssFetchCache[url]; + if (cache != null) { + return cache; + } + const res = await fetch(url); + const cssText = await res.text(); + cache = { url, cssText }; + cssFetchCache[url] = cache; + return cache; +} +async function embedFonts(data, options) { + let cssText = data.cssText; + const regexUrl = /url\(["']?([^"')]+)["']?\)/g; + const fontLocs = cssText.match(/url\([^)]+\)/g) || []; + const loadFonts = fontLocs.map(async (loc) => { + let url = loc.replace(regexUrl, '$1'); + if (!url.startsWith('https://')) { + url = new URL(url, data.url).href; + } + return fetchAsDataURL(url, options.fetchRequestInit, ({ result }) => { + cssText = cssText.replace(loc, `url(${result})`); + return [loc, result]; + }); + }); + return Promise.all(loadFonts).then(() => cssText); +} +function parseCSS(source) { + if (source == null) { + return []; + } + const result = []; + const commentsRegex = /(\/\*[\s\S]*?\*\/)/gi; + // strip out comments + let cssText = source.replace(commentsRegex, ''); + // eslint-disable-next-line prefer-regex-literals + const keyframesRegex = new RegExp('((@.*?keyframes [\\s\\S]*?){([\\s\\S]*?}\\s*?)})', 'gi'); + // eslint-disable-next-line no-constant-condition + while (true) { + const matches = keyframesRegex.exec(cssText); + if (matches === null) { + break; + } + result.push(matches[0]); + } + cssText = cssText.replace(keyframesRegex, ''); + const importRegex = /@import[\s\S]*?url\([^)]*\)[\s\S]*?;/gi; + // to match css & media queries together + const combinedCSSRegex = '((\\s*?(?:\\/\\*[\\s\\S]*?\\*\\/)?\\s*?@media[\\s\\S]' + + '*?){([\\s\\S]*?)}\\s*?})|(([\\s\\S]*?){([\\s\\S]*?)})'; + // unified regex + const unifiedRegex = new RegExp(combinedCSSRegex, 'gi'); + // eslint-disable-next-line no-constant-condition + while (true) { + let matches = importRegex.exec(cssText); + if (matches === null) { + matches = unifiedRegex.exec(cssText); + if (matches === null) { + break; + } + else { + importRegex.lastIndex = unifiedRegex.lastIndex; + } + } + else { + unifiedRegex.lastIndex = importRegex.lastIndex; + } + result.push(matches[0]); + } + return result; +} +async function getCSSRules(styleSheets, options) { + const ret = []; + const deferreds = []; + // First loop inlines imports + styleSheets.forEach((sheet) => { + if ('cssRules' in sheet) { + try { + toArray(sheet.cssRules || []).forEach((item, index) => { + if (item.type === CSSRule.IMPORT_RULE) { + let importIndex = index + 1; + const url = item.href; + const deferred = fetchCSS(url) + .then((metadata) => embedFonts(metadata, options)) + .then((cssText) => parseCSS(cssText).forEach((rule) => { + try { + sheet.insertRule(rule, rule.startsWith('@import') + ? (importIndex += 1) + : sheet.cssRules.length); + } + catch (error) { + console.error('Error inserting rule from remote css', { + rule, + error, + }); + } + })) + .catch((e) => { + console.error('Error loading remote css', e.toString()); + }); + deferreds.push(deferred); + } + }); + } + catch (e) { + const inline = styleSheets.find((a) => a.href == null) || document.styleSheets[0]; + if (sheet.href != null) { + deferreds.push(fetchCSS(sheet.href) + .then((metadata) => embedFonts(metadata, options)) + .then((cssText) => parseCSS(cssText).forEach((rule) => { + inline.insertRule(rule, inline.cssRules.length); + })) + .catch((err) => { + console.error('Error loading remote stylesheet', err); + })); + } + console.error('Error inlining remote css file', e); + } + } + }); + return Promise.all(deferreds).then(() => { + // Second loop parses rules + styleSheets.forEach((sheet) => { + if ('cssRules' in sheet) { + try { + toArray(sheet.cssRules || []).forEach((item) => { + ret.push(item); + }); + } + catch (e) { + console.error(`Error while reading CSS rules from ${sheet.href}`, e); + } + } + }); + return ret; + }); +} +function getWebFontRules(cssRules) { + return cssRules + .filter((rule) => rule.type === CSSRule.FONT_FACE_RULE) + .filter((rule) => shouldEmbed(rule.style.getPropertyValue('src'))); +} +async function parseWebFontRules(node, options) { + if (node.ownerDocument == null) { + throw new Error('Provided element is not within a Document'); + } + const styleSheets = toArray(node.ownerDocument.styleSheets); + const cssRules = await getCSSRules(styleSheets, options); + return getWebFontRules(cssRules); +} +function normalizeFontFamily(font) { + return font.trim().replace(/["']/g, ''); +} +function getUsedFonts(node) { + const fonts = new Set(); + function traverse(node) { + const fontFamily = node.style.fontFamily || getComputedStyle(node).fontFamily; + fontFamily.split(',').forEach((font) => { + fonts.add(normalizeFontFamily(font)); + }); + Array.from(node.children).forEach((child) => { + if (child instanceof HTMLElement) { + traverse(child); + } + }); + } + traverse(node); + return fonts; +} +async function getWebFontCSS(node, options) { + const rules = await parseWebFontRules(node, options); + const usedFonts = getUsedFonts(node); + const cssTexts = await Promise.all(rules + .filter((rule) => usedFonts.has(normalizeFontFamily(rule.style.fontFamily))) + .map((rule) => { + const baseUrl = rule.parentStyleSheet + ? rule.parentStyleSheet.href + : null; + return embedResources(rule.cssText, baseUrl, options); + })); + return cssTexts.join('\n'); +} +async function embedWebFonts(clonedNode, options) { + const cssText = options.fontEmbedCSS != null + ? options.fontEmbedCSS + : options.skipFonts + ? null + : await getWebFontCSS(clonedNode, options); + if (cssText) { + const styleNode = document.createElement('style'); + const sytleContent = document.createTextNode(cssText); + styleNode.appendChild(sytleContent); + if (clonedNode.firstChild) { + clonedNode.insertBefore(styleNode, clonedNode.firstChild); + } + else { + clonedNode.appendChild(styleNode); + } + } +} +//# sourceMappingURL=embed-webfonts.js.map +// CONCATENATED MODULE: ./plugins/HeatmapSessionRecording/node_modules/html-to-image/es/index.js + + + + + +async function toSvg(node, options = {}) { + const { width, height } = getImageSize(node, options); + const clonedNode = (await cloneNode(node, options, true)); + await embedWebFonts(clonedNode, options); + await embedImages(clonedNode, options); + applyStyle(clonedNode, options); + const datauri = await nodeToDataURL(clonedNode, width, height); + return datauri; +} +async function toCanvas(node, options = {}) { + const { width, height } = getImageSize(node, options); + const svg = await toSvg(node, options); + const img = await createImage(svg); + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + const ratio = options.pixelRatio || getPixelRatio(); + const canvasWidth = options.canvasWidth || width; + const canvasHeight = options.canvasHeight || height; + canvas.width = canvasWidth * ratio; + canvas.height = canvasHeight * ratio; + if (!options.skipAutoScale) { + checkCanvasDimensions(canvas); + } + canvas.style.width = `${canvasWidth}`; + canvas.style.height = `${canvasHeight}`; + if (options.backgroundColor) { + context.fillStyle = options.backgroundColor; + context.fillRect(0, 0, canvas.width, canvas.height); + } + context.drawImage(img, 0, 0, canvas.width, canvas.height); + return canvas; +} +async function toPixelData(node, options = {}) { + const { width, height } = getImageSize(node, options); + const canvas = await toCanvas(node, options); + const ctx = canvas.getContext('2d'); + return ctx.getImageData(0, 0, width, height).data; +} +async function toPng(node, options = {}) { + const canvas = await toCanvas(node, options); + return canvas.toDataURL(); +} +async function toJpeg(node, options = {}) { + const canvas = await toCanvas(node, options); + return canvas.toDataURL('image/jpeg', options.quality || 1); +} +async function toBlob(node, options = {}) { + const canvas = await toCanvas(node, options); + const blob = await canvasToBlob(canvas); + return blob; +} +async function getFontEmbedCSS(node, options = {}) { + return getWebFontCSS(node, options); +} +//# sourceMappingURL=index.js.map +// EXTERNAL MODULE: external "CoreHome" +var external_CoreHome_ = __webpack_require__("19dc"); + +// EXTERNAL MODULE: external "CorePluginsAdmin" +var external_CorePluginsAdmin_ = __webpack_require__("a5a2"); + +// CONCATENATED MODULE: ./plugins/HeatmapSessionRecording/vue/src/HeatmapVis/heatmapGradient.ts +/** + * Copyright (C) InnoCraft Ltd - All rights reserved. + * + * NOTICE: All information contained herein is, and remains the property of InnoCraft Ltd. + * The intellectual and technical concepts contained herein are protected by trade secret + * or copyright law. Redistribution of this information or reproduction of this material is + * strictly forbidden unless prior written permission is obtained from InnoCraft Ltd. + * + * You shall use this code only in accordance with the license agreement obtained from + * InnoCraft Ltd. + * + * @link https://www.innocraft.com/ + * @license For license details see https://www.innocraft.com/license + */ +// Single source of truth for all heatmap colors (click/move overlays, scroll +// overlay and the legend gradient). Stops must be ordered ascending by `stop`. +const HEATMAP_GRADIENT_STOPS = [{ + stop: 0.0, + rgb: [43, 89, 255] +}, { + stop: 0.2, + rgb: [22, 184, 192] +}, { + stop: 0.4, + rgb: [76, 175, 80] +}, { + stop: 0.6, + rgb: [255, 224, 0] +}, { + stop: 0.8, + rgb: [245, 124, 0] +}, { + stop: 1.0, + rgb: [229, 57, 53] +} // #E53935 +]; +function toHeatmapJsGradient(stops = HEATMAP_GRADIENT_STOPS) { + const gradient = {}; + for (let i = 0; i < stops.length; i += 1) { + gradient[`${stops[i].stop}`] = `rgb(${stops[i].rgb[0]},${stops[i].rgb[1]},${stops[i].rgb[2]})`; + } + return gradient; +} +function interpolateGradientColor(intensity, min, max, stops = HEATMAP_GRADIENT_STOPS) { + const lastStop = stops[stops.length - 1]; + if (min === max || !min && !max) { + return [lastStop.rgb[0], lastStop.rgb[1], lastStop.rgb[2]]; + } + let t = (intensity - min) / (max - min); + t = Math.min(Math.max(t, 0), 1); + let lower = stops[0]; + let upper = lastStop; + for (let i = 0; i < stops.length - 1; i += 1) { + if (t >= stops[i].stop && t <= stops[i + 1].stop) { + lower = stops[i]; + upper = stops[i + 1]; + break; + } + } + const range = upper.stop - lower.stop; + const ratio = range === 0 ? 0 : (t - lower.stop) / range; + return [Math.round(lower.rgb[0] + (upper.rgb[0] - lower.rgb[0]) * ratio), Math.round(lower.rgb[1] + (upper.rgb[1] - lower.rgb[1]) * ratio), Math.round(lower.rgb[2] + (upper.rgb[2] - lower.rgb[2]) * ratio)]; +} +function generateGradientImgData(width = 100, height = 10, stops = HEATMAP_GRADIENT_STOPS) { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) { + return ''; + } + const gradient = ctx.createLinearGradient(0, 0, width, 0); + for (let i = 0; i < stops.length; i += 1) { + gradient.addColorStop(stops[i].stop, `rgb(${stops[i].rgb[0]},${stops[i].rgb[1]},${stops[i].rgb[2]})`); + } + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, width, height); + return canvas.toDataURL(); +} +// CONCATENATED MODULE: ./plugins/HeatmapSessionRecording/vue/src/getIframeWindow.ts +/** + * Copyright (C) InnoCraft Ltd - All rights reserved. + * + * NOTICE: All information contained herein is, and remains the property of InnoCraft Ltd. + * The intellectual and technical concepts contained herein are protected by trade secret + * or copyright law. Redistribution of this information or reproduction of this material is + * strictly forbidden unless prior written permission is obtained from InnoCraft Ltd. + * + * You shall use this code only in accordance with the license agreement obtained from + * InnoCraft Ltd. + * + * @link https://www.innocraft.com/ + * @license For license details see https://www.innocraft.com/license + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getIframeWindow(iframeElement) { + if (iframeElement && iframeElement.contentWindow) { + return iframeElement.contentWindow; + } + if (iframeElement && iframeElement.contentDocument && iframeElement.contentDocument.defaultView) { + return iframeElement.contentDocument.defaultView; + } + return undefined; +} +// CONCATENATED MODULE: ./plugins/HeatmapSessionRecording/vue/src/oneAtATime.ts +/** + * Copyright (C) InnoCraft Ltd - All rights reserved. + * + * NOTICE: All information contained herein is, and remains the property of InnoCraft Ltd. + * The intellectual and technical concepts contained herein are protected by trade secret + * or copyright law. Redistribution of this information or reproduction of this material is + * strictly forbidden unless prior written permission is obtained from InnoCraft Ltd. + * + * You shall use this code only in accordance with the license agreement obtained from + * InnoCraft Ltd. + * + * @link https://www.innocraft.com/ + * @license For license details see https://www.innocraft.com/license + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function oneAtATime(method, options) { + let abortController = null; + return (params, postParams) => { + if (abortController) { + abortController.abort(); + abortController = null; + } + abortController = new AbortController(); + return external_CoreHome_["AjaxHelper"].post(Object.assign(Object.assign({}, params), {}, { + method + }), postParams, Object.assign(Object.assign({}, options), {}, { + abortController + })).finally(() => { + abortController = null; + }); + }; +} +// 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/HeatmapSessionRecording/vue/src/Tooltip/Tooltip.vue?vue&type=template&id=792e9515 + +const Tooltipvue_type_template_id_792e9515_hoisted_1 = { + class: "tooltip-item" +}; +const Tooltipvue_type_template_id_792e9515_hoisted_2 = { + class: "tooltip-label" +}; +const Tooltipvue_type_template_id_792e9515_hoisted_3 = { + class: "tooltip-value" +}; +const Tooltipvue_type_template_id_792e9515_hoisted_4 = { + class: "tooltip-item" +}; +const Tooltipvue_type_template_id_792e9515_hoisted_5 = { + class: "tooltip-label" +}; +const Tooltipvue_type_template_id_792e9515_hoisted_6 = { + class: "tooltip-value" +}; +function Tooltipvue_type_template_id_792e9515_render(_ctx, _cache, $props, $setup, $data, $options) { + 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"])("div", { + ref: "tooltipRef", + class: "tooltip", + style: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeStyle"])(_ctx.tooltipStyle) + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", Tooltipvue_type_template_id_792e9515_hoisted_1, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", Tooltipvue_type_template_id_792e9515_hoisted_2, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.getClickCountTranslation), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", Tooltipvue_type_template_id_792e9515_hoisted_3, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.getClickCount), 1)]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", Tooltipvue_type_template_id_792e9515_hoisted_4, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", Tooltipvue_type_template_id_792e9515_hoisted_5, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.getClickRateTranslation), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", Tooltipvue_type_template_id_792e9515_hoisted_6, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.getClickRate), 1)])], 4)), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], _ctx.visible]]); +} +// CONCATENATED MODULE: ./plugins/HeatmapSessionRecording/vue/src/Tooltip/Tooltip.vue?vue&type=template&id=792e9515 + +// 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/HeatmapSessionRecording/vue/src/Tooltip/Tooltip.vue?vue&type=script&lang=ts + + +/* harmony default export */ var Tooltipvue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + props: { + clickCount: { + type: Number, + required: true + }, + clickRate: { + type: Number, + required: true + }, + isMoves: { + type: Boolean, + required: false, + default: false + } + }, + setup() { + const state = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["reactive"])({ + visible: false, + position: { + top: 0, + left: 0 + } + }); + const tooltipRef = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])(null); + // The tooltip follows the cursor, so it is positioned in viewport + // coordinates via position: fixed. It must not be position: absolute: + // that would resolve the coordinates against the nearest positioned + // ancestor (the report card, as .card is position: relative), shifting + // the tooltip away from the cursor by that ancestor's page offset. + const tooltipStyle = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => ({ + top: `${state.position.top}px`, + left: `${state.position.left}px`, + position: 'fixed', + zIndex: 1000 + })); + function show(event) { + state.position.top = event.clientY + 10; + state.position.left = event.clientX + 10; + state.visible = true; + Object(external_commonjs_vue_commonjs2_vue_root_Vue_["nextTick"])(() => { + const tooltipElement = tooltipRef.value; + if (tooltipElement) { + const { + innerWidth, + innerHeight + } = window; + const tooltipRect = tooltipElement.getBoundingClientRect(); + if (tooltipRect.right > innerWidth) { + state.position.left = event.clientX - tooltipRect.width - 10; + } + if (tooltipRect.bottom > innerHeight) { + state.position.top = event.clientY - tooltipRect.height - 10; + } + const adjustedTooltipRect = tooltipElement.getBoundingClientRect(); + if (adjustedTooltipRect.left < 0) { + state.position.left = 10; + } + if (adjustedTooltipRect.top < 0) { + state.position.top = 10; + } + } + }); + } + function hide() { + state.visible = false; + } + return Object.assign(Object.assign({}, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toRefs"])(state)), {}, { + tooltipRef, + show, + hide, + tooltipStyle, + translate: external_CoreHome_["translate"] + }); + }, + computed: { + getClickCount() { + return external_CoreHome_["NumberFormatter"].formatNumber(this.clickCount); + }, + getClickRate() { + return external_CoreHome_["NumberFormatter"].formatPercent(this.clickRate); + }, + getClickCountTranslation() { + const translation = this.isMoves ? 'HeatmapSessionRecording_Moves' : 'HeatmapSessionRecording_Clicks'; + return Object(external_CoreHome_["translate"])(translation); + }, + getClickRateTranslation() { + const translation = this.isMoves ? 'HeatmapSessionRecording_MoveRate' : 'HeatmapSessionRecording_ClickRate'; + return Object(external_CoreHome_["translate"])(translation); + } + } +})); +// CONCATENATED MODULE: ./plugins/HeatmapSessionRecording/vue/src/Tooltip/Tooltip.vue?vue&type=script&lang=ts + +// CONCATENATED MODULE: ./plugins/HeatmapSessionRecording/vue/src/Tooltip/Tooltip.vue + + + +Tooltipvue_type_script_lang_ts.render = Tooltipvue_type_template_id_792e9515_render + +/* harmony default export */ var Tooltip = (Tooltipvue_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/HeatmapSessionRecording/vue/src/HeatmapVis/HeatmapEmptyState.vue?vue&type=template&id=7a3200ad + +const HeatmapEmptyStatevue_type_template_id_7a3200ad_hoisted_1 = { + class: "heatmapEmptyState" +}; +const HeatmapEmptyStatevue_type_template_id_7a3200ad_hoisted_2 = { + class: "heatmapEmptyStateIcon" +}; +const HeatmapEmptyStatevue_type_template_id_7a3200ad_hoisted_3 = { + key: 0, + src: "plugins/HeatmapSessionRecording/images/no-interactions.svg", + width: "34", + height: "38", + alt: "" +}; +const HeatmapEmptyStatevue_type_template_id_7a3200ad_hoisted_4 = { + key: 1, + class: "icon-delete" +}; +const HeatmapEmptyStatevue_type_template_id_7a3200ad_hoisted_5 = { + class: "heatmapEmptyStateTitle" +}; +const HeatmapEmptyStatevue_type_template_id_7a3200ad_hoisted_6 = { + class: "heatmapEmptyStateSubtitle" +}; +const HeatmapEmptyStatevue_type_template_id_7a3200ad_hoisted_7 = { + class: "heatmapEmptyStateCodeWrapper" +}; +const HeatmapEmptyStatevue_type_template_id_7a3200ad_hoisted_8 = ["textContent"]; +const HeatmapEmptyStatevue_type_template_id_7a3200ad_hoisted_9 = ["href"]; +function HeatmapEmptyStatevue_type_template_id_7a3200ad_render(_ctx, _cache, $props, $setup, $data, $options) { + const _directive_copy_to_clipboard = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveDirective"])("copy-to-clipboard"); + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", HeatmapEmptyStatevue_type_template_id_7a3200ad_hoisted_1, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", HeatmapEmptyStatevue_type_template_id_7a3200ad_hoisted_2, [_ctx.state === 'noInteractions' ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("img", HeatmapEmptyStatevue_type_template_id_7a3200ad_hoisted_3)) : (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", HeatmapEmptyStatevue_type_template_id_7a3200ad_hoisted_4))]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("h3", HeatmapEmptyStatevue_type_template_id_7a3200ad_hoisted_5, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.title), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("p", HeatmapEmptyStatevue_type_template_id_7a3200ad_hoisted_6, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.subtitle), 1), _ctx.state === 'deletedManual' ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])(external_commonjs_vue_commonjs2_vue_root_Vue_["Fragment"], { + key: 0 + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", HeatmapEmptyStatevue_type_template_id_7a3200ad_hoisted_7, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("pre", { + class: "heatmapEmptyStateCode", + textContent: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.captureCommand) + }, null, 8, HeatmapEmptyStatevue_type_template_id_7a3200ad_hoisted_8), [[_directive_copy_to_clipboard, {}]])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("a", { + class: "heatmapEmptyStateGuide btn", + target: "_blank", + rel: "noreferrer noopener", + href: _ctx.guideUrl + }, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('HeatmapSessionRecording_ReadTheGuide')), 9, HeatmapEmptyStatevue_type_template_id_7a3200ad_hoisted_9)], 64)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)]); +} +// CONCATENATED MODULE: ./plugins/HeatmapSessionRecording/vue/src/HeatmapVis/HeatmapEmptyState.vue?vue&type=template&id=7a3200ad + +// 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/HeatmapSessionRecording/vue/src/HeatmapVis/HeatmapEmptyState.vue?vue&type=script&lang=ts + + +const GUIDE_URL = 'https://matomo.org/faq/heatmap-session-recording/how-to-capture-the-initial-heatmap-snapshot-manually/'; +/* harmony default export */ var HeatmapEmptyStatevue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + directives: { + CopyToClipboard: external_CoreHome_["CopyToClipboard"] + }, + props: { + // one of: noInteractions, deletedAuto, deletedManual, deletedPaused, deletedNoRetake + state: { + type: String, + required: true + }, + idSiteHsr: { + type: Number, + required: true + } + }, + computed: { + title() { + if (this.state === 'noInteractions') { + return Object(external_CoreHome_["translate"])('HeatmapSessionRecording_EmptyStateNoInteractionsTitle'); + } + return Object(external_CoreHome_["translate"])('HeatmapSessionRecording_EmptyStateSnapshotDeletedTitle'); + }, + subtitle() { + switch (this.state) { + case 'noInteractions': + return Object(external_CoreHome_["translate"])('HeatmapSessionRecording_EmptyStateNoInteractionsSubtitle'); + case 'deletedManual': + return Object(external_CoreHome_["translate"])('HeatmapSessionRecording_EmptyStateSnapshotDeletedManualSubtitle'); + case 'deletedPaused': + return Object(external_CoreHome_["translate"])('HeatmapSessionRecording_EmptyStateSnapshotDeletedPausedSubtitle'); + case 'deletedNoRetake': + return Object(external_CoreHome_["translate"])('HeatmapSessionRecording_EmptyStateSnapshotDeletedEndedSubtitle'); + default: + return Object(external_CoreHome_["translate"])('HeatmapSessionRecording_EmptyStateSnapshotDeletedAutoSubtitle'); + } + }, + captureCommand() { + return `_paq.push(['HeatmapSessionRecording::captureInitialDom', ${this.idSiteHsr}])`; + }, + guideUrl() { + // externalRawLink adds the matomo.org campaign parameters to the FAQ URL. + return Object(external_CoreHome_["externalRawLink"])(GUIDE_URL); + } + } +})); +// CONCATENATED MODULE: ./plugins/HeatmapSessionRecording/vue/src/HeatmapVis/HeatmapEmptyState.vue?vue&type=script&lang=ts + +// CONCATENATED MODULE: ./plugins/HeatmapSessionRecording/vue/src/HeatmapVis/HeatmapEmptyState.vue + + + +HeatmapEmptyStatevue_type_script_lang_ts.render = HeatmapEmptyStatevue_type_template_id_7a3200ad_render + +/* harmony default export */ var HeatmapEmptyState = (HeatmapEmptyStatevue_type_script_lang_ts); +// 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/HeatmapSessionRecording/vue/src/HeatmapVis/HeatmapVis.vue?vue&type=script&lang=ts + + + + + + + + + + +const { + $ +} = window; +const deviceDesktop = 1; +const deviceTablet = 2; +const deviceMobile = 3; +// Summary card ranking thresholds: how many candidate elements we track before +// re-aggregating clicks by bounding box, how many we ultimately display, and the +// maximum length of an element label before it is truncated. +const MAX_TRACKED_CLICK_ELEMENTS = 15; +const MAX_TOP_CLICKED_ELEMENTS = 5; +const MAX_CLICKED_ELEMENT_LABEL_LENGTH = 60; +// Measured space the environment's vertical scrollbar actually consumes in +// the frame (see getScrollbarGutterWidth); cached for the page's lifetime. +let measuredScrollbarGutter = null; +// Folds at/above this share of the page height don't get a fold line: they +// mean the page was (nearly) fully visible in the visitors' viewports, where +// "nearly" absorbs the tracker's document-height padding from body margins +// and scrollbars (a fully-seen page typically tracks ~99%, not 100%). +const FOLD_LINE_MAX_PERCENT = 97; +// The summary's "Bottom" band tolerates the same padding: a visitor whose +// viewport covered the whole page tracks a depth just short of 1000‰, and +// requiring exactly 1000 would report Bottom 0% for a fully-seen page. +const SCROLL_REACH_BOTTOM_MARK = FOLD_LINE_MAX_PERCENT * 10; +// Elements the summary card prefers to attribute clicks to; clicks on their +// descendants roll up to them, and their full descendant text is a safe label. +const INTERACTIVE_ELEMENTS_SELECTOR = 'a, button, input, select, textarea, label, summary, [role="button"], [role="link"], [tabindex]'; +// Structural/landmark elements only surface in the summary card through the +// container fallback and otherwise have no descriptive label, so they get a +// semantic type. The generic "table" reuses the shared core translation. +const STRUCTURAL_TYPE_KEYS = { + nav: 'HeatmapSessionRecording_ElementTypeNavigation', + ul: 'HeatmapSessionRecording_ElementTypeList', + ol: 'HeatmapSessionRecording_ElementTypeList', + form: 'HeatmapSessionRecording_ElementTypeForm', + header: 'HeatmapSessionRecording_ElementTypeHeader', + footer: 'HeatmapSessionRecording_ElementTypeFooter', + main: 'HeatmapSessionRecording_ElementTypeMainContent', + aside: 'HeatmapSessionRecording_ElementTypeSidebar', + section: 'HeatmapSessionRecording_ElementTypeSection', + article: 'HeatmapSessionRecording_ElementTypeArticle', + table: 'General_Table' +}; +// Container tags excluded from the preferred (interactive-only) pass when they +// have child elements: derived from the structural map above so the two can +// never drift apart, plus containers that have no semantic label of their own. +const GENERIC_CONTAINER_TAGS = Object.keys(STRUCTURAL_TYPE_KEYS).concat(['div', 'li', 'td', 'th', 'tr', 'tbody', 'thead']); +let heightPerHeatmap = 32000; +const userAgent = String(window.navigator.userAgent).toLowerCase(); +if (userAgent.match(/(iPod|iPhone|iPad|Android|IEMobile|Windows Phone)/i)) { + heightPerHeatmap = 2000; +} else if (userAgent.indexOf('msie ') > 0 || userAgent.indexOf('trident/') > 0 || userAgent.indexOf('edge') > 0) { + heightPerHeatmap = 8000; +} +function initHeatmap(recordingPlayer, heatmapContainer, +// eslint-disable-next-line @typescript-eslint/no-explicit-any +recordingIframe) { + const $iframe = $(recordingPlayer); + // we first set the iframe to the initial 400px again so we can for sure detect the current + // height of the inner iframe body correctly + $iframe.css('height', '400px'); + const documentHeight = recordingIframe.getIframeHeight(); + $iframe.css('height', `${documentHeight}px`); + $(heatmapContainer).css('height', `${documentHeight}px`).css('width', `${$iframe.width()}px`).empty(); + const numHeatmaps = Math.ceil(documentHeight / heightPerHeatmap); + for (let i = 1; i <= numHeatmaps; i += 1) { + let height = heightPerHeatmap; + if (i === numHeatmaps) { + height = documentHeight % heightPerHeatmap; + } + $(heatmapContainer).append(`
    `); + $(heatmapContainer).find(`#heatmap${i}`).css({ + height: `${height}px` + }); + } + return numHeatmaps; +} +function scrollHeatmap(iframeRecordingContainer, recordingPlayer, +// eslint-disable-next-line @typescript-eslint/no-explicit-any +recordingIframe, scrollReaches) { + const $iframe = $(recordingPlayer); + // we first set the iframe to the initial 400px again so we can for sure detect the current + // height of the inner iframe body correctly + $iframe.css('height', '400px'); + const documentHeight = recordingIframe.getIframeHeight(); + $iframe.css('height', `${documentHeight}px`); + const numIntervals = 1000; + const heightToIntervalRatio = documentHeight / numIntervals; + const numViewersTotal = scrollReaches.reduce((pv, cv) => pv + parseInt(cv.value, 10), 0); + const buckets = []; + let num_viewers = 0; + let lastBucket = null; + let percentage = 100; + let reachScrolledFromPosition = 0; + // reachScrolledFromPosition we start from 0, and then always paint to the next bucket. eg when + // scrollReach is 27 and scrollDepth is 35, then we know that 27 people have scrolled down to + // 3.5% of the page. + scrollReaches.forEach(scrollReachObj => { + // the number of people that reached this point + const scrollReach = parseInt(scrollReachObj.value, 10); + // how far down they scrolled + const scrollDepth = parseInt(scrollReachObj.label, 10); + const reachScrolledToPosition = Math.round(scrollDepth * heightToIntervalRatio); + if (lastBucket && lastBucket.position === reachScrolledToPosition) { + // when page is < 1000 we need to aggregate buckets + num_viewers += scrollReach; + } else { + if (numViewersTotal !== 0) { + percentage = (numViewersTotal - num_viewers) / numViewersTotal * 100; + } + num_viewers += scrollReach; + // percentage.toFixed(1) * 10 => convert eg 99.8 => 998 + lastBucket = { + percentageValue: parseFloat(percentage.toFixed(1)) * 10, + position: reachScrolledFromPosition, + percent: percentage.toFixed(1) + }; + buckets.push(lastBucket); + } + reachScrolledFromPosition = reachScrolledToPosition; + }); + if (buckets.length) { + // we need to make sure to draw scroll heatmap over full page + const found = buckets.some(b => b.position === 0); + if (!found) { + buckets.unshift({ + percent: '100.0', + percentageValue: 1000, + position: 0 + }); + } + } else { + // we'll show full page as not scrolled + buckets.push({ + percent: '0', + percentageValue: 0, + position: 0 + }); + } + let minValue = 0; + const maxValue = 1000; // max value is always 1000 (=100%) + if (buckets && buckets.length && buckets[0]) { + minValue = buckets[buckets.length - 1].percentageValue; + } + const iframeWidth = $iframe.width(); + let nextBucket = null; + for (let index = 0; index < buckets.length; index += 1) { + const bucket = buckets[index]; + if (buckets[index + 1]) { + nextBucket = buckets[index + 1]; + } else { + nextBucket = { + position: documentHeight + }; + } + const top = bucket.position; + let height = nextBucket.position - bucket.position; + if (height === 0) { + height = 1; // make sure to draw at least one px + } + const percent = `${bucket.percent} percent reached this point`; + const colorValues = interpolateGradientColor(bucket.percentageValue, minValue, maxValue); + const color = `rgb(${colorValues.join(',')})`; + $(iframeRecordingContainer).append(`
    `); + } + $('.scrollHeatmapLeaf', iframeRecordingContainer).tooltip({ + track: true, + items: '*', + tooltipClass: 'heatmapTooltip', + show: false, + hide: false + }); +} +function actualRenderHeatmap(recordingPlayer, heatmapContainer, +// eslint-disable-next-line @typescript-eslint/no-explicit-any +recordingIframe, dataPoints) { + const numHeatmaps = initHeatmap(recordingPlayer, heatmapContainer, recordingIframe); + const heatmapInstances = []; + for (let i = 1; i <= numHeatmaps; i += 1) { + const dpoints = { + min: dataPoints.min, + max: dataPoints.max, + data: [] + }; + const config = { + container: document.getElementById(`heatmap${i}`), + radius: 10, + maxOpacity: 0.5, + minOpacity: 0, + blur: 0.75, + gradient: toHeatmapJsGradient() + }; + if (dataPoints && dataPoints.data && dataPoints.data.length >= 20000) { + config.radius = 8; + } else if (dataPoints && dataPoints.data && dataPoints.data.length >= 2000) { + config.radius = 9; + } + if (numHeatmaps === 1) { + dpoints.data = dataPoints.data; + } else { + const lowerLimit = (i - 1) * heightPerHeatmap; + const upperLimit = lowerLimit + heightPerHeatmap - 1; + dataPoints.data.forEach(dp => { + if (dp.y >= lowerLimit && dp.y <= upperLimit) { + const thePoint = Object.assign(Object.assign({}, dp), {}, { + y: dp.y - lowerLimit + }); + dpoints.data.push(thePoint); + } + }); + } + const heatmapInstance = heatmap_default.a.create(config); + // heatmap type requires value to be number, but matomo sets it as string + heatmapInstance.setData(dpoints); + heatmapInstances.push(heatmapInstance); + } + return heatmapInstances; +} +async function backgroundRenders(url, ownerDoc) { + const probe = ownerDoc.createElement('img'); + probe.src = url; + try { + await probe.decode(); + return true; + } catch (e) { + return false; + } +} +/* harmony default export */ var HeatmapVisvue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + props: { + idSiteHsr: { + type: Number, + required: true + }, + deviceTypes: { + type: Array, + required: true + }, + heatmapTypes: { + type: Array, + required: true + }, + breakpointMobile: { + type: Number, + required: true + }, + breakpointTablet: { + type: Number, + required: true + }, + offsetAccuracy: { + type: Number, + required: true + }, + heatmapPeriod: { + type: String, + required: true + }, + heatmapDate: { + type: String, + required: true + }, + url: { + type: String, + required: true + }, + isActive: Boolean, + numSamples: { + type: Object, + required: true + }, + excludedElements: { + type: String, + required: true + }, + createdDate: { + type: String, + required: true + }, + createdDateRaw: { + type: String, + required: true + }, + heatmapName: { + type: String, + required: true + }, + desktopPreviewSize: { + type: Number, + required: true + }, + iframeResolutionsValues: { + type: Object, + required: true + }, + imageProxyNonce: { + type: String, + default: '' + }, + // Whether a snapshot (page tree mirror) exists; when false the empty state renders instead. + hasSnapshot: { + type: Boolean, + default: true + }, + // Whether the heatmap only captures its snapshot manually (drives the empty-state text). + captureManually: Boolean, + // Whether a snapshot was previously deleted (tells "deleted" apart from "never captured"). + snapshotDeleted: Boolean, + // Whether the heatmap is paused (resumable) rather than ended (permanent); the deleted + // empty state tells the user capture resumes once the heatmap is resumed. + isPaused: Boolean + }, + components: { + Field: external_CorePluginsAdmin_["Field"], + Tooltip: Tooltip, + HeatmapEmptyState: HeatmapEmptyState + }, + data() { + return { + isLoading: false, + isExporting: false, + iframeWidth: this.desktopPreviewSize, + customIframeWidth: this.desktopPreviewSize, + avgFold: 0, + summaryAvgFold: 0, + heatmapType: this.heatmapTypes[0].key, + deviceType: this.deviceTypes[0].key, + iframeResolutions: this.iframeResolutionsValues, + actualNumSamples: this.numSamples, + dataCoordinates: [], + currentElement: null, + totalClicks: 0, + topClickedElements: [], + scrollReach: [], + fetchGeneration: 0, + scrollAreaMaxHeight: 0, + previewScale: 1, + recordingHeight: 0, + summaryCardHeight: 0, + frameNeedsVScroll: true, + tooltipShowTimeoutId: null, + clickCount: 0, + clickRate: 0 + }; + }, + setup(props) { + const tooltip = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])(null); + let iframeLoadedResolve = null; + const iframeLoadedPromise = new Promise(resolve => { + iframeLoadedResolve = resolve; + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let recordingIframe = null; + const getRecordingIframe = recordingPlayer => { + if (!recordingIframe) { + recordingIframe = getIframeWindow(recordingPlayer).recordingFrame; + recordingIframe.excludeElements(props.excludedElements); + recordingIframe.addClass('html', 'piwikHeatmap'); + recordingIframe.addClass('html', 'matomoHeatmap'); + recordingIframe.addWorkaroundForSharepointHeatmaps(); + } + return recordingIframe; + }; + const heatmapInstances = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])(null); + const renderHeatmap = (recordingPlayer, heatmapContainer, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + theRecordingIframe, dataPoints) => { + heatmapInstances.value = actualRenderHeatmap(recordingPlayer, heatmapContainer, theRecordingIframe, dataPoints); + }; + return { + iframeLoadedPromise, + onLoaded: iframeLoadedResolve, + getRecordedHeatmap: oneAtATime('HeatmapSessionRecording.getRecordedHeatmap'), + getRecordedClickHeatmap: oneAtATime('HeatmapSessionRecording.getRecordedHeatmap'), + getRecordedScrollHeatmap: oneAtATime('HeatmapSessionRecording.getRecordedHeatmap'), + getRecordedHeatmapMetadata: oneAtATime('HeatmapSessionRecording.getRecordedHeatmapMetadata'), + getRecordingIframe, + heatmapInstances, + renderHeatmap, + tooltip + }; + }, + created() { + if (this.iframeResolutions.indexOf(this.breakpointMobile) === -1) { + this.iframeResolutions.push(this.breakpointMobile); + } + if (this.iframeResolutions.indexOf(this.breakpointTablet) === -1) { + this.iframeResolutions.push(this.breakpointTablet); + } + this.iframeResolutions = this.iframeResolutions.sort((a, b) => a - b); + this.fetchHeatmap(); + // Hide the period selector since we don't filter the heatmap by period + external_CoreHome_["Matomo"].postEvent('hidePeriodSelector'); + }, + mounted() { + this.onWindowResize(); + window.addEventListener('resize', this.onWindowResize); + }, + updated() { + // The summary card grows as its async data lands; keep the frame's + // height floor in sync (guarded internally against re-render loops). + this.updateSummaryCardHeight(); + }, + emits: ['exporting'], + watch: { + isExporting() { + // let the parent (settings dropdown) reflect the export progress + this.$emit('exporting', this.isExporting); + }, + isLoading() { + if (this.isLoading === true) { + return; + } + // The chrome above/below the scroll area can change once the report has + // rendered (toolbar wrapping, the delete-screenshot button appearing). + this.$nextTick(() => this.onWindowResize()); + const heatmapContainer = window.document.getElementById('heatmapContainer'); + if (!heatmapContainer) { + return; + } + heatmapContainer.addEventListener('mouseleave', event => { + // Stop processing tooltip when moving mouse out of parent element + if (this.tooltipShowTimeoutId) { + clearTimeout(this.tooltipShowTimeoutId); + this.tooltipShowTimeoutId = null; + } + // Reset the highlight and tooltip when leaving the container + this.currentElement = null; + this.handleTooltip(event, 0, 0, 'hide'); + const highlightDiv = window.document.getElementById('highlightDiv'); + if (!highlightDiv) { + return; + } + highlightDiv.hidden = true; + }); + heatmapContainer.addEventListener('mousemove', e => { + this.handleMouseMove(e); + }); + } + }, + beforeUnmount() { + window.removeEventListener('resize', this.onWindowResize); + this.removeScrollHeatmap(); + }, + methods: { + onWindowResize() { + // Order matters: the scale (and its scrollbar decision) reads the cap + // and the card height measured by the two calls before it. + this.updateScrollAreaMaxHeight(); + this.updateSummaryCardHeight(); + this.updatePreviewScale(); + }, + // How much layout width the environment's vertical scrollbar takes inside + // the frame: ~10px (the styled webkit width) with classic scrollbars, + // 0 where scrollbars overlay or are hidden (macOS default, headless + // screenshot runs) — subtracting a fixed allowance there left a hole to + // the card's right. Probed once with the frame's own styling. + getScrollbarGutterWidth() { + if (measuredScrollbarGutter === null) { + const probe = document.createElement('div'); + probe.className = 'heatmapScrollArea'; + probe.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;' + 'width:100px;height:100px;border:0;padding:0;overflow-y:scroll;'; + document.body.appendChild(probe); + measuredScrollbarGutter = Math.max(0, probe.offsetWidth - probe.clientWidth); + probe.remove(); + } + return measuredScrollbarGutter; + }, + // Height of the summary card while it sits beside the frame (row layout); + // 0 in the stacked layout, where flooring the frame to the card would + // only inflate it. Measured rather than computed because the card's + // height depends on its async-loaded content; also re-measured from + // updated() so content arriving later is picked up. + updateSummaryCardHeight() { + const area = this.$refs.heatmapScrollArea; + const layout = area === null || area === void 0 ? void 0 : area.parentElement; + const card = layout === null || layout === void 0 ? void 0 : layout.querySelector('.heatmapSummaryCard'); + if (!layout || !card) { + return; + } + const stacked = window.getComputedStyle(layout).flexDirection === 'column'; + const height = stacked ? 0 : Math.round(card.getBoundingClientRect().height); + // Guarded assignment: this runs from updated(), so writing an unchanged + // value would re-render in a loop. + if (height !== this.summaryCardHeight) { + this.summaryCardHeight = height; + } + }, + // Fits a preview wider than the viewing area by scaling it down: the + // recorded page keeps its true layout width inside the iframe (so the + // heatmap data stays aligned with the elements it was recorded against) + // and the rendered result shrinks uniformly, like the replay player. + // Narrower previews stay at 1:1 and are centered instead. + updatePreviewScale() { + const area = this.$refs.heatmapScrollArea; + const layout = area === null || area === void 0 ? void 0 : area.parentElement; + if (!area || !layout || !layout.clientWidth || !this.iframeWidth) { + return; + } + // The frame hugs the (scaled) snapshot, so its own width cannot drive + // the scale (it *is* the scale). The available width is the layout + // row's, minus the summary card and the row gap beside it — the frame + // may only grow as far as the card still fits — minus the frame's + // borders. + const layoutStyles = window.getComputedStyle(layout); + const areaStyles = window.getComputedStyle(area); + const stacked = layoutStyles.flexDirection === 'column'; + const card = layout.querySelector('.heatmapSummaryCard'); + const cardSpace = !stacked && card ? card.getBoundingClientRect().width + (parseFloat(layoutStyles.columnGap || '0') || 0) : 0; + const borders = (parseFloat(areaStyles.borderLeftWidth || '0') || 0) + (parseFloat(areaStyles.borderRightWidth || '0') || 0); + const available = layout.clientWidth - cardSpace - borders; + if (available <= 0) { + return; + } + const round4 = value => Math.round(value * 10000) / 10000; + // Two candidate scales, decided from first principles rather than live + // scrollbar measurements (which would oscillate at the boundary: hiding + // the scrollbar widens the area, raising the scale, which can re-need + // the scrollbar). If the preview fits the frame's height without the + // scrollbar's gutter, no vertical scrolling is possible and the gutter + // is dropped entirely; otherwise the scale accounts for the gutter. + const scaleWithoutGutter = Math.min(1, round4(available / this.iframeWidth)); + const frameHeight = this.scrollAreaMaxHeight ? Math.max(Math.min(this.scrollAreaMaxHeight, this.recordingHeight || Infinity), this.summaryCardHeight) : Infinity; + const fits = !this.recordingHeight || scaleWithoutGutter * this.recordingHeight <= frameHeight; + this.frameNeedsVScroll = !fits; + this.previewScale = fits ? scaleWithoutGutter : Math.min(1, round4((available - this.getScrollbarGutterWidth()) / this.iframeWidth)); + }, + // Caps the scroll area so the report's bottom edge lands at the bottom of + // the window (16px gap): the recording scrolls inside the frame while the + // content below it (e.g. the delete-screenshot button) stays visible. + // Measured live rather than a calc(100vh - X) constant because the + // surrounding chrome varies (toolbar wrapping, notifications, admin vs + // widgetized page headers). + updateScrollAreaMaxHeight() { + const area = this.$refs.heatmapScrollArea; + const root = this.$el; + if (!area || !(root !== null && root !== void 0 && root.getBoundingClientRect)) { + return; + } + const layout = area.parentElement; + const areaRect = area.getBoundingClientRect(); + // Content that must stay visible below the frame (the delete-screenshot + // block), measured from the layout row's bottom rather than the area's + // own: the row's bottom tracks its tallest child, so this stays + // constant — and recomputation stable — even when the summary card + // outgrows the capped area. + const belowChrome = layout ? root.getBoundingClientRect().bottom - layout.getBoundingClientRect().bottom : 0; + // The area's top as if the page were scrolled to the top, so the cap is + // stable no matter when this runs. + const areaTop = areaRect.top + window.scrollY; + this.scrollAreaMaxHeight = Math.max(400, window.innerHeight - areaTop - belowChrome - 16); + }, + // The metadata endpoint returns sample counts as strings (HeatmapMetadata's + // `number` notwithstanding), so anything doing arithmetic on them must + // coerce first: summing raw values concatenates ("0" + "2" + "0" -> "020") + // and silently produces absurd device shares. + getDeviceSamples(deviceKey) { + return parseInt(String(this.actualNumSamples[`nb_samples_device_${deviceKey}`]), 10) || 0; + }, + resolveClickedElement( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + recordingIframe, selector) { + const $element = recordingIframe.findElement(selector); + const rawElement = $element && $element[0] ? $element[0] : null; + if (!rawElement) { + return null; + } + // Resolve permissively (containers allowed) but remember whether the click + // also resolves to a preferred, non-container element, so a single pass can + // serve both the interactive-only and the container-fallback rankings. + const element = this.normalizeClickedElement(rawElement, true); + if (!element) { + return null; + } + return { + key: this.getElementKey(element), + label: this.getElementLabel(element, selector), + boundingRectangle: element.getBoundingClientRect(), + isPreferred: this.normalizeClickedElement(rawElement, false) !== null + }; + }, + normalizeClickedElement(element, includeContainers) { + const interactiveParent = element.closest(INTERACTIVE_ELEMENTS_SELECTOR); + if (interactiveParent) { + return interactiveParent; + } + const wrapped = this.getWrappedInteractiveElement(element); + if (wrapped) { + return wrapped; + } + if (!this.isUsefulClickedElement(element, includeContainers)) { + return null; + } + return element; + }, + // A presentational wrapper around exactly one interactive element (an image + // inside a link, a button padded by a div) credits that element - the mirror + // of the ancestor roll-up above. Landmark containers (nav, section, aside...) + // keep their own identity, and a wrapper with its own text is more than a + // pass-through, so both are left alone. + getWrappedInteractiveElement(element) { + const tagName = element.tagName.toLowerCase(); + if (STRUCTURAL_TYPE_KEYS[tagName] || this.getElementText(element)) { + return null; + } + const interactive = element.querySelectorAll(INTERACTIVE_ELEMENTS_SELECTOR); + return interactive.length === 1 ? interactive[0] : null; + }, + isUsefulClickedElement(element, includeContainers) { + const tagName = element.tagName.toLowerCase(); + if (['html', 'body'].indexOf(tagName) !== -1) { + return false; + } + if (/^h[1-6]$/.test(tagName)) { + return true; + } + // When too few interactive elements were found, generic containers are + // allowed as a fallback so the summary card is not left mostly empty. + if (includeContainers) { + return true; + } + return !(GENERIC_CONTAINER_TAGS.indexOf(tagName) !== -1 && element.children.length); + }, + getElementKey(element) { + const id = element.getAttribute('id'); + if (id) { + return `${element.tagName.toLowerCase()}#${id}`; + } + const name = element.getAttribute('name'); + if (name) { + return `${element.tagName.toLowerCase()}[name="${name}"]`; + } + // No stable attribute: derive a deterministic path from the resolved element + // itself. The recorded click selector varies by which child node was hit, so + // keying on it would split repeated clicks on the same element into separate + // entries; a path built from the element merges them. + return this.getElementPath(element); + }, + getElementPath(element) { + const segments = []; + let current = element; + while (current) { + const tagName = current.tagName.toLowerCase(); + if (tagName === 'body' || tagName === 'html') { + break; + } + const id = current.getAttribute('id'); + if (id) { + segments.unshift(`${tagName}#${id}`); + break; + } + const parent = current.parentElement; + if (!parent) { + segments.unshift(tagName); + break; + } + const sameTag = current.tagName; + const index = Array.from(parent.children).filter(child => child.tagName === sameTag).indexOf(current) + 1; + segments.unshift(`${tagName}:nth-of-type(${index})`); + current = parent; + } + return segments.join('>') || element.tagName.toLowerCase(); + }, + getElementLabel(element, selector) { + const tagName = element.tagName.toLowerCase(); + const htmlElementType = element.getAttribute('type'); + const rawLabel = [this.getElementText(element), element.getAttribute('aria-label'), element.getAttribute('title'), element.getAttribute('alt'), element.getAttribute('placeholder'), element.getAttribute('name'), element.getAttribute('id'), htmlElementType === 'button' ? element.getAttribute('value') || '' : '', this.getDescendantImageLabel(element), selector, tagName].map(value => (value || '').replace(/\s+/g, ' ').trim()).find(value => !!value) || tagName; + const label = rawLabel.length > MAX_CLICKED_ELEMENT_LABEL_LENGTH ? `${rawLabel.slice(0, MAX_CLICKED_ELEMENT_LABEL_LENGTH - 3)}...` : rawLabel; + const type = this.getElementTypeLabel(element); + return type ? Object(external_CoreHome_["translate"])('HeatmapSessionRecording_TopClickedElementLabel', label, type) : label; + }, + getElementText(element) { + // An interactive element owns all its descendant text (Buy + // now), so the full text is its visible label. Containers and + // headings only use direct text nodes: labelling a nav by the text of its + // first link would be misleading. + const text = element.matches(INTERACTIVE_ELEMENTS_SELECTOR) ? element.textContent || '' : [...element.childNodes].filter(node => node.nodeType === Node.TEXT_NODE).map(node => node.textContent || '').join(' '); + return text.replace(/\s+/g, ' ').trim(); + }, + // An interactive element whose only content is an image (a thumbnail link, + // an icon button) carries no text of its own, so the image's alt/title is + // the natural label. Used as a fallback before the raw selector. + getDescendantImageLabel(element) { + const img = element.querySelector('img'); + if (!img) { + return ''; + } + return (img.getAttribute('alt') || img.getAttribute('title') || '').replace(/\s+/g, ' ').trim(); + }, + getElementTypeLabel(element) { + const tagName = element.tagName.toLowerCase(); + const role = element.getAttribute('role'); + const htmlElementType = element.getAttribute('type'); + // An image, or an interactive element whose only content is an image (a + // linked thumbnail), reads to a visitor as an image. Calling it a "link" + // would misdirect them to the separate text link that often sits beside it. + if (tagName === 'img' || element.matches(INTERACTIVE_ELEMENTS_SELECTOR) && !this.getElementText(element) && element.querySelector('img')) { + return Object(external_CoreHome_["translate"])('HeatmapSessionRecording_ElementTypeImage'); + } + if (tagName === 'a' || role === 'link') { + return Object(external_CoreHome_["translate"])('HeatmapSessionRecording_ElementTypeLink'); + } + if (tagName === 'button' || role === 'button' || htmlElementType === 'button') { + return Object(external_CoreHome_["translate"])('HeatmapSessionRecording_ElementTypeButton'); + } + if (/^h[1-6]$/.test(tagName)) { + return Object(external_CoreHome_["translate"])('HeatmapSessionRecording_ElementTypeHeading'); + } + if (['input', 'select', 'textarea'].indexOf(tagName) !== -1) { + return Object(external_CoreHome_["translate"])('HeatmapSessionRecording_ElementTypeField'); + } + const typeKey = STRUCTURAL_TYPE_KEYS[tagName]; + return typeKey ? Object(external_CoreHome_["translate"])(typeKey) : ''; + }, + formatTopClickedElements(clickedElements, totalClicks) { + return [...clickedElements.values()].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)).slice(0, MAX_TOP_CLICKED_ELEMENTS).map(element => { + const percent = totalClicks ? Math.round(element.count / totalClicks * 100) : 0; + return Object.assign(Object.assign({}, element), {}, { + percent, + percentLabel: external_CoreHome_["NumberFormatter"].formatPercent(percent) + }); + }); + }, + getScrollReach(rows) { + const total = rows.reduce((sum, row) => sum + (parseInt(row.value, 10) || 0), 0); + // No scroll data: return no bands so the empty state renders (rather than a + // ladder of 0% rows). + if (!total) { + return []; + } + // Cumulative reach at 0/25/50/75/100% of the page (row.label is the max + // scroll depth on a 0-1000 scale). "Top" counts every visit; each following + // row counts visits that scrolled at least that far; "Bottom" is visits + // that reached the very end of the page. + const marks = [{ + key: 'top', + mark: 0, + label: Object(external_CoreHome_["translate"])('HeatmapSessionRecording_ScrollReachTop') + }, { + key: 'q25', + mark: 250, + label: external_CoreHome_["NumberFormatter"].formatPercent(25) + }, { + key: 'q50', + mark: 500, + label: external_CoreHome_["NumberFormatter"].formatPercent(50) + }, { + key: 'q75', + mark: 750, + label: external_CoreHome_["NumberFormatter"].formatPercent(75) + }, { + key: 'bottom', + mark: SCROLL_REACH_BOTTOM_MARK, + label: Object(external_CoreHome_["translate"])('HeatmapSessionRecording_ScrollReachBottom') + }]; + return marks.map(({ + key, + mark, + label + }) => { + const reached = rows.reduce((sum, row) => (parseInt(row.label, 10) || 0) >= mark ? sum + (parseInt(row.value, 10) || 0) : sum, 0); + const percent = Math.round(reached / total * 100); + return { + key, + label, + percent, + percentLabel: external_CoreHome_["NumberFormatter"].formatPercent(percent) + }; + }); + }, + // True while `generation` is still the most recent fetchHeatmap call. The + // summary-card requests run on their own oneAtATime closures that the primary + // heatmap request never aborts, so their callbacks use this to drop a stale + // (superseded) response before it overwrites the card. + isCurrentFetch(generation) { + return generation === this.fetchGeneration; + }, + // Applies a background scroll-reach response, ignoring it when a newer fetch + // has superseded it. + applyScrollReach(rows, generation) { + if (!this.isCurrentFetch(generation)) { + return; + } + this.scrollReach = this.getScrollReach(rows); + }, + getHeatmapDataPoints(rows, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + recordingIframe) { + const dataPoints = { + min: 0, + max: 0, + data: [] + }; + let totalValue = 0; + let numEntriesHigherThan1 = 0; + for (let i = 0; i < rows.length; i += 1) { + const row = rows[i]; + if (row.selector) { + const value = parseInt(row.value, 10) || 0; + const dataPoint = recordingIframe.getCoordinatesInFrame(row.selector, row.offset_x, row.offset_y, this.offsetAccuracy, true); + if (dataPoint) { + dataPoint.value = row.value; + dataPoint.selector = row.selector; + dataPoints.data.push(dataPoint); + totalValue += value; + if (value > 1) { + numEntriesHigherThan1 += 1; + } + } + } + } + return { + dataPoints, + numEntriesHigherThan1, + totalValue + }; + }, + getTopClickedElements(dataPoints, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + recordingIframe, totalClicks) { + const candidates = this.collectClickCandidates(dataPoints, recordingIframe); + // Prefer interactive elements (links, buttons, fields...). Only fall back to + // including generic containers when too few preferred elements were clicked. + const preferred = new Map(Array.from(candidates.entries()).filter(([, candidate]) => candidate.isPreferred)); + const chosen = preferred.size >= MAX_TOP_CLICKED_ELEMENTS ? preferred : candidates; + return this.rankClickedElements(chosen, dataPoints, totalClicks); + }, + collectClickCandidates(dataPoints, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + recordingIframe) { + const candidates = new Map(); + const resolvedCache = new Map(); + // Single pass: resolve each clicked selector once (DOM lookups are the + // expensive part) and tally selector-based counts per resolved element. + dataPoints.data.forEach(dp => { + const value = parseInt(dp.value, 10) || 0; + if (!dp.selector || !value) { + return; + } + let resolved = resolvedCache.get(dp.selector); + if (!resolvedCache.has(dp.selector)) { + resolved = this.resolveClickedElement(recordingIframe, dp.selector); + resolvedCache.set(dp.selector, resolved); + } + if (!resolved) { + return; + } + const existing = candidates.get(resolved.key); + if (existing) { + existing.count += value; + } else { + candidates.set(resolved.key, Object.assign(Object.assign({}, resolved), {}, { + count: value, + percent: 0, + percentLabel: external_CoreHome_["NumberFormatter"].formatPercent(0) + })); + } + }); + return candidates; + }, + rankClickedElements(clickedElements, dataPoints, totalClicks) { + // Keep the top candidates. Capture their selector-based counts, then clear + // them so the geometric pass below can re-tally clicks by bounding box (to + // match the heatmap tooltip, which aggregates clicks landing in an element). + const reducedClickedElements = new Map(Array.from(clickedElements.entries()).sort((a, b) => b[1].count - a[1].count || a[1].label.localeCompare(b[1].label)).slice(0, MAX_TRACKED_CLICK_ELEMENTS)); + const selectorCounts = new Map(); + reducedClickedElements.forEach((element, key) => { + selectorCounts.set(key, element.count); + element.count = 0; + }); + // Attribute each click to exactly one candidate: the smallest one whose + // bounding box contains it. Candidates can nest (a link inside a nav kept + // by the container fallback), and crediting every containing box would + // inflate a container's count with its children's clicks. + const rankedCandidates = Array.from(reducedClickedElements.values()); + const boxArea = element => (element.boundingRectangle.right - element.boundingRectangle.left) * (element.boundingRectangle.bottom - element.boundingRectangle.top); + dataPoints.data.forEach(dp => { + const containing = rankedCandidates.filter(element => (dp === null || dp === void 0 ? void 0 : dp.x) >= element.boundingRectangle.left && (dp === null || dp === void 0 ? void 0 : dp.x) <= element.boundingRectangle.right && (dp === null || dp === void 0 ? void 0 : dp.y) >= element.boundingRectangle.top && (dp === null || dp === void 0 ? void 0 : dp.y) <= element.boundingRectangle.bottom); + if (!containing.length) { + return; + } + const target = containing.reduce((smallest, element) => boxArea(element) < boxArea(smallest) ? element : smallest); + target.count += parseInt(dp.value, 10) || 0; + }); + // If the geometric pass attributed no clicks to an element (e.g. a recorded + // offset that lands just outside a small element's box), fall back to its + // selector-based count so a genuinely clicked element is not dropped. + reducedClickedElements.forEach((element, key) => { + if (!element.count) { + element.count = selectorCounts.get(key) || 0; + } + }); + return this.formatTopClickedElements(new Map(Array.from(reducedClickedElements.entries()).sort((a, b) => b[1].count - a[1].count || a[1].label.localeCompare(b[1].label)).slice(0, MAX_TOP_CLICKED_ELEMENTS)), totalClicks); + }, + removeScrollHeatmap() { + const element = this.$refs.iframeRecordingContainer; + $(element).find('.scrollHeatmapLeaf').remove(); + }, + proxyImageUrl(originalUrl, nonce) { + const query = external_CoreHome_["MatomoUrl"].stringify({ + module: 'HeatmapSessionRecording', + action: 'getProxiedImage', + idSite: external_CoreHome_["Matomo"].idSite, + idSiteHsr: this.idSiteHsr, + url: originalUrl, + nonce + }); + // Resolve against the parent (Matomo) page so the URL is absolute and points at the Matomo + // origin. A relative `?...` would resolve against the iframe's base (the recorded site). + return new URL(`?${query}`, window.location.href).href; + }, + isCrossOriginUrl(url) { + if (!url || url.startsWith('data:')) { + return false; + } + try { + return new URL(url).origin !== window.location.origin; + } catch (e) { + return false; + } + }, + extractCrossOriginBackgroundUrls(background) { + const urls = []; + const regex = /url\(\s*['"]?([^'")]+)['"]?\s*\)/g; + let match = regex.exec(background); + while (match !== null) { + if (this.isCrossOriginUrl(match[1])) { + urls.push(match[1]); + } + match = regex.exec(background); + } + return urls; + }, + proxyCssBackground(background, nonce, confirmedUrls) { + return background.replace(/url\(\s*['"]?([^'")]+)['"]?\s*\)/g, (match, url) => this.isCrossOriginUrl(url) && confirmedUrls.has(url) ? `url("${this.proxyImageUrl(url, nonce)}")` : match); + }, + async exportToImage() { + if (!this.hasAdminAccess) { + return; + } + this.isExporting = true; + external_CoreHome_["NotificationsStore"].remove('hsrExportImage'); + const originalSrcs = []; + const originalBackgrounds = []; + const domRestores = []; + let originalIframeCss = null; + const heatmapContainer = this.$refs.heatmapContainer; + const previousZIndex = heatmapContainer.style.zIndex; + const foldLine = this.$refs.aboveFoldLine; + const previousFoldZIndex = foldLine.style.zIndex; + try { + var _this$heatmapTypes$fi, _this$deviceTypes$fin; + const iframe = this.$refs.recordingPlayer; + const doc = iframe.contentDocument; + if (!doc) { + return; + } + const nonce = this.imageProxyNonce; + // Keep the dot overlay, then the fold line, above the recorded page during capture. + heatmapContainer.style.zIndex = '2147483646'; + foldLine.style.zIndex = '2147483647'; + doc.querySelectorAll('svg').forEach(svg => { + if (!svg.hasAttribute('xmlns:xlink')) { + domRestores.push(() => svg.removeAttribute('xmlns:xlink')); + svg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink'); + } + }); + // Route cross-origin sources through the proxy so the capture isn't tainted, but + // only for images that actually rendered in the iframe. naturalWidth is 0 when the image + // was blocked by CSP, 404'd or otherwise failed, in which case there is nothing to proxy. + doc.querySelectorAll('img').forEach(img => { + if (!(img.complete && img.naturalWidth > 0)) { + return; + } + const rendered = img.currentSrc || img.src; + if (this.isCrossOriginUrl(rendered)) { + originalSrcs.push({ + img, + src: img.src, + srcset: img.srcset + }); + img.src = this.proxyImageUrl(rendered, nonce); + img.srcset = ''; + } else if (rendered !== img.src) { + originalSrcs.push({ + img, + src: img.src, + srcset: img.srcset + }); + img.src = rendered; + img.srcset = ''; + } + }); + // Same for cross-origin CSS background images, via an inline override we restore later. + // A background has no naturalWidth to inspect, so probe each candidate url with + // img.decode() and only proxy the ones that actually rendered. Anything we can't confirm + // loaded is left untouched. + const view = doc.defaultView || window; + const backgroundCandidates = []; + const candidateUrls = new Set(); + doc.querySelectorAll('*').forEach(el => { + const background = view.getComputedStyle(el).backgroundImage; + if (!background || !background.includes('url(')) { + return; + } + const urls = this.extractCrossOriginBackgroundUrls(background); + if (!urls.length) { + return; + } + backgroundCandidates.push({ + el, + background + }); + urls.forEach(url => candidateUrls.add(url)); + }); + const confirmedUrls = new Set(); + await Promise.all([...candidateUrls].map(async url => { + if (await backgroundRenders(url, doc)) { + confirmedUrls.add(url); + } + })); + backgroundCandidates.forEach(({ + el, + background + }) => { + const proxied = this.proxyCssBackground(background, nonce, confirmedUrls); + if (proxied !== background) { + originalBackgrounds.push({ + el, + background: el.style.backgroundImage + }); + el.style.backgroundImage = proxied; + } + }); + // html-to-image copies the