From 80028ea81da6daa2431ee57cbb34f521f973f604 Mon Sep 17 00:00:00 2001 From: diosmosis Date: Fri, 21 Aug 2026 22:54:41 -0700 Subject: [PATCH 1/6] upgrade core to 5.13.0 --- app/composer.json | 1 + app/composer.lock | 92 +- app/config/global.ini.php | 10 +- app/core/API/DataTableGenericFilter.php | 42 + .../API/DataTableManipulator/Flattener.php | 7 +- .../API/DataTableManipulator/LabelFilter.php | 4 +- .../ReportTotalsCalculator.php | 116 +- app/core/API/DataTablePostProcessor.php | 6 + app/core/API/Request.php | 12 + app/core/ArchiveProcessor.php | 4 +- .../ArchiveProcessor/BlobTableAggregator.php | 33 +- app/core/ArchiveProcessor/RecordBuilder.php | 70 +- app/core/Columns/Join.php | 12 + app/core/Columns/Join/GoalNameJoin.php | 5 + app/core/DataAccess/ArchiveSelector.php | 15 +- app/core/DataAccess/LogAggregator.php | 31 +- app/core/DataTable.php | 7 + app/core/Db/Schema/Mariadb.php | 9 +- app/core/Filesystem.php | 2 +- app/core/Http.php | 190 ++- app/core/Http/EgressBlockedException.php | 20 + app/core/Http/EgressHostValidator.php | 143 ++ app/core/Plugin/LogTablesProvider.php | 1 + app/core/Plugin/WidgetsProvider.php | 7 +- app/core/ReportRenderer.php | 15 + app/core/ReportRenderer/Pdf.php | 2 + app/core/Segment.php | 11 +- app/core/Session/SessionAuth.php | 5 +- app/core/Settings/Settings.php | 1 + app/core/SiteContentDetector.php | 37 +- app/core/Tracker/Db/Mysqli.php | 3 +- app/core/Tracker/Db/Pdo/Mysql.php | 3 +- app/core/Tracker/GoalManager.php | 16 + app/core/Twig.php | 8 +- app/core/Version.php | 2 +- app/core/ViewDataTable/Factory.php | 2 +- app/core/ViewDataTable/Manager.php | 2 +- app/core/Visualization/Sparkline.php | 39 +- app/jest.config.js | 3 + app/lang/en.json | 210 ++- app/lang/es.json | 9 +- app/lang/lv.json | 3 + app/lang/pt-br.json | 20 +- app/package-lock.json | 12 +- app/phpstan-baseline.neon | 85 -- .../AIProviders/AIConversationRequest.php | 251 ++++ .../AIProviders/AIConversationResponse.php | 142 ++ .../AIProviders/AIProviderResponse.php | 140 ++ app/plugins/AIProviders/AIProviderService.php | 335 +++++ app/plugins/AIProviders/AIProviders.php | 127 ++ app/plugins/AIProviders/AIProvidersList.php | 120 ++ app/plugins/AIProviders/AIRequest.php | 286 ++++ app/plugins/AIProviders/API.php | 145 ++ app/plugins/AIProviders/CanonicalMessage.php | 121 ++ app/plugins/AIProviders/Controller.php | 41 + .../Exception/AIProviderClientException.php | 20 + .../Exception/AIProviderException.php | 26 + .../Exception/AIProviderServerException.php | 20 + app/plugins/AIProviders/Menu.php | 41 + .../AIProviders/Model/Configuration.php | 702 +++++++++ .../AIProviders/Provider/AIProvider.php | 918 ++++++++++++ .../AIProviders/Provider/Anthropic.php | 286 ++++ app/plugins/AIProviders/Provider/Bedrock.php | 611 ++++++++ .../AIProviders/Provider/CustomProvider.php | 114 ++ app/plugins/AIProviders/Provider/Google.php | 357 +++++ app/plugins/AIProviders/Provider/OpenAI.php | 84 ++ app/plugins/AIProviders/README.md | 156 ++ app/plugins/AIProviders/templates/index.twig | 7 + .../AIProviders/vue/dist/AIProviders.css | 1 + .../AIProviders/vue/dist/AIProviders.umd.js | 811 +++++++++++ .../vue/dist/AIProviders.umd.min.js | 2 + .../AIProviders/vue/dist/umd.metadata.json | 6 + .../AIProviders/vue/src/ManageAIProviders.vue | 665 +++++++++ .../vue/src/components/ProviderCard.vue | 422 ++++++ app/plugins/AIProviders/vue/src/index.ts | 8 + app/plugins/AIProviders/vue/src/types.ts | 58 + app/plugins/API/API.php | 32 +- app/plugins/API/Controller.php | 7 +- app/plugins/Actions/API.php | 141 +- app/plugins/Actions/ArchivingHelper.php | 19 + .../Actions/RecordBuilders/ActionReports.php | 6 +- app/plugins/BotTracking/BotDetector.php | 10 +- .../BotTracking/Widgets/NoRecentRequests.php | 5 +- .../Widgets/NoRecentRequestsRealtime.php | 24 + app/plugins/CoreHome/CoreHome.php | 27 +- .../DataTableRowAction/RowEvolution.php | 8 + .../FeatureFlags/ReportHeaderRedesign.php | 19 - app/plugins/CoreHome/javascripts/dataTable.js | 4 + app/plugins/CoreHome/javascripts/sparkline.js | 21 +- .../stylesheets/dataTable/_dataTable.less | 8 + .../CoreHome/stylesheets/sparklineColors.less | 30 +- .../CoreHome/templates/_dataTableActions.twig | 1 + .../CoreHome/templates/_dataTableCell.twig | 34 +- .../CoreHome/templates/widgetContainer.twig | 15 +- app/plugins/CoreHome/vue/dist/CoreHome.umd.js | 380 ++++- .../CoreHome/vue/dist/CoreHome.umd.min.js | 118 +- .../vue/src/Comparisons/Comparisons.store.ts | 11 +- .../DataTableActions.component.spec.ts | 121 ++ .../vue/src/DataTable/DataTableActions.vue | 31 +- .../EnrichedHeadline/EnrichedHeadline.less | 2 + .../vue/src/ReportHeader/ReportHeader.less | 71 + .../vue/src/ReportHeader/ReportHeader.spec.ts | 113 ++ .../vue/src/ReportHeader/ReportHeader.vue | 136 ++ .../CoreHome/vue/src/Sparkline/Sparkline.vue | 9 +- .../src/WidgetControls/WidgetControls.less | 45 + .../src/WidgetControls/WidgetControls.spec.ts | 77 + .../vue/src/WidgetControls/WidgetControls.vue | 82 ++ app/plugins/CoreHome/vue/src/index.ts | 3 + app/plugins/CoreHome/vue/src/ucfirst.spec.ts | 46 + app/plugins/CoreHome/vue/src/ucfirst.ts | 19 + .../CoreVisualizations/CoreVisualizations.php | 20 +- .../FeatureFlags/SparklinesRedesign.php | 19 - .../JqplotDataGenerator/Chart.php | 15 +- .../JqplotDataGenerator/Evolution.php | 274 +++- .../JqplotDataGenerator/ForecastBuilder.php | 1277 +++++++++++++++++ .../ForecastMetricClassifier.php | 152 ++ .../ForecastSampleWindow.php | 79 + .../ForecastSeriesState.php | 102 ++ .../ForecastSeriesStateBuilder.php | 74 + .../ForecastSubPeriodFetcher.php | 784 ++++++++++ .../Metrics/MetricTotalsTreatment.php | 80 ++ .../Visualizations/HtmlTable.php | 76 + .../Visualizations/HtmlTable/Config.php | 9 + .../HtmlTable/RequestConfig.php | 11 +- .../Visualizations/JqplotGraph/Evolution.php | 67 + .../JqplotGraph/Evolution/Config.php | 25 +- .../Visualizations/Sparklines.php | 76 +- .../Visualizations/Sparklines/Config.php | 22 +- .../CoreVisualizations/javascripts/jqplot.js | 263 +++- .../javascripts/jqplotEvolutionGraph.js | 20 +- .../stylesheets/dataTableVisualizations.less | 15 + .../stylesheets/jqplot.less | 12 + .../templates/_dataTableViz_htmlTable.twig | 18 +- .../_dataTableViz_htmlTable_comparisons.twig | 24 +- .../_dataTableViz_htmlTable_ratio.twig | 13 +- .../templates/_dataTableViz_sparklines.twig | 3 +- .../CoreVisualizations/templates/macros.twig | 3 +- .../vue/dist/CoreVisualizations.umd.js | 790 ++++++++-- .../vue/dist/CoreVisualizations.umd.min.js | 8 +- .../src/EvolutionBadge/EvolutionBadge.less | 17 +- .../vue/src/MetricValue/MetricValue.less | 78 +- .../vue/src/MetricValue/MetricValue.spec.ts | 158 +- .../vue/src/MetricValue/MetricValue.vue | 62 +- .../src/SingleMetricView/SingleMetricView.vue | 4 +- .../vue/src/Sparklines/DateAtom.less | 15 + .../vue/src/Sparklines/DateAtom.spec.ts | 23 + .../vue/src/Sparklines/DateAtom.vue | 33 + .../vue/src/Sparklines/DateComparison.less | 28 + .../vue/src/Sparklines/DateComparison.spec.ts | 201 +++ .../vue/src/Sparklines/DateComparison.vue | 57 + .../vue/src/Sparklines/NoComparison.spec.ts | 30 +- .../vue/src/Sparklines/NoComparison.vue | 30 +- .../vue/src/Sparklines/PeriodColumns.less | 34 + .../vue/src/Sparklines/PeriodColumns.spec.ts | 98 ++ .../vue/src/Sparklines/PeriodColumns.vue | 105 ++ .../src/Sparklines/SegmentComparisonCard.less | 50 + .../Sparklines/SegmentComparisonCard.spec.ts | 225 +++ .../src/Sparklines/SegmentComparisonCard.vue | 108 ++ .../src/Sparklines/SegmentComparisonRow.less | 61 + .../Sparklines/SegmentComparisonRow.spec.ts | 172 +++ .../src/Sparklines/SegmentComparisonRow.vue | 75 + .../vue/src/Sparklines/SparklineCard.less | 100 +- .../vue/src/Sparklines/SparklineCard.spec.ts | 61 +- .../vue/src/Sparklines/SparklineCard.vue | 83 +- .../src/Sparklines/sparklineDataAttrs.spec.ts | 71 + .../vue/src/Sparklines/sparklineDataAttrs.ts | 55 + .../vue/src/Sparklines/types.ts | 19 + .../src/SparklinesGrid/SparklinesGrid.less | 105 +- .../src/SparklinesGrid/SparklinesGrid.spec.ts | 210 ++- .../vue/src/SparklinesGrid/SparklinesGrid.vue | 98 +- .../CoreVue/polyfills/dist/MatomoPolyfills.js | 2 +- .../polyfills/dist/MatomoPolyfills.min.js | 4 +- app/plugins/CoreVue/types/plugin-modules.d.ts | 30 + app/plugins/Dashboard/Dashboard.php | 33 +- .../Dashboard/javascripts/dashboardWidget.js | 91 +- .../Dashboard/javascripts/widgetMenu.js | 12 +- app/plugins/Dashboard/stylesheets/widget.less | 40 +- .../templates/_widgetFactoryTemplate.twig | 29 +- .../Dashboard/vue/dist/Dashboard.umd.js | 35 +- .../Dashboard/vue/dist/Dashboard.umd.min.js | 6 +- .../src/AddWidgetModal/AddWidgetModal.spec.ts | 1 + .../src/AddWidgetModal/WidgetPreview.spec.ts | 1 + .../vue/src/AddWidgetModal/WidgetPreview.vue | 20 +- .../Ecommerce/Columns/BaseConversion.php | 7 + app/plugins/Ecommerce/Controller.php | 1 + app/plugins/Goals/API.php | 7 +- app/plugins/Goals/Goals.php | 1 + app/plugins/Goals/vue/dist/Goals.umd.js | 32 +- app/plugins/Goals/vue/dist/Goals.umd.min.js | 4 +- .../Goals/vue/src/ManageGoals/ManageGoals.vue | 31 + app/plugins/ImageGraph/API.php | 13 + .../Tracker/RequestProcessor.php | 10 +- app/plugins/Live/Reports/GetLastVisits.php | 1 + app/plugins/Login/Controller.php | 15 +- .../Login/Emails/PasswordResetEmail.php | 8 +- app/plugins/Login/Login.php | 9 +- app/plugins/Login/PasswordResetter.php | 81 +- app/plugins/Login/PasswordVerifier.php | 5 +- .../templates/_passwordResetHtmlEmail.twig | 6 + .../templates/_passwordResetTextEmail.twig | 6 + app/plugins/Marketplace/Menu.php | 7 +- .../Marketplace/Plugins/InvalidLicenses.php | 3 +- app/plugins/Marketplace/SiteAwareLinks.php | 77 + .../Marketplace/vue/dist/Marketplace.umd.js | 395 ++--- .../vue/dist/Marketplace.umd.min.js | 2 +- .../vue/src/GetNewPlugins/GetNewPlugins.vue | 1 + .../GetNewPluginsAdmin/GetNewPluginsAdmin.vue | 2 + .../GetPremiumFeatures/GetPremiumFeatures.vue | 7 +- .../src/ManageLicenseKey/ManageLicenseKey.vue | 1 + .../vue/src/PluginList/CTAContainer.vue | 1 + .../vue/src/PluginList/DownloadButton.vue | 1 + .../vue/src/StartFreeTrial/StartFreeTrial.vue | 2 + .../SubscriptionOverview.vue | 4 + .../Morpheus/stylesheets/base/colors.less | 7 +- .../Morpheus/stylesheets/general/_utils.less | 13 + .../MultiSites/vue/dist/MultiSites.umd.js | 74 +- .../MultiSites/vue/dist/MultiSites.umd.min.js | 2 +- .../AllWebsitesDashboard/SitesTableSite.vue | 11 +- .../Overlay/javascripts/Piwik_Overlay.js | 119 +- app/plugins/Overlay/templates/index.twig | 4 +- .../Overlay/templates/index_noframe.twig | 2 +- .../JqplotGraph/StackedBarEvolution.php | 4 + .../PrivacyManager/vue/dist/umd.metadata.json | 4 +- .../Widgets/PromoAbTesting.php | 2 + .../Widgets/PromoCrashAnalytics.php | 2 + .../Widgets/PromoCustomReports.php | 2 + .../Widgets/PromoFormAnalytics.php | 2 + .../Widgets/PromoFunnels.php | 2 + .../Widgets/PromoHeatmaps.php | 2 + .../Widgets/PromoMediaAnalytics.php | 2 + .../Widgets/PromoSessionRecordings.php | 2 + app/plugins/ProfessionalServices/changes.json | 5 + .../templates/pluginAdvertising.twig | 4 +- app/plugins/Referrers/API.php | 23 + .../Metrics/VisitorsFromReferrerPercent.php | 4 +- app/plugins/ScheduledReports/API.php | 14 + .../stylesheets/scheduledreports.less | 2 +- .../templates/manageSegments.twig | 3 +- app/plugins/SitesManager/Controller.php | 4 +- app/plugins/SitesManager/SitesManager.php | 1 + app/plugins/TagManager/API.php | 19 +- app/plugins/TagManager/API/Import.php | 7 +- app/plugins/TagManager/Controller.php | 3 + .../TagManager/Input/AccessValidator.php | 17 + app/plugins/TagManager/Model/Container.php | 7 +- app/plugins/TagManager/Model/Variable.php | 27 + app/plugins/TagManager/TagManager.php | 7 - app/plugins/TwoFactorAuth/Controller.php | 6 +- app/plugins/UsersManager/API.php | 25 +- app/plugins/UsersManager/Model.php | 15 +- .../Repository/UserRepository.php | 12 + app/plugins/UsersManager/UsersManager.php | 3 +- .../UsersManager/vue/dist/UsersManager.umd.js | 428 +++--- .../vue/dist/UsersManager.umd.min.js | 4 +- .../vue/src/AddNewToken/AddNewToken.vue | 2 +- .../vue/src/PagedUsersList/PagedUsersList.vue | 37 +- .../UserPermissionsEdit.vue | 75 +- app/plugins/Widgetize/Controller.php | 31 +- .../Widgetize/UrlTokenAuthFailedException.php | 25 + app/plugins/Widgetize/Widgetize.php | 2 + app/plugins/Widgetize/templates/index.twig | 1 + .../Widgetize/vue/dist/Widgetize.umd.js | 60 +- .../Widgetize/vue/dist/Widgetize.umd.min.js | 2 +- .../vue/src/ExportWidget/ExportWidget.vue | 19 + app/tsconfig.spec.json | 4 + app/vendor/composer/autoload_classmap.php | 65 +- app/vendor/composer/autoload_static.php | 65 +- app/vendor/composer/ca-bundle/res/cacert.pem | 53 +- app/vendor/composer/installed.php | 28 +- .../matomo/referrer-spam-list/spammers.txt | 1 + .../AIAssistants.yml | 3 + .../searchengine-and-social-list/Socials.yml | 11 + app/vendor/prefixed/vendor/autoload.php | 2 +- .../vendor/composer/autoload_real.php | 10 +- .../vendor/composer/autoload_static.php | 4 +- .../polyfill-intl-grapheme/Grapheme.php | 22 +- .../polyfill-intl-grapheme/bootstrap.php | 5 + assets/js/asset_manager_core_js.js | 66 +- 278 files changed, 17479 insertions(+), 1876 deletions(-) create mode 100644 app/core/Http/EgressBlockedException.php create mode 100644 app/core/Http/EgressHostValidator.php create mode 100644 app/plugins/AIProviders/AIConversationRequest.php create mode 100644 app/plugins/AIProviders/AIConversationResponse.php create mode 100644 app/plugins/AIProviders/AIProviderResponse.php create mode 100644 app/plugins/AIProviders/AIProviderService.php create mode 100644 app/plugins/AIProviders/AIProviders.php create mode 100644 app/plugins/AIProviders/AIProvidersList.php create mode 100644 app/plugins/AIProviders/AIRequest.php create mode 100644 app/plugins/AIProviders/API.php create mode 100644 app/plugins/AIProviders/CanonicalMessage.php create mode 100644 app/plugins/AIProviders/Controller.php create mode 100644 app/plugins/AIProviders/Exception/AIProviderClientException.php create mode 100644 app/plugins/AIProviders/Exception/AIProviderException.php create mode 100644 app/plugins/AIProviders/Exception/AIProviderServerException.php create mode 100644 app/plugins/AIProviders/Menu.php create mode 100644 app/plugins/AIProviders/Model/Configuration.php create mode 100644 app/plugins/AIProviders/Provider/AIProvider.php create mode 100644 app/plugins/AIProviders/Provider/Anthropic.php create mode 100644 app/plugins/AIProviders/Provider/Bedrock.php create mode 100644 app/plugins/AIProviders/Provider/CustomProvider.php create mode 100644 app/plugins/AIProviders/Provider/Google.php create mode 100644 app/plugins/AIProviders/Provider/OpenAI.php create mode 100644 app/plugins/AIProviders/README.md create mode 100644 app/plugins/AIProviders/templates/index.twig create mode 100644 app/plugins/AIProviders/vue/dist/AIProviders.css create mode 100644 app/plugins/AIProviders/vue/dist/AIProviders.umd.js create mode 100644 app/plugins/AIProviders/vue/dist/AIProviders.umd.min.js create mode 100644 app/plugins/AIProviders/vue/dist/umd.metadata.json create mode 100644 app/plugins/AIProviders/vue/src/ManageAIProviders.vue create mode 100644 app/plugins/AIProviders/vue/src/components/ProviderCard.vue create mode 100644 app/plugins/AIProviders/vue/src/index.ts create mode 100644 app/plugins/AIProviders/vue/src/types.ts create mode 100644 app/plugins/BotTracking/Widgets/NoRecentRequestsRealtime.php delete mode 100644 app/plugins/CoreHome/FeatureFlags/ReportHeaderRedesign.php create mode 100644 app/plugins/CoreHome/vue/src/DataTable/DataTableActions.component.spec.ts create mode 100644 app/plugins/CoreHome/vue/src/ReportHeader/ReportHeader.less create mode 100644 app/plugins/CoreHome/vue/src/ReportHeader/ReportHeader.spec.ts create mode 100644 app/plugins/CoreHome/vue/src/ReportHeader/ReportHeader.vue create mode 100644 app/plugins/CoreHome/vue/src/WidgetControls/WidgetControls.less create mode 100644 app/plugins/CoreHome/vue/src/WidgetControls/WidgetControls.spec.ts create mode 100644 app/plugins/CoreHome/vue/src/WidgetControls/WidgetControls.vue create mode 100644 app/plugins/CoreHome/vue/src/ucfirst.spec.ts create mode 100644 app/plugins/CoreHome/vue/src/ucfirst.ts delete mode 100644 app/plugins/CoreVisualizations/FeatureFlags/SparklinesRedesign.php create mode 100644 app/plugins/CoreVisualizations/JqplotDataGenerator/ForecastBuilder.php create mode 100644 app/plugins/CoreVisualizations/JqplotDataGenerator/ForecastMetricClassifier.php create mode 100644 app/plugins/CoreVisualizations/JqplotDataGenerator/ForecastSampleWindow.php create mode 100644 app/plugins/CoreVisualizations/JqplotDataGenerator/ForecastSeriesState.php create mode 100644 app/plugins/CoreVisualizations/JqplotDataGenerator/ForecastSeriesStateBuilder.php create mode 100644 app/plugins/CoreVisualizations/JqplotDataGenerator/ForecastSubPeriodFetcher.php create mode 100644 app/plugins/CoreVisualizations/Metrics/MetricTotalsTreatment.php create mode 100644 app/plugins/CoreVisualizations/vue/src/Sparklines/DateAtom.less create mode 100644 app/plugins/CoreVisualizations/vue/src/Sparklines/DateAtom.spec.ts create mode 100644 app/plugins/CoreVisualizations/vue/src/Sparklines/DateAtom.vue create mode 100644 app/plugins/CoreVisualizations/vue/src/Sparklines/DateComparison.less create mode 100644 app/plugins/CoreVisualizations/vue/src/Sparklines/DateComparison.spec.ts create mode 100644 app/plugins/CoreVisualizations/vue/src/Sparklines/DateComparison.vue create mode 100644 app/plugins/CoreVisualizations/vue/src/Sparklines/PeriodColumns.less create mode 100644 app/plugins/CoreVisualizations/vue/src/Sparklines/PeriodColumns.spec.ts create mode 100644 app/plugins/CoreVisualizations/vue/src/Sparklines/PeriodColumns.vue create mode 100644 app/plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonCard.less create mode 100644 app/plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonCard.spec.ts create mode 100644 app/plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonCard.vue create mode 100644 app/plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonRow.less create mode 100644 app/plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonRow.spec.ts create mode 100644 app/plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonRow.vue create mode 100644 app/plugins/CoreVisualizations/vue/src/Sparklines/sparklineDataAttrs.spec.ts create mode 100644 app/plugins/CoreVisualizations/vue/src/Sparklines/sparklineDataAttrs.ts create mode 100644 app/plugins/Marketplace/SiteAwareLinks.php create mode 100644 app/plugins/Widgetize/UrlTokenAuthFailedException.php diff --git a/app/composer.json b/app/composer.json index 4403d574e..4fc999733 100644 --- a/app/composer.json +++ b/app/composer.json @@ -29,6 +29,7 @@ "PKSA-1tmc-rt7x-12w6": "CVE-2026-48806 (sandbox `__toString()` bypass via dynamic mapping keys): not exploitable - Matomo does not enable Twig's sandbox. Upgrade blocked while Matomo supports PHP 7.2.", "PKSA-21g2-dzjv-sky5": "CVE-2026-46634 (`template_from_string()` escapes a SourcePolicy-driven sandbox): not exploitable - Matomo does not call `template_from_string`\/`createTemplate` at runtime (only one TagManager test fixture) and does not implement `SourcePolicyInterface`. Upgrade blocked while Matomo supports PHP 7.2.", "PKSA-3mcc-k66d-pydb": "CVE-2026-46638 (`{% sandbox %}{% include %}` skips `checkSecurity()` on cached templates): not exploitable - Matomo uses neither the `{% sandbox %}` tag nor `SandboxExtension`. Upgrade blocked while Matomo supports PHP 7.2.", + "PKSA-8zx5-v2nz-58pb": "CVE-2026-49981 (sandbox filter, tag and function allow-list bypass when sandbox state changes between renders for a cached `Template`): not exploitable - Matomo does not enable Twig's sandbox or toggle sandbox state. Upgrade blocked while Matomo supports PHP 7.2.", "PKSA-dpx1-78wg-1kqs": "CVE-2026-47732 (multiple sandbox `__toString()` bypasses via unguarded string coercion points): not exploitable - Matomo does not enable Twig's sandbox. Upgrade blocked while Matomo supports PHP 7.2.", "PKSA-fbvq-z33h-r2np": "CVE-2026-48808 (sandbox property allowlist bypass via `column` filter under `SourcePolicyInterface`): not exploitable - Matomo does not register `SandboxExtension` or implement `SourcePolicyInterface`. Upgrade blocked while Matomo supports PHP 7.2.", "PKSA-g9zw-qxh8-pq8w": "CVE-2026-48805 (sandbox state regression in deprecated wrappers in `vendor\/twig\/twig\/src\/Resources\/core.php`): not exploitable - Matomo does not enable Twig's sandbox. Upgrade blocked while Matomo supports PHP 7.2.", diff --git a/app/composer.lock b/app/composer.lock index f5308af6f..ff0b86b49 100644 --- a/app/composer.lock +++ b/app/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "composer/ca-bundle", - "version": "1.5.12", + "version": "1.5.13", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78" + "reference": "c008272789979f709f7fcb32c2ecf1d2db5e84e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/00a2f4201641d5c53f7fc0195e6c8d9fcc321a78", - "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/c008272789979f709f7fcb32c2ecf1d2db5e84e5", + "reference": "c008272789979f709f7fcb32c2ecf1d2db5e84e5", "shasum": "" }, "require": { @@ -64,7 +64,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.5.12" + "source": "https://github.com/composer/ca-bundle/tree/1.5.13" }, "funding": [ { @@ -76,7 +76,7 @@ "type": "github" } ], - "time": "2026-05-19T11:26:22+00:00" + "time": "2026-07-18T12:35:13+00:00" }, { "name": "composer/semver", @@ -706,12 +706,12 @@ "source": { "type": "git", "url": "https://github.com/matomo-org/referrer-spam-list.git", - "reference": "d94fc55b2bcd0a4f36fd32e8b4430ff5fd60d23d" + "reference": "4f1c7a8d99be37b0aa19ff93fcd8bd7f2d0bf27e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/matomo-org/referrer-spam-list/zipball/d94fc55b2bcd0a4f36fd32e8b4430ff5fd60d23d", - "reference": "d94fc55b2bcd0a4f36fd32e8b4430ff5fd60d23d", + "url": "https://api.github.com/repos/matomo-org/referrer-spam-list/zipball/4f1c7a8d99be37b0aa19ff93fcd8bd7f2d0bf27e", + "reference": "4f1c7a8d99be37b0aa19ff93fcd8bd7f2d0bf27e", "shasum": "" }, "replace": { @@ -729,7 +729,7 @@ "issues": "https://github.com/matomo-org/referrer-spam-list/issues", "source": "https://github.com/matomo-org/referrer-spam-list/tree/master" }, - "time": "2026-06-25T20:01:36+00:00" + "time": "2026-07-01T13:51:06+00:00" }, { "name": "matomo/searchengine-and-social-list", @@ -737,12 +737,12 @@ "source": { "type": "git", "url": "https://github.com/matomo-org/searchengine-and-social-list.git", - "reference": "743a0faff8185340c6db38f3b7f1c13cf168cc5b" + "reference": "3b3a82dc83cd146abe17aafe31254de1c25f7844" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/matomo-org/searchengine-and-social-list/zipball/743a0faff8185340c6db38f3b7f1c13cf168cc5b", - "reference": "743a0faff8185340c6db38f3b7f1c13cf168cc5b", + "url": "https://api.github.com/repos/matomo-org/searchengine-and-social-list/zipball/3b3a82dc83cd146abe17aafe31254de1c25f7844", + "reference": "3b3a82dc83cd146abe17aafe31254de1c25f7844", "shasum": "" }, "replace": { @@ -759,7 +759,7 @@ "issues": "https://github.com/matomo-org/searchengine-and-social-list/issues", "source": "https://github.com/matomo-org/searchengine-and-social-list/tree/master" }, - "time": "2025-12-15T20:05:07+00:00" + "time": "2026-07-15T21:31:22+00:00" }, { "name": "maxmind-db/reader", @@ -2560,16 +2560,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.38.1", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", "shasum": "" }, "require": { @@ -2618,7 +2618,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" }, "funding": [ { @@ -2638,7 +2638,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T05:58:03+00:00" + "time": "2026-07-28T08:25:59+00:00" }, { "name": "symfony/polyfill-intl-normalizer", @@ -4178,11 +4178,11 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.33", + "version": "1.12.34", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1", - "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/4dd89ca7aa30fdc6760be21550d583bcc32e8476", + "reference": "4dd89ca7aa30fdc6760be21550d583bcc32e8476", "shasum": "" }, "require": { @@ -4227,7 +4227,7 @@ "type": "github" } ], - "time": "2026-02-28T20:30:03+00:00" + "time": "2026-07-28T10:04:39+00:00" }, { "name": "phpunit/php-code-coverage", @@ -4527,25 +4527,25 @@ }, { "name": "phpunit/phpunit", - "version": "8.5.52", + "version": "8.5.53", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "1015741814413c156abb0f53d7db7bbd03c6e858" + "reference": "17cd4291b0ef645b6d21e5ddb6f497694c5e9bcd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/1015741814413c156abb0f53d7db7bbd03c6e858", - "reference": "1015741814413c156abb0f53d7db7bbd03c6e858", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/17cd4291b0ef645b6d21e5ddb6f497694c5e9bcd", + "reference": "17cd4291b0ef645b6d21e5ddb6f497694c5e9bcd", "shasum": "" }, "require": { "doctrine/instantiator": "^1.5.0", "ext-dom": "*", + "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", - "ext-xml": "*", "ext-xmlwriter": "*", "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", @@ -4605,31 +4605,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/8.5.52" + "source": "https://github.com/sebastianbergmann/phpunit/tree/8.5.53" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-01-27T05:20:18+00:00" + "time": "2026-07-06T14:29:25+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", @@ -5474,16 +5458,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.13.5", + "version": "3.13.6", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4" + "reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4", - "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/4c378e1a528ea066890fc2397cbdd2f94eb2fc91", + "reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91", "shasum": "" }, "require": { @@ -5549,7 +5533,7 @@ "type": "thanks_dev" } ], - "time": "2025-11-04T16:30:35+00:00" + "time": "2026-08-06T00:17:32+00:00" }, { "name": "symfony/yaml", diff --git a/app/config/global.ini.php b/app/config/global.ini.php index 2508ae826..3aa0cccd6 100644 --- a/app/config/global.ini.php +++ b/app/config/global.ini.php @@ -885,6 +885,13 @@ ; If you may need to download GeoIP updates or other stuff using other protocols like ftp you may need to extend this list. allowed_outgoing_protocols = 'http,https' +; HTTP requests fetching a user-configured URL (e.g. a site's URL for site content detection) refuse to +; contact private, loopback or otherwise reserved IP addresses, so they cannot be pointed at other servers +; in this Matomo's network. If this Matomo tracks intranet sites hosted on such addresses, allowlist their +; ranges here. Accepts single IPs, CIDR notation and wildcards, both IPv4 and IPv6. +; allowed_private_egress_ranges[] = "10.0.0.0/8" +; allowed_private_egress_ranges[] = "192.168.1.*" + ; This option forces matomo marketplace and matomo api requests to use HTTP, as default we use HTTPS to improve security ; If you have a problem loading the marketplace, please enable this config option force_matomo_http_request = 0 @@ -1028,7 +1035,7 @@ ; Comma separated list of URL query string variable names that will be removed from your tracked URLs ; By default, Matomo will remove the most common parameters which are known to change often (eg. session ID parameters) -url_query_parameter_to_exclude_from_url = "gclid,fbclid,msclkid,twclid,wbraid,gbraid,yclid,fb_xd_fragment,fb_comment_id,phpsessid,jsessionid,sessionid,aspsessionid,doing_wp_cron,sid,pk_vid,li_fat_id" +url_query_parameter_to_exclude_from_url = "gclid,fbclid,msclkid,twclid,wbraid,gbraid,yclid,fb_xd_fragment,fb_comment_id,phpsessid,jsessionid,sessionid,aspsessionid,doing_wp_cron,sid,pk_vid,li_fat_id,token_auth,token" ; If set to 1, Matomo will use the default provider if no other provider is configured. ; In addition the default provider will be used as a fallback when the configure provider does not return any results. @@ -1337,6 +1344,7 @@ Plugins[] = FeatureFlags Plugins[] = AIAgents Plugins[] = BotTracking +Plugins[] = AIProviders [PluginsInstalled] PluginsInstalled[] = Diagnostics diff --git a/app/core/API/DataTableGenericFilter.php b/app/core/API/DataTableGenericFilter.php index 8ebab893a..bb51e835e 100644 --- a/app/core/API/DataTableGenericFilter.php +++ b/app/core/API/DataTableGenericFilter.php @@ -15,6 +15,13 @@ use Piwik\Plugin\Report; class DataTableGenericFilter { + /** + * The generic filters that reduce the result to the requested page of rows instead of + * removing rows that do not match the request. + * + * @var string[] + */ + private const ROW_LIMITING_FILTERS = array('Truncate', 'Limit'); /** * List of filter names not to run. * @@ -29,6 +36,10 @@ class DataTableGenericFilter * @var array */ private $request; + /** + * @var callable|null + */ + private $callbackBeforeRowLimitingFilters; /** * @param array $request * @param Report $report @@ -38,6 +49,23 @@ public function __construct($request, $report) $this->request = $request; $this->report = $report; } + /** + * Sets a callback that is invoked with every filtered DataTable after the filters that remove + * and sort rows have been applied, but before the row limiting filters reduce the table to the + * requested page of rows. + * + * At that point the table contains every row matching the request, which makes it the place to + * compute values that have to take all matching rows into account. + * + * The callback is invoked exactly once per DataTable, even when all row limiting filters are + * disabled or skipped. + * + * @param callable|null $callback A callback that receives the DataTable being filtered. + */ + public function setCallbackBeforeRowLimitingFilters(?callable $callback) : void + { + $this->callbackBeforeRowLimitingFilters = $callback; + } /** * Filters the given data table * @@ -108,11 +136,16 @@ protected function applyGenericFilters($datatable) $tableDisabledFilters = $datatable->getMetadata(DataTable::GENERIC_FILTERS_TO_DISABLE_METADATA_NAME) ?: []; $genericFilters = $this->getGenericFiltersHavingDefaultValues(); $filterApplied = \false; + $rowLimitingFiltersReached = \false; foreach ($genericFilters as $filterMeta) { $filterName = $filterMeta[0]; $filterParams = $filterMeta[1]; $filterParameters = array(); $exceptionRaised = \false; + if (!$rowLimitingFiltersReached && in_array($filterName, self::ROW_LIMITING_FILTERS, \true)) { + $rowLimitingFiltersReached = \true; + $this->invokeCallbackBeforeRowLimitingFilters($datatable); + } if (in_array($filterName, $this->disabledFilters) || in_array($filterName, $tableDisabledFilters)) { continue; } @@ -143,8 +176,17 @@ protected function applyGenericFilters($datatable) $filterApplied = \true; } } + if (!$rowLimitingFiltersReached) { + $this->invokeCallbackBeforeRowLimitingFilters($datatable); + } return $filterApplied; } + private function invokeCallbackBeforeRowLimitingFilters(DataTable $datatable) : void + { + if (null !== $this->callbackBeforeRowLimitingFilters) { + call_user_func($this->callbackBeforeRowLimitingFilters, $datatable); + } + } public function areProcessedMetricsNeededFor($metrics) { $columnQueryParameters = array('filter_column', 'filter_column_recursive', 'filter_excludelowpop', 'filter_sort_column'); diff --git a/app/core/API/DataTableManipulator/Flattener.php b/app/core/API/DataTableManipulator/Flattener.php index bcc48604b..460714229 100644 --- a/app/core/API/DataTableManipulator/Flattener.php +++ b/app/core/API/DataTableManipulator/Flattener.php @@ -203,7 +203,7 @@ private function flattenRow(Row $row, $rowId, DataTable $dataTable, $level, $dim } } /** - * Remove the flat parameter from the subtable request + * Remove the flat & filter_pattern parameters from the subtable request * * @param array $request * @return array @@ -211,6 +211,11 @@ private function flattenRow(Row $row, $rowId, DataTable $dataTable, $level, $dim protected function manipulateSubtableRequest($request) { unset($request['flat']); + // don't apply the search pattern while subtables are loaded, otherwise rows are filtered on + // their child label before the parent label parts are combined into the final flattened label. + // that would drop rows whose match only appears in a parent label. instead we let the pattern + // run once on the flattened table (via the generic filters applied after flattening). + unset($request['filter_pattern']); return $request; } } diff --git a/app/core/API/DataTableManipulator/LabelFilter.php b/app/core/API/DataTableManipulator/LabelFilter.php index b60c8b9bf..17d5813ce 100644 --- a/app/core/API/DataTableManipulator/LabelFilter.php +++ b/app/core/API/DataTableManipulator/LabelFilter.php @@ -166,8 +166,10 @@ protected function manipulateDataTable($dataTable) $labelSeriesIndex = $this->labelSeries[$labelIndex]; $originalLabel = $row->getColumn($this->labelColumn) ?: $row->getMetadata($this->labelColumn); $row = $comparisons->getRowFromId($labelSeriesIndex); + // the suffix is appended after labels are sanitized, so encode it to match + $comparisonSuffix = Common::sanitizeInputValue((string) $row->getMetadata('compareSeriesPretty')); // add label and make sure it is the first column - $columns = array_merge(['label' => $originalLabel . ' ' . $row->getMetadata('compareSeriesPretty')], $row->getColumns()); + $columns = array_merge(['label' => $originalLabel . ' ' . $comparisonSuffix], $row->getColumns()); $row->setColumns($columns); } } diff --git a/app/core/API/DataTableManipulator/ReportTotalsCalculator.php b/app/core/API/DataTableManipulator/ReportTotalsCalculator.php index 8051f7204..b2601a1da 100644 --- a/app/core/API/DataTableManipulator/ReportTotalsCalculator.php +++ b/app/core/API/DataTableManipulator/ReportTotalsCalculator.php @@ -12,6 +12,7 @@ use Piwik\API\DataTablePostProcessor; use Piwik\Common; use Piwik\DataTable; +use Piwik\Metrics\Formatter; use Piwik\Period; use Piwik\Piwik; use Piwik\Plugin\Report; @@ -78,18 +79,88 @@ protected function manipulateDataTable($dataTable) if (!$firstLevelTable->getRowsCount() || $dataTable->getTotalsRow() || $dataTable->getMetadata('totals')) { return $dataTable; } + $totalRowUnformatted = null; + $totalRow = $this->makeTotalsRow($firstLevelTable, $totalRowUnformatted); + if (isset($totalRow)) { + $totals = $totalRow->getColumns(); + unset($totals['label']); + $dataTable->setMetadata('totals', $totals); + if (isset($totalRowUnformatted)) { + unset($totalRowUnformatted['label']); + $dataTable->setMetadata('totalsUnformatted', $totalRowUnformatted); + } + if (1 === Common::getRequestVar('keep_totals_row', 0, 'integer', $this->request)) { + $totalLabel = Common::getRequestVar('keep_totals_row_label', Piwik::translate('General_Totals'), 'string', $this->request); + $totalRow->deleteMetadata(\false); + $totalRow->setColumn('label', $totalLabel); + $dataTable->setTotalsRow($totalRow); + } + } + return $dataTable; + } + /** + * Replaces the totals row of a table that was reduced to the rows matching the table search of + * the request, so it totals those rows instead of every row of the report. + * + * Has to be called while the table still contains every matching row, ie. before the rows are + * limited to the requested page of results. The report totals are left untouched in the + * 'totals' and 'totalsUnformatted' metadata, so both values can be shown next to each other. + * + * @param DataTable $dataTable A table that only contains the rows matching the request. + */ + public function calculateFilteredTotals(DataTable $dataTable) : void + { + if (empty($this->apiModule) || empty($this->apiMethod) || !$this->isTableSearchActive() || !$dataTable->getRowsCount() || !$dataTable->getTotalsRow()) { + return; + } + if (1 === Common::getRequestVar('compare', 0, 'integer', $this->request)) { + // the compared reports are requested without the table search, so a filtered total could + // only be compared against an unfiltered one + return; + } + try { + $unformatted = null; + $totalRow = $this->makeTotalsRow($dataTable, $unformatted, $rowsWerePostProcessed = \true); + } catch (\Exception $e) { + // the report totals are kept when the filtered totals cannot be computed + return; + } + if (!isset($totalRow)) { + return; + } + $totalRow->deleteMetadata(\false); + $totalRow->setColumn('label', Piwik::translate('General_FilteredTotal')); + $dataTable->setTotalsRow($totalRow); + $dataTable->setMetadata(DataTable::TOTALS_ROW_IS_FILTERED_METADATA_NAME, \true); + } + /** + * Sums every row of the given table into a single row, computes its processed metrics and + * formats it. + * + * @param array|null $totalRowUnformatted Set to the total column values before they are formatted. + * @param bool $rowsWerePostProcessed Whether the rows already went through the part of the post + * processing that computes and formats processed metrics. + */ + private function makeTotalsRow(DataTable $table, ?array &$totalRowUnformatted, bool $rowsWerePostProcessed = \false) : ?DataTable\Row + { // keeping queued filters would not only add various metadata but also break the totals calculator for some reports // eg when needed metadata is missing to get site information (multisites.getall) etc - $clone = $firstLevelTable->getEmptyClone($keepFilters = \false); - foreach ($firstLevelTable->getQueuedFilters() as $queuedFilter) { + $clone = $table->getEmptyClone($keepFilters = \false); + foreach ($table->getQueuedFilters() as $queuedFilter) { if (is_array($queuedFilter) && 'ReplaceColumnNames' === $queuedFilter['className']) { $clone->queueFilter($queuedFilter['className'], $queuedFilter['parameters']); } } - $tableMeta = $firstLevelTable->getMetadata(DataTable::COLUMN_AGGREGATION_OPS_METADATA_NAME); + if ($rowsWerePostProcessed) { + // getEmptyClone() copies the metadata of the source table, which then states that the + // processed metrics were already computed and formatted. The totals row still needs both. + $clone->deleteMetadata(DataTablePostProcessor::PROCESSED_METRICS_COMPUTED_FLAG); + $clone->deleteMetadata(Formatter::PROCESSED_METRICS_FORMATTED_FLAG); + } + $tableMeta = $table->getMetadata(DataTable::COLUMN_AGGREGATION_OPS_METADATA_NAME); /** @var DataTable\Row|null $totalRow */ $totalRow = null; - foreach ($firstLevelTable->getRows() as $row) { + foreach ($table->getRows() as $row) { if (!isset($totalRow)) { $columns = $row->getColumns(); $columns['label'] = DataTable::LABEL_TOTALS_ROW; @@ -98,6 +169,16 @@ protected function manipulateDataTable($dataTable) $totalRow->sumRow($row, $copyMetadata = \false, $tableMeta); } } + if (!isset($totalRow)) { + return null; + } + if ($rowsWerePostProcessed) { + // processed metrics are derived from the summed metrics and must never be summed themselves. + // they are already on the rows when a generic filter needed them, eg when sorting by one. + foreach (Report::getProcessedMetricsForTable($table, $this->report) as $processedMetricName => $processedMetric) { + $totalRow->deleteColumn($processedMetricName); + } + } $clone->addRow($totalRow); if ($this->report && $this->report->getProcessedMetrics() && array_keys($this->report->getProcessedMetrics()) === array('nb_actions_per_visit', 'avg_time_on_site', 'bounce_rate', 'conversion_rate')) { // hack for AllColumns table or default processed metrics @@ -127,22 +208,21 @@ protected function manipulateDataTable($dataTable) // if for some reason the processor renamed the totals row, $totalRow = $clone->getFirstRow(); } - if (isset($totalRow)) { - $totals = $totalRow->getColumns(); - unset($totals['label']); - $dataTable->setMetadata('totals', $totals); - if (isset($totalRowUnformatted)) { - unset($totalRowUnformatted['label']); - $dataTable->setMetadata('totalsUnformatted', $totalRowUnformatted); - } - if (1 === Common::getRequestVar('keep_totals_row', 0, 'integer', $this->request)) { - $totalLabel = Common::getRequestVar('keep_totals_row_label', Piwik::translate('General_Totals'), 'string', $this->request); - $totalRow->deleteMetadata(\false); - $totalRow->setColumn('label', $totalLabel); - $dataTable->setTotalsRow($totalRow); + return $totalRow; + } + /** + * Returns whether the request searches the table for a pattern, ie. whether the rows the request + * results in are a subset of the rows of the report. + */ + private function isTableSearchActive() : bool + { + $patterns = array(Common::getRequestVar('filter_pattern', '', 'string', $this->request), Common::getRequestVar('filter_pattern_recursive', '', 'string', $this->request)); + foreach ($patterns as $pattern) { + if ('' !== $pattern) { + return \true; } } - return $dataTable; + return \false; } private function makeSureToWorkOnFirstLevelDataTable($table) { diff --git a/app/core/API/DataTablePostProcessor.php b/app/core/API/DataTablePostProcessor.php index e5ba9a160..4bcc89121 100644 --- a/app/core/API/DataTablePostProcessor.php +++ b/app/core/API/DataTablePostProcessor.php @@ -209,6 +209,12 @@ public function applyGenericFilters($dataTable) if (!empty($label)) { $genericFilter->disableFilters(array('Limit', 'Truncate')); } + $totalsCalculator = new ReportTotalsCalculator($this->apiModule, $this->apiMethod, $this->request, $this->report); + $genericFilter->setCallbackBeforeRowLimitingFilters(function (DataTable $table) use($totalsCalculator) { + // the table still holds every row matching the request here, so the totals row can be + // recalculated for the table search before the rows are limited to the requested page + $totalsCalculator->calculateFilteredTotals($table); + }); $genericFilter->filter($dataTable); } return $dataTable; diff --git a/app/core/API/Request.php b/app/core/API/Request.php index b4425df8a..dcc11ebed 100644 --- a/app/core/API/Request.php +++ b/app/core/API/Request.php @@ -341,6 +341,18 @@ public static function isCurrentApiRequestTheRootApiRequest() { return self::$nestedApiInvocationCount == 1; } + /** + * Checks if the currently executing API request is running inside another API request. + * + * This is true only for "child" API requests, i.e. requests that were dispatched + * programmatically from within another API method (for example the sub-requests run by + * {@link \Piwik\Plugins\API\API::getBulkRequest()}). It is false for the root request and + * when no API request is currently being processed. + */ + public static function isCurrentApiRequestNestedInAnotherApiRequest() : bool + { + return self::$nestedApiInvocationCount > 1; + } /** * Detect if request is an API request. Meaning the module is 'API' and an API method having a valid format was * specified. Note that this method will return true even if the actual request is for example a regular UI diff --git a/app/core/ArchiveProcessor.php b/app/core/ArchiveProcessor.php index 0e44fdf4b..5e6bd9c73 100644 --- a/app/core/ArchiveProcessor.php +++ b/app/core/ArchiveProcessor.php @@ -357,8 +357,8 @@ protected function getAggregatedDataTableMapFromBlobs(\Iterator $dataTableBlobs, */ $this->renameColumnsAfterAggregation($table, $columnsToRenameAfterAggregation); } - }, null, function (string $period, int $tableId) : void { - StaticContainer::get(LoggerInterface::class)->info('Unexpected state when aggregating DataTable, unknown period/table ID combination encountered: {period} - {tableId}.' . ' This either means the SQL to order blobs is behaving incorrectly or the blob data is corrupt in some way.', ['period' => $period, 'tableId' => $tableId]); + }, null, function (string $sitePeriod, int $tableId) : void { + StaticContainer::get(LoggerInterface::class)->info('Unexpected state when aggregating DataTable, unknown site/period/table ID combination encountered: {sitePeriod} - {tableId}.' . ' This either means the SQL to order blobs is behaving incorrectly or the blob data is corrupt in some way.', ['sitePeriod' => $sitePeriod, 'tableId' => $tableId]); }); unset($hasRows); return $result; diff --git a/app/core/ArchiveProcessor/BlobTableAggregator.php b/app/core/ArchiveProcessor/BlobTableAggregator.php index 07ebd989d..f83b83e2a 100644 --- a/app/core/ArchiveProcessor/BlobTableAggregator.php +++ b/app/core/ArchiveProcessor/BlobTableAggregator.php @@ -19,16 +19,20 @@ final class BlobTableAggregator { /** - * @param iterable $archiveDataRows + * @param iterable $archiveDataRows * @param callable(DataTable):void $renameColumnsCallback - * @param callable(array{name: string, date1: string, date2: string, value: string}):bool|null $shouldIncludeRow - * @param callable(string, int):void|null $onMissingParentTable + * @param callable(array{idsite: int|string, name: string, date1: string, date2: string, value: string}):bool|null $shouldIncludeRow + * @param callable(string, int):void|null $onMissingParentTable Called with the site/period key of the orphaned + * subtable blob (see {@link self::getSitePeriodKey()}) + * and the subtable ID it references. * @return array{0: DataTable, 1: bool} */ public static function aggregateBlobRows(iterable $archiveDataRows, string $recordName, ?array $columnsAggregationOperation, callable $renameColumnsCallback, ?callable $shouldIncludeRow = null, ?callable $onMissingParentTable = null) : array { - // maps period & subtable ID in database to the Row instance in $result that subtable should be added to - // [$row['date1'].','.$row['date2']][$tableId] = $row in $result + // maps site, period & subtable ID in database to the Row instance in $result that subtable should be added to. + // the site is part of the key because an archive query can span multiple sites for the same period + // (eg for roll-up day archives) and each site's blobs use their own subtable ID space. + // [$row['idsite'].'|'.$row['date1'].','.$row['date2']][$tableId] = $row in $result $tableIdToResultRowMapping = []; $result = new DataTable(); $hasRows = \false; @@ -40,7 +44,7 @@ public static function aggregateBlobRows(iterable $archiveDataRows, string $reco continue; } $hasRows = \true; - $period = $archiveDataRow['date1'] . ',' . $archiveDataRow['date2']; + $sitePeriod = self::getSitePeriodKey($archiveDataRow); $tableId = $archiveDataRow['name'] === $recordName ? null : self::parseSubtableIdFromBlobName($archiveDataRow['name']); $blobTable = DataTable::fromSerializedArray($archiveDataRow['value']); $blobTable->filter(function (DataTable $table) use($renameColumnsCallback) { @@ -48,14 +52,14 @@ public static function aggregateBlobRows(iterable $archiveDataRows, string $reco }); if ($tableId === null) { $tableToAddTo = $result; - } elseif (empty($tableIdToResultRowMapping[$period][$tableId])) { + } elseif (empty($tableIdToResultRowMapping[$sitePeriod][$tableId])) { if ($onMissingParentTable !== null) { - $onMissingParentTable($period, $tableId); + $onMissingParentTable($sitePeriod, $tableId); } Common::destroy($blobTable); continue; } else { - $rowToAddTo = $tableIdToResultRowMapping[$period][$tableId]; + $rowToAddTo = $tableIdToResultRowMapping[$sitePeriod][$tableId]; if (!$rowToAddTo->getIdSubDataTable()) { $newTable = new DataTable(); if (!empty($columnsAggregationOperation)) { @@ -74,13 +78,22 @@ public static function aggregateBlobRows(iterable $archiveDataRows, string $reco } $rowToAddTo = $tableToAddTo->getRowFromLabel($label); if ($rowToAddTo instanceof Row) { - $tableIdToResultRowMapping[$period][$subtableId] = $rowToAddTo; + $tableIdToResultRowMapping[$sitePeriod][$subtableId] = $rowToAddTo; } } Common::destroy($blobTable); } return [$result, $hasRows]; } + /** + * Returns a key uniquely identifying the site and period an archive data row belongs to. + * + * @param array{idsite: int|string, date1: string, date2: string} $archiveDataRow + */ + public static function getSitePeriodKey(array $archiveDataRow) : string + { + return $archiveDataRow['idsite'] . '|' . $archiveDataRow['date1'] . ',' . $archiveDataRow['date2']; + } public static function parseSubtableIdFromBlobName(string $recordName) : ?int { $parts = explode('_', $recordName); diff --git a/app/core/ArchiveProcessor/RecordBuilder.php b/app/core/ArchiveProcessor/RecordBuilder.php index e7d582499..05926770c 100644 --- a/app/core/ArchiveProcessor/RecordBuilder.php +++ b/app/core/ArchiveProcessor/RecordBuilder.php @@ -253,13 +253,13 @@ protected function aggregateBuiltFromFlatRecordForNonDay(ArchiveProcessor $archi $flatColumnToRenameAfterAggregation = $flatRecord->getColumnToRenameAfterAggregation() ?? $this->columnToRenameAfterAggregation; $flatColumnToSortByBeforeTruncation = $flatRecord->getColumnToSortByBeforeTruncation() ?? $this->columnToSortByBeforeTruncation; $flatMaxRowsInTable = $flatRecord->getMaxRowsInTable() ?? $this->maxRowsInTable; - [$flatTable, $hasFlatSourceData, $periodsWithFlatRecord] = $this->aggregateRootDataTableFromBlobs($archiveProcessor, $flatRecordName, $flatColumnAggregationOps, $flatColumnToRenameAfterAggregation); + [$flatTable, $hasFlatSourceData, $sitePeriodsWithFlatRecord] = $this->aggregateRootDataTableFromBlobs($archiveProcessor, $flatRecordName, $flatColumnAggregationOps, $flatColumnToRenameAfterAggregation); $allSubperiodKeys = $this->getAllSubperiodKeys($archiveProcessor); - $periodsWithoutFlatRecord = array_diff_key($allSubperiodKeys, $periodsWithFlatRecord); + $sitePeriodsWithoutFlatRecord = array_diff_key($allSubperiodKeys, $sitePeriodsWithFlatRecord); $hasLegacyFallbackData = \false; $legacyReducerCallback = $hierarchicalRecord->getLegacyHierarchyToFlatReducerCallback(); - if (!empty($periodsWithoutFlatRecord) && is_callable($legacyReducerCallback)) { - $hasLegacyFallbackData = $this->aggregateLegacyHierarchyPeriodsIntoFlatTable($archiveProcessor, $hierarchicalRecord->getName(), $flatTable, $legacyReducerCallback, $hierarchicalRecord, $columnAggregationOps, $columnToRenameAfterAggregation, $periodsWithoutFlatRecord); + if (!empty($sitePeriodsWithoutFlatRecord) && is_callable($legacyReducerCallback)) { + $hasLegacyFallbackData = $this->aggregateLegacyHierarchyPeriodsIntoFlatTable($archiveProcessor, $hierarchicalRecord->getName(), $flatTable, $legacyReducerCallback, $hierarchicalRecord, $columnAggregationOps, $columnToRenameAfterAggregation, $sitePeriodsWithoutFlatRecord); } if (!$hasFlatSourceData && !$hasLegacyFallbackData) { Common::destroy($flatTable); @@ -288,31 +288,31 @@ protected function aggregateBuiltFromFlatRecordForNonDay(ArchiveProcessor $archi Common::destroy($flatTable); return \true; } - protected function aggregateLegacyHierarchyPeriodsIntoFlatTable(ArchiveProcessor $archiveProcessor, string $recordName, DataTable $flatTable, callable $legacyReducerCallback, \Piwik\ArchiveProcessor\Record $hierarchicalRecord, ?array $columnsAggregationOperation, ?array $columnsToRenameAfterAggregation, ?array $periodsToInclude) : bool + protected function aggregateLegacyHierarchyPeriodsIntoFlatTable(ArchiveProcessor $archiveProcessor, string $recordName, DataTable $flatTable, callable $legacyReducerCallback, \Piwik\ArchiveProcessor\Record $hierarchicalRecord, ?array $columnsAggregationOperation, ?array $columnsToRenameAfterAggregation, ?array $sitePeriodsToInclude) : bool { - $currentPeriod = null; - $currentPeriodRows = []; + $currentSitePeriod = null; + $currentSitePeriodRows = []; $hasRows = \false; foreach ($this->querySingleBlobRows($archiveProcessor, $recordName) as $archiveDataRow) { - $period = $archiveDataRow['date1'] . ',' . $archiveDataRow['date2']; - if ($periodsToInclude !== null && !isset($periodsToInclude[$period])) { + $sitePeriod = \Piwik\ArchiveProcessor\BlobTableAggregator::getSitePeriodKey($archiveDataRow); + if ($sitePeriodsToInclude !== null && !isset($sitePeriodsToInclude[$sitePeriod])) { continue; } - if ($currentPeriod !== null && $period !== $currentPeriod) { - $hasRows = $this->reduceLegacyHierarchyPeriodRowsIntoFlatTable($currentPeriodRows, $recordName, $flatTable, $legacyReducerCallback, $archiveProcessor, $hierarchicalRecord, $columnsAggregationOperation, $columnsToRenameAfterAggregation) || $hasRows; - $currentPeriodRows = []; + if ($currentSitePeriod !== null && $sitePeriod !== $currentSitePeriod) { + $hasRows = $this->reduceLegacyHierarchyPeriodRowsIntoFlatTable($currentSitePeriodRows, $recordName, $flatTable, $legacyReducerCallback, $archiveProcessor, $hierarchicalRecord, $columnsAggregationOperation, $columnsToRenameAfterAggregation) || $hasRows; + $currentSitePeriodRows = []; } - $currentPeriod = $period; - $currentPeriodRows[] = $archiveDataRow; + $currentSitePeriod = $sitePeriod; + $currentSitePeriodRows[] = $archiveDataRow; } - if (!empty($currentPeriodRows)) { - $hasRows = $this->reduceLegacyHierarchyPeriodRowsIntoFlatTable($currentPeriodRows, $recordName, $flatTable, $legacyReducerCallback, $archiveProcessor, $hierarchicalRecord, $columnsAggregationOperation, $columnsToRenameAfterAggregation) || $hasRows; + if (!empty($currentSitePeriodRows)) { + $hasRows = $this->reduceLegacyHierarchyPeriodRowsIntoFlatTable($currentSitePeriodRows, $recordName, $flatTable, $legacyReducerCallback, $archiveProcessor, $hierarchicalRecord, $columnsAggregationOperation, $columnsToRenameAfterAggregation) || $hasRows; } return $hasRows; } - protected function reduceLegacyHierarchyPeriodRowsIntoFlatTable(array $periodRows, string $recordName, DataTable $flatTable, callable $legacyReducerCallback, ArchiveProcessor $archiveProcessor, \Piwik\ArchiveProcessor\Record $hierarchicalRecord, ?array $columnsAggregationOperation, ?array $columnsToRenameAfterAggregation) : bool + protected function reduceLegacyHierarchyPeriodRowsIntoFlatTable(array $sitePeriodRows, string $recordName, DataTable $flatTable, callable $legacyReducerCallback, ArchiveProcessor $archiveProcessor, \Piwik\ArchiveProcessor\Record $hierarchicalRecord, ?array $columnsAggregationOperation, ?array $columnsToRenameAfterAggregation) : bool { - [$legacyHierarchicalTable, $hasRows] = \Piwik\ArchiveProcessor\BlobTableAggregator::aggregateBlobRows($periodRows, $recordName, $columnsAggregationOperation, function (DataTable $table) use($archiveProcessor, $columnsToRenameAfterAggregation) : void { + [$legacyHierarchicalTable, $hasRows] = \Piwik\ArchiveProcessor\BlobTableAggregator::aggregateBlobRows($sitePeriodRows, $recordName, $columnsAggregationOperation, function (DataTable $table) use($archiveProcessor, $columnsToRenameAfterAggregation) : void { $archiveProcessor->renameColumnsAfterAggregation($table, $columnsToRenameAfterAggregation); }); if ($hasRows) { @@ -427,38 +427,52 @@ protected function isSummaryRowEmpty(Row $summaryRow) : bool return \true; } /** - * Aggregates a root blob record while discovering periods that contain the root record in a single pass. + * Aggregates a root blob record while discovering the site and period combinations that contain the + * root record in a single pass. The combinations are keyed in the same way as + * {@link BlobTableAggregator::getSitePeriodKey()}. * * @return array{0: DataTable, 1: bool, 2: array} */ protected function aggregateRootDataTableFromBlobs(ArchiveProcessor $archiveProcessor, string $recordName, ?array $columnsAggregationOperation, ?array $columnsToRenameAfterAggregation) : array { - $periodsWithRootRecord = []; + $sitePeriodsWithRootRecord = []; [$result, $hasRows] = \Piwik\ArchiveProcessor\BlobTableAggregator::aggregateBlobRows($this->querySingleBlobRows($archiveProcessor, $recordName), $recordName, $columnsAggregationOperation, function (DataTable $table) use($archiveProcessor, $columnsToRenameAfterAggregation) : void { $archiveProcessor->renameColumnsAfterAggregation($table, $columnsToRenameAfterAggregation); - }, function (array $archiveDataRow) use(&$periodsWithRootRecord, $recordName) : bool { - $period = $archiveDataRow['date1'] . ',' . $archiveDataRow['date2']; + }, function (array $archiveDataRow) use(&$sitePeriodsWithRootRecord, $recordName) : bool { + $sitePeriod = \Piwik\ArchiveProcessor\BlobTableAggregator::getSitePeriodKey($archiveDataRow); if ($archiveDataRow['name'] === $recordName) { - $periodsWithRootRecord[$period] = \true; + $sitePeriodsWithRootRecord[$sitePeriod] = \true; return \true; } - return isset($periodsWithRootRecord[$period]); + return isset($sitePeriodsWithRootRecord[$sitePeriod]); }); - return [$result, $hasRows, $periodsWithRootRecord]; + return [$result, $hasRows, $sitePeriodsWithRootRecord]; } protected function querySingleBlobRows(ArchiveProcessor $archiveProcessor, string $recordName) : iterable { - $archive = Archive::factory($archiveProcessor->getParams()->getSegment(), $archiveProcessor->getParams()->getPeriod()->getSubperiods(), [$archiveProcessor->getParams()->getSite()->getId()]); + // use the same parameters as ArchiveProcessor::getArchive(): a day period has no subperiods, so the + // period itself must be queried, and an archive can aggregate the archives of multiple sites for the + // same period (eg for roll-up day archives) + $archive = Archive::factory($archiveProcessor->getParams()->getSegment(), $archiveProcessor->getParams()->getSubPeriods(), $archiveProcessor->getParams()->getIdSites()); if (!method_exists($archive, 'querySingleBlob')) { return []; } return $archive->querySingleBlob($recordName); } + /** + * Returns one entry per site and subperiod combination the archive being built aggregates over, + * keyed in the same way as {@link BlobTableAggregator::getSitePeriodKey()}. + * + * @return array + */ protected function getAllSubperiodKeys(ArchiveProcessor $archiveProcessor) : array { $result = []; - foreach ($archiveProcessor->getParams()->getPeriod()->getSubperiods() as $period) { - $result[$period->getDateStart()->toString() . ',' . $period->getDateEnd()->toString()] = \true; + foreach ($archiveProcessor->getParams()->getIdSites() as $idSite) { + foreach ($archiveProcessor->getParams()->getSubPeriods() as $period) { + $key = \Piwik\ArchiveProcessor\BlobTableAggregator::getSitePeriodKey(['idsite' => $idSite, 'date1' => $period->getDateStart()->toString(), 'date2' => $period->getDateEnd()->toString()]); + $result[$key] = \true; + } } return $result; } diff --git a/app/core/Columns/Join.php b/app/core/Columns/Join.php index 198788f11..223133947 100644 --- a/app/core/Columns/Join.php +++ b/app/core/Columns/Join.php @@ -52,4 +52,16 @@ public function getTargetColumn() { return $this->targetColumn; } + /** + * Columns that must additionally match between the joined-from table and the joined table + * to identify a row, given as column names present on both tables. Use this when the primary + * join column is not unique on its own and needs a composite key (for example the site id). + * + * @return string[] + * @since 5.13.0 + */ + public function getAdditionalKeyColumns() + { + return []; + } } diff --git a/app/core/Columns/Join/GoalNameJoin.php b/app/core/Columns/Join/GoalNameJoin.php index 9aa95db10..86dc6cff5 100644 --- a/app/core/Columns/Join/GoalNameJoin.php +++ b/app/core/Columns/Join/GoalNameJoin.php @@ -19,4 +19,9 @@ public function __construct() { parent::__construct('goal', 'idgoal', 'name'); } + public function getAdditionalKeyColumns() + { + // a goal is identified by (idsite, idgoal), so the site must match too + return ['idsite']; + } } diff --git a/app/core/DataAccess/ArchiveSelector.php b/app/core/DataAccess/ArchiveSelector.php index f20d08360..f88951d95 100644 --- a/app/core/DataAccess/ArchiveSelector.php +++ b/app/core/DataAccess/ArchiveSelector.php @@ -449,7 +449,7 @@ public static function querySingleBlob(array $archiveIds, string $recordName) $chunk = new Chunk(); [$getValuesSql, $bind] = self::getSqlTemplateToFetchArchiveData([$recordName], Archive::ID_SUBTABLE_LOAD_ALL_SUBTABLES, \true); $archiveIdsPerMonth = self::getArchiveIdsByYearMonth($archiveIds); - $periodsSeen = []; + $sitePeriodsSeen = []; // $yearMonth = "2022-11", foreach ($archiveIdsPerMonth as $yearMonth => $ids) { $date = Date::factory($yearMonth . '-01'); @@ -474,17 +474,18 @@ public static function querySingleBlob(array $archiveIds, string $recordName) if (empty($archiveIds[$period])) { continue; } - // only use the first period/blob name combination seen (since we order by ts_archived descending) - if (!empty($periodsSeen[$period][$recordName])) { + // only use the first site/period/blob name combination seen (since we order by ts_archived descending) + if (!empty($sitePeriodsSeen[$row['idsite']][$period][$recordName])) { continue; } - $periodsSeen[$period][$recordName] = \true; + $sitePeriodsSeen[$row['idsite']][$period][$recordName] = \true; $row['value'] = \Piwik\DataAccess\ArchiveSelector::uncompress($row['value']); if ($chunk->isRecordNameAChunk($row['name'])) { // $blobs = array([subtableID] = [blob of subtableId]) $blobs = Common::safe_unserialize($row['value']); if (!is_array($blobs)) { (yield $row); + continue; } ksort($blobs); // $rawName = eg 'PluginName_ArchiveName' @@ -505,8 +506,8 @@ public static function querySingleBlob(array $archiveIds, string $recordName) * * @param array $recordNames The list of records to look for. * @param string|int $idSubtable The idSubtable to look for or 'all' to load all of them. - * @param boolean $orderBySubtableId If true, orders the result set by start date ascending, subtable ID - * ascending and ts_archived descending. Only applied if loading all + * @param boolean $orderBySubtableId If true, orders the result set by start date ascending, site ID ascending, + * subtable ID ascending and ts_archived descending. Only applied if loading all * subtables for a single record. * * This parameter is used when aggregating blob data for a single record @@ -532,7 +533,7 @@ private static function getSqlTemplateToFetchArchiveData(array $recordNames, $id $bind = array($name, addcslashes($name, '%_') . '%'); if ($orderBySubtableId && count($recordNames) == 1) { $idSubtableAsInt = self::getExtractIdSubtableFromBlobNameSql($chunk, $name); - $orderBy = "ORDER BY date1 ASC, " . " {$idSubtableAsInt} ASC,\n ts_archived DESC"; + $orderBy = "ORDER BY date1 ASC, " . " idsite ASC, " . " {$idSubtableAsInt} ASC,\n ts_archived DESC"; // ascending order so we use the latest data found } } else { diff --git a/app/core/DataAccess/LogAggregator.php b/app/core/DataAccess/LogAggregator.php index 631fb6c62..42fc66f97 100644 --- a/app/core/DataAccess/LogAggregator.php +++ b/app/core/DataAccess/LogAggregator.php @@ -419,7 +419,8 @@ public static function getConversionsMetricFields() } private static function getSqlConversionRevenueSum(string $field) : string { - return self::getSqlRevenue('SUM(' . self::LOG_CONVERSION_TABLE . '.' . $field . ')'); + $column = self::LOG_CONVERSION_TABLE . '.' . $field; + return self::getSqlRevenue(self::getSqlSumExcludingOutOfRange($column, $column)); } /** * @param string $field @@ -429,6 +430,28 @@ public static function getSqlRevenue($field) { return "ROUND(" . $field . "," . GoalManager::REVENUE_PRECISION . ")"; } + /** + * Wraps a money value expression in a SUM() that excludes rows whose guarding money + * column is outside the tracked-value bound (GoalManager::MAX_ALLOWED_REVENUE). + * + * Such rows are rejected at tracking time; excluding them here keeps archiving + * consistent with tracking for values that were stored before the bound existed. + * It is used for both the ecommerce item metrics (guarded on log_conversion_item.price) + * and the order-level revenue metrics (each guarded on its own log_conversion column), + * so the item and order aggregations treat legacy out-of-range values the same way. + * + * It also removes the archiving overflow vector: once every money value is bounded by + * |value| <= 1e12 (and item quantity by its INT UNSIGNED column, <= ~4.29e9), neither + * quantity * price nor the summed totals can exceed the MySQL DOUBLE range and abort + * archiving with error 1690. + * + * @param string $guardColumn the money column whose magnitude decides row exclusion + * @param string $valueExpression the expression summed for kept rows (0 is summed otherwise) + */ + private static function getSqlSumExcludingOutOfRange(string $guardColumn, string $valueExpression) : string + { + return sprintf('SUM(CASE WHEN ABS(%s) > %d THEN 0 ELSE %s END)', $guardColumn, GoalManager::MAX_ALLOWED_REVENUE, $valueExpression); + } /** * Helper function that returns an array with common metrics for a given log_visit field distinct values. * @@ -778,7 +801,7 @@ public function queryEcommerceItems($dimension) { $query = $this->generateQuery( // SELECT ... - implode(', ', array("log_action.name AS label", sprintf("log_conversion_item.%s AS labelIdAction", $dimension), sprintf('%s AS `%d`', self::getSqlRevenue('SUM(log_conversion_item.quantity * log_conversion_item.price)'), Metrics::INDEX_ECOMMERCE_ITEM_REVENUE), sprintf('%s AS `%d`', self::getSqlRevenue('SUM(log_conversion_item.quantity)'), Metrics::INDEX_ECOMMERCE_ITEM_QUANTITY), sprintf('%s AS `%d`', self::getSqlRevenue('SUM(log_conversion_item.price)'), Metrics::INDEX_ECOMMERCE_ITEM_PRICE), sprintf('COUNT(distinct log_conversion_item.idorder) AS `%d`', Metrics::INDEX_ECOMMERCE_ORDERS), sprintf('COUNT(distinct log_conversion_item.idvisit) AS `%d`', Metrics::INDEX_NB_VISITS), sprintf('CASE log_conversion_item.idorder WHEN \'0\' THEN %d ELSE %d END AS ecommerceType', GoalManager::IDGOAL_CART, GoalManager::IDGOAL_ORDER))), + implode(', ', array("log_action.name AS label", sprintf("log_conversion_item.%s AS labelIdAction", $dimension), sprintf('%s AS `%d`', self::getSqlRevenue(self::getSqlSumExcludingOutOfRange('log_conversion_item.price', 'log_conversion_item.quantity * log_conversion_item.price')), Metrics::INDEX_ECOMMERCE_ITEM_REVENUE), sprintf('%s AS `%d`', self::getSqlRevenue('SUM(log_conversion_item.quantity)'), Metrics::INDEX_ECOMMERCE_ITEM_QUANTITY), sprintf('%s AS `%d`', self::getSqlRevenue(self::getSqlSumExcludingOutOfRange('log_conversion_item.price', 'log_conversion_item.price')), Metrics::INDEX_ECOMMERCE_ITEM_PRICE), sprintf('COUNT(distinct log_conversion_item.idorder) AS `%d`', Metrics::INDEX_ECOMMERCE_ORDERS), sprintf('COUNT(distinct log_conversion_item.idvisit) AS `%d`', Metrics::INDEX_NB_VISITS), sprintf('CASE log_conversion_item.idorder WHEN \'0\' THEN %d ELSE %d END AS ecommerceType', GoalManager::IDGOAL_CART, GoalManager::IDGOAL_ORDER))), // FROM ... array("log_conversion_item", array("table" => "log_action", "joinOn" => sprintf("log_conversion_item.%s = log_action.idaction", $dimension))), // WHERE ... AND ... @@ -983,7 +1006,7 @@ public function queryConversionsByDimension($dimensions = [], $where = \false, $ */ public function queryConversionsByPageView(string $linkField, int $idGoal) { - $select = "\n log_conversion.idvisit AS idvisit,\n " . $idGoal . " AS idgoal,\n " . ($linkField == 'idaction_url' ? Action::TYPE_PAGE_URL : Action::TYPE_PAGE_TITLE) . " AS `type`,\n lac.idaction AS idaction, \n COUNT(*) AS `1`, \n " . sprintf("ROUND(SUM(log_conversion.revenue),2) AS `%d`,", Metrics::INDEX_GOAL_REVENUE) . "\n " . sprintf("COUNT(log_conversion.idvisit) AS `%d`,", Metrics::INDEX_GOAL_NB_VISITS_CONVERTED) . "\n " . sprintf("ROUND(SUM(1 / log_conversion.pageviews_before * log_conversion.revenue_subtotal),2) AS `%d`,", Metrics::INDEX_GOAL_ECOMMERCE_REVENUE_SUBTOTAL) . "\n " . sprintf("ROUND(SUM(1 / log_conversion.pageviews_before * log_conversion.revenue_tax),2) AS `%d`,", Metrics::INDEX_GOAL_ECOMMERCE_REVENUE_TAX) . "\n " . sprintf("ROUND(SUM(1 / log_conversion.pageviews_before * log_conversion.revenue_shipping),2) AS `%d`,", Metrics::INDEX_GOAL_ECOMMERCE_REVENUE_SHIPPING) . "\n " . sprintf("ROUND(SUM(1 / log_conversion.pageviews_before * log_conversion.revenue_discount),2) AS `%d`,", Metrics::INDEX_GOAL_ECOMMERCE_REVENUE_DISCOUNT) . "\n " . sprintf("SUM(ROUND(1 / log_conversion.pageviews_before * log_conversion.items, 4)) AS `%d`,", Metrics::INDEX_GOAL_ECOMMERCE_ITEMS) . "\n " . sprintf("log_conversion.pageviews_before AS `%d`,", Metrics::INDEX_GOAL_NB_PAGES_UNIQ_BEFORE) . "\n " . sprintf("SUM(ROUND(1 / log_conversion.pageviews_before, 4)) AS `%d`,", Metrics::INDEX_GOAL_NB_CONVERSIONS_ATTRIB) . "\n " . sprintf("COUNT(*) AS `%d`,", Metrics::INDEX_GOAL_NB_CONVERSIONS_PAGE_UNIQ) . "\n " . sprintf("ROUND(SUM(1 / log_conversion.pageviews_before * log_conversion.revenue),2) AS `%d`", Metrics::INDEX_GOAL_REVENUE_ATTRIB); + $select = "\n log_conversion.idvisit AS idvisit,\n " . $idGoal . " AS idgoal,\n " . ($linkField == 'idaction_url' ? Action::TYPE_PAGE_URL : Action::TYPE_PAGE_TITLE) . " AS `type`,\n lac.idaction AS idaction, \n COUNT(*) AS `1`, \n " . sprintf("ROUND(%s,2) AS `%d`,", self::getSqlSumExcludingOutOfRange('log_conversion.revenue', 'log_conversion.revenue'), Metrics::INDEX_GOAL_REVENUE) . "\n " . sprintf("COUNT(log_conversion.idvisit) AS `%d`,", Metrics::INDEX_GOAL_NB_VISITS_CONVERTED) . "\n " . sprintf("ROUND(%s,2) AS `%d`,", self::getSqlSumExcludingOutOfRange('log_conversion.revenue_subtotal', '1 / log_conversion.pageviews_before * log_conversion.revenue_subtotal'), Metrics::INDEX_GOAL_ECOMMERCE_REVENUE_SUBTOTAL) . "\n " . sprintf("ROUND(%s,2) AS `%d`,", self::getSqlSumExcludingOutOfRange('log_conversion.revenue_tax', '1 / log_conversion.pageviews_before * log_conversion.revenue_tax'), Metrics::INDEX_GOAL_ECOMMERCE_REVENUE_TAX) . "\n " . sprintf("ROUND(%s,2) AS `%d`,", self::getSqlSumExcludingOutOfRange('log_conversion.revenue_shipping', '1 / log_conversion.pageviews_before * log_conversion.revenue_shipping'), Metrics::INDEX_GOAL_ECOMMERCE_REVENUE_SHIPPING) . "\n " . sprintf("ROUND(%s,2) AS `%d`,", self::getSqlSumExcludingOutOfRange('log_conversion.revenue_discount', '1 / log_conversion.pageviews_before * log_conversion.revenue_discount'), Metrics::INDEX_GOAL_ECOMMERCE_REVENUE_DISCOUNT) . "\n " . sprintf("SUM(ROUND(1 / log_conversion.pageviews_before * log_conversion.items, 4)) AS `%d`,", Metrics::INDEX_GOAL_ECOMMERCE_ITEMS) . "\n " . sprintf("log_conversion.pageviews_before AS `%d`,", Metrics::INDEX_GOAL_NB_PAGES_UNIQ_BEFORE) . "\n " . sprintf("SUM(ROUND(1 / log_conversion.pageviews_before, 4)) AS `%d`,", Metrics::INDEX_GOAL_NB_CONVERSIONS_ATTRIB) . "\n " . sprintf("COUNT(*) AS `%d`,", Metrics::INDEX_GOAL_NB_CONVERSIONS_PAGE_UNIQ) . "\n " . sprintf("ROUND(%s,2) AS `%d`", self::getSqlSumExcludingOutOfRange('log_conversion.revenue', '1 / log_conversion.pageviews_before * log_conversion.revenue'), Metrics::INDEX_GOAL_REVENUE_ATTRIB); $from = ['log_conversion', ['table' => 'log_link_visit_action', 'tableAlias' => 'logva', 'join' => 'RIGHT JOIN', 'joinOn' => 'log_conversion.idvisit = logva.idvisit'], ['table' => 'log_action', 'tableAlias' => 'lac', 'joinOn' => 'logva.' . $linkField . ' = lac.idaction']]; $where = $this->getWhereStatement('log_conversion', 'server_time'); $where .= sprintf('AND log_conversion.idgoal = %d @@ -1001,7 +1024,7 @@ public function queryConversionsByPageView(string $linkField, int $idGoal) public function queryConversionsByEntryPageView(string $linkField, int $rankingQueryLimit = 0) { $tableName = self::LOG_CONVERSION_TABLE; - $select = implode(', ', ['log_conversion.idgoal AS idgoal', sprintf('log_visit.%s AS idaction', $linkField), 'log_action.type', sprintf('COUNT(*) AS `%d`', Metrics::INDEX_GOAL_NB_CONVERSIONS), sprintf('COUNT(distinct log_conversion.idvisit) AS `%d`', Metrics::INDEX_GOAL_NB_VISITS_CONVERTED), sprintf('%s AS `%d`', self::getSqlRevenue('SUM(log_conversion.revenue)'), Metrics::INDEX_GOAL_REVENUE_ENTRY), sprintf('%s AS `%d`', self::getSqlRevenue('SUM(log_conversion.revenue_subtotal)'), Metrics::INDEX_GOAL_ECOMMERCE_REVENUE_SUBTOTAL), sprintf('%s AS `%d`', self::getSqlRevenue('SUM(log_conversion.revenue_tax)'), Metrics::INDEX_GOAL_ECOMMERCE_REVENUE_TAX), sprintf('%s AS `%d`', self::getSqlRevenue('SUM(log_conversion.revenue_shipping)'), Metrics::INDEX_GOAL_ECOMMERCE_REVENUE_SHIPPING), sprintf('%s AS `%d`', self::getSqlRevenue('SUM(log_conversion.revenue_discount)'), Metrics::INDEX_GOAL_ECOMMERCE_REVENUE_DISCOUNT), sprintf('SUM(log_conversion.items) AS `%d`', Metrics::INDEX_GOAL_ECOMMERCE_ITEMS), sprintf('COUNT(*) AS `%d`', Metrics::INDEX_GOAL_NB_CONVERSIONS_ENTRY)]); + $select = implode(', ', ['log_conversion.idgoal AS idgoal', sprintf('log_visit.%s AS idaction', $linkField), 'log_action.type', sprintf('COUNT(*) AS `%d`', Metrics::INDEX_GOAL_NB_CONVERSIONS), sprintf('COUNT(distinct log_conversion.idvisit) AS `%d`', Metrics::INDEX_GOAL_NB_VISITS_CONVERTED), sprintf('%s AS `%d`', self::getSqlRevenue(self::getSqlSumExcludingOutOfRange('log_conversion.revenue', 'log_conversion.revenue')), Metrics::INDEX_GOAL_REVENUE_ENTRY), sprintf('%s AS `%d`', self::getSqlRevenue(self::getSqlSumExcludingOutOfRange('log_conversion.revenue_subtotal', 'log_conversion.revenue_subtotal')), Metrics::INDEX_GOAL_ECOMMERCE_REVENUE_SUBTOTAL), sprintf('%s AS `%d`', self::getSqlRevenue(self::getSqlSumExcludingOutOfRange('log_conversion.revenue_tax', 'log_conversion.revenue_tax')), Metrics::INDEX_GOAL_ECOMMERCE_REVENUE_TAX), sprintf('%s AS `%d`', self::getSqlRevenue(self::getSqlSumExcludingOutOfRange('log_conversion.revenue_shipping', 'log_conversion.revenue_shipping')), Metrics::INDEX_GOAL_ECOMMERCE_REVENUE_SHIPPING), sprintf('%s AS `%d`', self::getSqlRevenue(self::getSqlSumExcludingOutOfRange('log_conversion.revenue_discount', 'log_conversion.revenue_discount')), Metrics::INDEX_GOAL_ECOMMERCE_REVENUE_DISCOUNT), sprintf('SUM(log_conversion.items) AS `%d`', Metrics::INDEX_GOAL_ECOMMERCE_ITEMS), sprintf('COUNT(*) AS `%d`', Metrics::INDEX_GOAL_NB_CONVERSIONS_ENTRY)]); $from = [$tableName, ["table" => "log_visit", "joinOn" => "log_visit.idvisit = log_conversion.idvisit"], ["table" => "log_action", "joinOn" => "log_action.idaction = log_visit." . $linkField]]; $where = $linkField . ' IS NOT NULL AND log_conversion.idgoal >= 0'; $where = $this->getWhereStatement($tableName, self::CONVERSION_DATETIME_FIELD, $where); diff --git a/app/core/DataTable.php b/app/core/DataTable.php index 2c54ae082..00062757f 100644 --- a/app/core/DataTable.php +++ b/app/core/DataTable.php @@ -191,6 +191,13 @@ class DataTable implements DataTableInterface, \IteratorAggregate, \ArrayAccess * Name for metadata that stores array of generic filters that should not be run on the table. */ public const GENERIC_FILTERS_TO_DISABLE_METADATA_NAME = 'generic_filters_to_disable'; + /** + * Name for metadata that describes whether the totals row only totals the rows matching the + * table search of the request, instead of every row of the report. + * + * The report totals stay available in the `totals` metadata when this is set. + */ + public const TOTALS_ROW_IS_FILTERED_METADATA_NAME = 'totalsRowIsFiltered'; /** The ID of the Summary Row. */ public const ID_SUMMARY_ROW = -1; /** diff --git a/app/core/Db/Schema/Mariadb.php b/app/core/Db/Schema/Mariadb.php index 3a6b34c3a..b5fe0eb5e 100644 --- a/app/core/Db/Schema/Mariadb.php +++ b/app/core/Db/Schema/Mariadb.php @@ -52,17 +52,18 @@ public function supportsRankingRollupWithoutExtraSorting() : bool public function hasReachedEOL() : bool { $currentVersion = $this->getVersion(); + $isEnterprise = \false !== strpos($currentVersion, 'enterprise'); // End of security update for certain MariaDb versions as of https://mariadb.org/about/#maintenance-policy - // Support for 10.6 LTS ends on 6th July 2026 - if (version_compare($currentVersion, '10.6', '>=') && version_compare($currentVersion, '10.7', '<') && Date::today()->isEarlier(Date::factory('2026-07-07'))) { + // Community Support for 10.6 LTS ends on 6th July 2026, Enterprise on 23rd August 2029 + if (version_compare($currentVersion, '10.6', '>=') && version_compare($currentVersion, '10.7', '<') && Date::today()->isEarlier(Date::factory($isEnterprise ? '2029-08-24' : '2026-07-07'))) { return \false; } // Support for 10.11 LTS ends on 16th February 2028 if (version_compare($currentVersion, '10.11', '>=') && version_compare($currentVersion, '10.12', '<') && Date::today()->isEarlier(Date::factory('2028-02-17'))) { return \false; } - // Support for 11.4 LTS ends on 29th May 2029 - if (version_compare($currentVersion, '11.4', '>=') && version_compare($currentVersion, '11.5', '<') && Date::today()->isEarlier(Date::factory('2029-05-30'))) { + // Community Support for 11.4 LTS ends on 29th May 2029, Enterprise on 16th January 2033 + if (version_compare($currentVersion, '11.4', '>=') && version_compare($currentVersion, '11.5', '<') && Date::today()->isEarlier(Date::factory($isEnterprise ? '2033-01-17' : '2029-05-30'))) { return \false; } // Support for all versions prior to 11.8 (not covered by conditions above) already ended diff --git a/app/core/Filesystem.php b/app/core/Filesystem.php index 02652f94f..58efa7bb9 100644 --- a/app/core/Filesystem.php +++ b/app/core/Filesystem.php @@ -386,7 +386,7 @@ public static function getFileSize($pathToFile, $unit = 'B') throw new \Exception('Invalid unit given'); } if (!file_exists($pathToFile)) { - return; + return null; } $filesize = filesize($pathToFile); $factor = $units[$unit]; diff --git a/app/core/Http.php b/app/core/Http.php index 54402fb37..ee2660d89 100644 --- a/app/core/Http.php +++ b/app/core/Http.php @@ -12,6 +12,8 @@ use Exception; use Piwik\Config\GeneralConfig; use Piwik\Container\StaticContainer; +use Piwik\Http\EgressBlockedException; +use Piwik\Http\EgressHostValidator; /** * Contains HTTP client related helper methods that can retrieve content from remote servers * and optionally save to a local file. @@ -73,6 +75,14 @@ protected static function isCurlEnabled() * @param string $httpPassword HTTP Auth password * @param bool $checkHostIsAllowed whether we should check if the target host is allowed or not. This should only * be set to false when using a hardcoded URL. + * @param bool $validateEgressIp when true, serves the request over the SSRF-safe path: the resolved host must be a + * public IP (or covered by `[General] allowed_private_egress_ranges`), every redirect + * hop is re-validated and the connection pinned to it. Use this whenever the URL comes + * from untrusted input (e.g. a site's own configured URL). + * Requires curl, bypasses any configured or environment proxy, retains the method and + * body across same-origin hops, drops credentials, caller headers and the body on an + * origin change, and does not follow redirects when downloading to a file. + * A refused target or unmet precondition throws {@see EgressBlockedException}. * * @return string|array|bool If `$destinationPath` is not specified the HTTP response is returned on success. `false` * is returned on failure. @@ -89,12 +99,21 @@ protected static function isCurlEnabled() * @phpstan-return ($destinationPath is null ? ($getExtendedInfo is true ? array{status: ?int, headers?: ?array, data?: ?string} : string|false) : bool) * @api */ - public static function sendHttpRequest($aUrl, $timeout, $userAgent = null, $destinationPath = null, $followDepth = 0, $acceptLanguage = \false, $byteRange = \false, $getExtendedInfo = \false, $httpMethod = 'GET', $httpUsername = null, $httpPassword = null, $checkHostIsAllowed = \true) + public static function sendHttpRequest($aUrl, $timeout, $userAgent = null, $destinationPath = null, $followDepth = 0, $acceptLanguage = \false, $byteRange = \false, $getExtendedInfo = \false, $httpMethod = 'GET', $httpUsername = null, $httpPassword = null, $checkHostIsAllowed = \true, $validateEgressIp = \false) { // create output file $file = self::ensureDestinationDirectoryExists($destinationPath); + $transport = self::getTransportMethod(); + if ($validateEgressIp) { + // The SSRF-safe path only pins and re-validates reliably over curl, so fail + // closed rather than silently degrade to an unprotected transport. + if (!self::isCurlEnabled()) { + throw new EgressBlockedException('SSRF-safe HTTP requests require the curl PHP extension.'); + } + $transport = 'curl'; + } $acceptLanguage = $acceptLanguage ? 'Accept-Language: ' . $acceptLanguage : ''; - return self::sendHttpRequestBy(self::getTransportMethod(), $aUrl, $timeout, $userAgent, $destinationPath, $file, $followDepth ?? 0, $acceptLanguage, $acceptInvalidSslCertificate = \false, $byteRange, $getExtendedInfo, $httpMethod, $httpUsername, $httpPassword, null, [], null, $checkHostIsAllowed); + return self::sendHttpRequestBy($transport, $aUrl, $timeout, $userAgent, $destinationPath, $file, $followDepth ?? 0, $acceptLanguage, $acceptInvalidSslCertificate = \false, $byteRange, $getExtendedInfo, $httpMethod, $httpUsername, $httpPassword, null, [], null, $checkHostIsAllowed, $validateEgressIp); } /** * @param string|null $destinationPath @@ -112,6 +131,21 @@ public static function ensureDestinationDirectoryExists($destinationPath) } return null; } + /** + * Throws when the host matches any `http.blocklist.hosts` wildcard rule. + */ + private static function assertHostNotBlocked(?string $host) : void + { + if (empty($host)) { + return; + } + $disallowedHosts = StaticContainer::get('http.blocklist.hosts'); + foreach ($disallowedHosts as $disallowedHost) { + if (preg_match(self::convertWildcardToPattern($disallowedHost), $host) === 1) { + throw new Exception(sprintf('Hostname %s is in list of disallowed hosts', $host)); + } + } + } private static function convertWildcardToPattern(string $wildcardHost) : string { $flexibleStart = $flexibleEnd = \false; @@ -152,14 +186,19 @@ private static function convertWildcardToPattern(string $wildcardHost) : string * @param string|null $httpPassword HTTP Auth password * @param array|string|null $requestBody If $httpMethod is 'POST' this may accept an array of variables or a string that needs to be posted * @param array $additionalHeaders List of additional headers to set for the request - * @param bool|null $forcePost If true, forces POST redirects to remain POST requests (curl only). + * @param bool|null $forcePost If true, forces POST redirects to remain POST requests (curl only). Ignored on the + * `$validateEgressIp` path, where the method and body are retained on same-origin + * redirects only and cross-origin redirects are downgraded to GET without a body. * @param bool $checkHostIsAllowed whether we should check if the target host is allowed or not. This should only * be set to false when using a hardcoded URL. + * @param bool $validateEgressIp when true, the request is served over the SSRF-safe path: public-IP validation, + * manual per-hop redirect re-validation and connection pinning. See + * {@see sendHttpRequest()} for the full contract. * * @return ($destinationPath is null ? ($getExtendedInfo is true ? array{status: ?int, headers?: ?array, data?: ?string} : string|false) : bool) * @throws Exception */ - public static function sendHttpRequestBy($method, $aUrl, $timeout, $userAgent = null, $destinationPath = null, $file = null, $followDepth = 0, $acceptLanguage = \false, $acceptInvalidSslCertificate = \false, $byteRange = \false, $getExtendedInfo = \false, $httpMethod = 'GET', $httpUsername = null, $httpPassword = null, $requestBody = null, $additionalHeaders = array(), $forcePost = null, $checkHostIsAllowed = \true) + public static function sendHttpRequestBy($method, $aUrl, $timeout, $userAgent = null, $destinationPath = null, $file = null, $followDepth = 0, $acceptLanguage = \false, $acceptInvalidSslCertificate = \false, $byteRange = \false, $getExtendedInfo = \false, $httpMethod = 'GET', $httpUsername = null, $httpPassword = null, $requestBody = null, $additionalHeaders = array(), $forcePost = null, $checkHostIsAllowed = \true, $validateEgressIp = \false) { if ($followDepth > 5) { throw new Exception('Too many redirects (' . $followDepth . ')'); @@ -181,16 +220,47 @@ public static function sendHttpRequestBy($method, $aUrl, $timeout, $userAgent = throw new Exception(sprintf('Protocol %s not in list of allowed protocols: %s', $parsedUrl['scheme'], $allowedProtocols)); } if ($checkHostIsAllowed) { - $disallowedHosts = StaticContainer::get('http.blocklist.hosts'); - $isBlocked = \false; - foreach ($disallowedHosts as $host) { - if (!empty($parsedUrl['host']) && preg_match(self::convertWildcardToPattern($host), $parsedUrl['host']) === 1) { - $isBlocked = \true; - break; + self::assertHostNotBlocked($parsedUrl['host'] ?? null); + } + // SSRF-safe path: only curl can pin the validated address + // we handle redirects manually below, and refuse any other transport + // or a forward proxy rather than fetch unsafely. + $pinnedResolveEntry = null; + if ($validateEgressIp) { + if ($method !== 'curl') { + throw new EgressBlockedException('SSRF-safe HTTP requests require the curl transport.'); + } + if (!self::isCurlEnabled()) { + throw new EgressBlockedException('SSRF-safe HTTP requests require the curl PHP extension.'); + } + // Restrict to http(s): other schemes have different default ports + $scheme = strtolower((string) $parsedUrl['scheme']); + if ($scheme !== 'http' && $scheme !== 'https') { + throw new EgressBlockedException('SSRF-safe HTTP requests only support the http and https schemes.'); + } + [$configuredProxyHost] = self::getProxyConfiguration($aUrl); + if (!empty($configuredProxyHost)) { + throw new EgressBlockedException('SSRF-safe HTTP requests cannot be routed through a configured proxy.'); + } + $effectivePort = isset($parsedUrl['port']) ? (int) $parsedUrl['port'] : ($scheme === 'https' ? 443 : 80); + // Resolved via DI so tests can substitute a validator that accepts the local fixture server. + [$canonicalHost, $pinnedIp] = StaticContainer::get(EgressHostValidator::class)->resolveTarget((string) ($parsedUrl['host'] ?? '')); + // Rewrite the URL to the canonical host when it differs (IDN folding, casing, a trailing dot) + if ($canonicalHost !== trim((string) ($parsedUrl['host'] ?? ''), '[]')) { + $aUrl = self::replaceUrlHost($parsedUrl, $canonicalHost); + // Re-check the blocklist against the host curl will actually connect to. The check + // above ran on the raw host, so canonicalisation (a trailing dot, IDN folding) could + // otherwise slip a blocked host like "s3.amazonaws.com." past the wildcard rules. + if ($checkHostIsAllowed) { + self::assertHostNotBlocked($canonicalHost); } } - if ($isBlocked) { - throw new Exception(sprintf('Hostname %s is in list of disallowed hosts', $parsedUrl['host'])); + // For a DNS host, pin the name to the validated IP so curl cannot re-resolve to + // a different address. An IP literal (canonicalHost === pinnedIp) needs no pin. + // @todo PHP 8.1 min: strpos($pinnedIp, ':') !== false can become str_contains(). + if ($canonicalHost !== $pinnedIp) { + $pinnedAddress = strpos($pinnedIp, ':') !== \false ? '[' . $pinnedIp . ']' : $pinnedIp; + $pinnedResolveEntry = $canonicalHost . ':' . $effectivePort . ':' . $pinnedAddress; } } // When sending an insecure request, but https is forced, and we would care about valid certificates, log a warning @@ -228,7 +298,7 @@ public static function sendHttpRequestBy($method, $aUrl, $timeout, $userAgent = if ($httpAuthIsUsed) { $httpAuth = 'Authorization: Basic ' . base64_encode($httpUsername . ':' . $httpPassword) . "\r\n"; } - $httpEventParams = array('httpMethod' => $httpMethod, 'body' => $requestBody, 'userAgent' => $userAgent, 'timeout' => $timeout, 'headers' => array_map('trim', array_filter(array_merge([$rangeHeader, $via, $httpAuth, $acceptLanguage], $additionalHeaders))), 'verifySsl' => !$acceptInvalidSslCertificate, 'destinationPath' => $destinationPath); + $httpEventParams = array('httpMethod' => $httpMethod, 'body' => $requestBody, 'userAgent' => $userAgent, 'timeout' => $timeout, 'headers' => array_map('trim', array_filter(array_merge([$rangeHeader, $via, $httpAuth, $acceptLanguage], $additionalHeaders))), 'verifySsl' => !$acceptInvalidSslCertificate, 'destinationPath' => $destinationPath, 'validateEgressIp' => $validateEgressIp); /** * Triggered to send an HTTP request. Allows plugins to resolve the HTTP request themselves or to find out * when an HTTP request is triggered to log this information for example to a monitoring tool. @@ -242,6 +312,8 @@ public static function sendHttpRequestBy($method, $aUrl, $timeout, $userAgent = * - 'headers' An array of header strings like array('Accept-Language: en', '...') * - 'verifySsl' A boolean whether SSL certificate should be verified * - 'destinationPath' If set, the response of the HTTP request should be saved to this file + * - 'validateEgressIp' Whether the caller asked for SSRF-safe semantics. A listener + * resolving the request itself must honour them or leave it unhandled * @param string &$response A plugin listening to this event should assign the HTTP response it received to this variable, for example "{value: true}" * @param int &$status A plugin listening to this event should assign the HTTP status code it received to this variable, for example "200" * @param array &$headers A plugin listening to this event should assign the HTTP headers it received to this variable, eg array('Content-Length' => '5') @@ -530,11 +602,25 @@ public static function sendHttpRequestBy($method, $aUrl, $timeout, $userAgent = } @curl_setopt_array($ch, $curl_options); self::configCurlCertificate($ch); + if ($validateEgressIp) { + // Follow redirects manually so every hop is re-validated, and pin + // the connection to the address we validated to close the DNS-rebinding window. + // CURLOPT_RESOLVE keeps the original hostname for SNI and certificate checks. + // @todo when switching to a new HTTP library: this transport-specific + // pinning/redirect wiring should probably be re-implemented against it (e.g. Guzzle's + // curl.options + redirect middleware). EgressHostValidator is reusable as-is. + @curl_setopt($ch, \CURLOPT_FOLLOWLOCATION, \false); + // Disable any environment proxy (http_proxy etc.) so it cannot re-resolve the host and bypass the pin. + @curl_setopt($ch, \CURLOPT_PROXY, ''); + if ($pinnedResolveEntry !== null) { + @curl_setopt($ch, \CURLOPT_RESOLVE, array($pinnedResolveEntry)); + } + } /* * as of php 5.2.0, CURLOPT_FOLLOWLOCATION can't be set if * in safe_mode or open_basedir is set */ - if ((string) ini_get('safe_mode') == '' && ini_get('open_basedir') == '') { + if (!$validateEgressIp && (string) ini_get('safe_mode') == '' && ini_get('open_basedir') == '') { $protocols = 0; foreach (explode(',', $allowedProtocols) as $protocol) { if (defined('CURLPROTO_' . strtoupper(trim($protocol)))) { @@ -590,8 +676,41 @@ public static function sendHttpRequestBy($method, $aUrl, $timeout, $userAgent = $contentLength = @curl_getinfo($ch, \CURLINFO_CONTENT_LENGTH_DOWNLOAD); $fileLength = is_resource($file) ? @curl_getinfo($ch, \CURLINFO_SIZE_DOWNLOAD) : strlen($response); $status = @curl_getinfo($ch, \CURLINFO_HTTP_CODE); + $elapsed = (float) @curl_getinfo($ch, \CURLINFO_TOTAL_TIME); + $curlRedirectUrl = (string) @curl_getinfo($ch, \CURLINFO_REDIRECT_URL); @curl_close($ch); unset($ch); + // SSRF-safe path follows redirects manually so each hop is re-validated and re-pinned. + if ($validateEgressIp && $status >= 300 && $status < 400 && $status !== 304) { + if (is_resource($file)) { + // CURLOPT_HEADER is off for file downloads, so the Location cannot be re-validated: fail closed. + @fclose($file); + if ($destinationPath) { + @unlink($destinationPath); + } + throw new EgressBlockedException('SSRF-safe HTTP requests cannot follow redirects when downloading to a file.'); + } + // Read from curl, not from $headers: the splitter above keeps the last "HTTP/" block + // it finds, so a response body can forge one. + $redirectUrl = $curlRedirectUrl; + if ($redirectUrl !== '') { + // Cross-origin: drop credentials, caller headers and the body, and downgrade to GET. + // $acceptInvalidSslCertificate stays, since it guards no secret past this point and + // dropping it would fail the common http -> https hop for self-signed sites. + if (!self::urlsSameOrigin($aUrl, $redirectUrl)) { + $httpUsername = null; + $httpPassword = null; + $additionalHeaders = array(); + $requestBody = null; + $httpMethod = 'GET'; + $forcePost = null; + } + // Shrink the timeout by what this hop already spent so the whole redirect chain + // stays within the caller's original budget instead of granting it to every hop. + $remainingTimeout = max(1, (int) floor($timeout - $elapsed)); + return self::sendHttpRequestBy($method, $redirectUrl, $remainingTimeout, $userAgent, $destinationPath, $file, $followDepth + 1, $acceptLanguage, $acceptInvalidSslCertificate, $byteRange, $getExtendedInfo, $httpMethod, $httpUsername, $httpPassword, $requestBody, $additionalHeaders, $forcePost, $checkHostIsAllowed, $validateEgressIp); + } + } } else { throw new Exception('Invalid request method: ' . $method); } @@ -617,6 +736,8 @@ public static function sendHttpRequestBy($method, $aUrl, $timeout, $userAgent = * - 'headers' An array of header strings like array('Accept-Language: en', '...') * - 'verifySsl' A boolean whether SSL certificate should be verified * - 'destinationPath' If set, the response of the HTTP request should be saved to this file + * - 'validateEgressIp' Whether the caller asked for SSRF-safe semantics. A listener + * resolving the request itself must honour them or leave it unhandled * @param string &$response The response of the HTTP request, for example "{value: true}" * @param int &$status The returned HTTP status code, for example "200" * @param array &$headers The returned headers, eg array('Content-Length' => '5') @@ -812,6 +933,47 @@ private static function parseHeaderLine(&$headers, $line) : void $headers[$camelName] = trim($value); } } + /** + * Whether two URLs share the same origin (scheme, host and effective port). Fails closed: + * a parse failure or missing component counts as a different origin. + */ + private static function urlsSameOrigin(string $urlA, string $urlB) : bool + { + $a = @parse_url($urlA); + $b = @parse_url($urlB); + if (!is_array($a) || !is_array($b) || !isset($a['host'], $b['host'], $a['scheme'], $b['scheme'])) { + return \false; + } + $schemeA = strtolower($a['scheme']); + $schemeB = strtolower($b['scheme']); + if ($schemeA !== $schemeB || strcasecmp($a['host'], $b['host']) !== 0) { + return \false; + } + $defaultPort = $schemeA === 'https' ? 443 : 80; + $portA = isset($a['port']) ? (int) $a['port'] : $defaultPort; + $portB = isset($b['port']) ? (int) $b['port'] : $defaultPort; + return $portA === $portB; + } + /** + * Rebuilds a URL from its parse_url() parts with a replacement host, preserving every other + * component. Used on the SSRF-safe path, so the connected URL carries the pinned canonical host. + * + * @param array $parts + */ + private static function replaceUrlHost(array $parts, string $newHost) : string + { + // @todo PHP 8.1 min (Matomo 6): strpos($newHost, ':') !== false can become str_contains(). + $scheme = isset($parts['scheme']) ? $parts['scheme'] . '://' : ''; + $user = (string) ($parts['user'] ?? ''); + $pass = isset($parts['pass']) ? ':' . $parts['pass'] : ''; + $auth = $user !== '' ? $user . $pass . '@' : ''; + $hostPart = strpos($newHost, ':') !== \false ? '[' . $newHost . ']' : $newHost; + $port = isset($parts['port']) ? ':' . $parts['port'] : ''; + $path = (string) ($parts['path'] ?? ''); + $query = isset($parts['query']) ? '?' . $parts['query'] : ''; + $fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : ''; + return $scheme . $auth . $hostPart . $port . $path . $query . $fragment; + } /** * Utility function that truncates a string to an arbitrary limit. * diff --git a/app/core/Http/EgressBlockedException.php b/app/core/Http/EgressBlockedException.php new file mode 100644 index 000000000..61291d1e6 --- /dev/null +++ b/app/core/Http/EgressBlockedException.php @@ -0,0 +1,20 @@ +resolver = $resolver ?? [self::class, 'resolveHostIpsViaDns']; + $this->allowedPrivateRanges = $allowedPrivateRanges ?? GeneralConfig::getArrayConfigValue('allowed_private_egress_ranges', []); + } + /** + * Canonicalises and validates a host for connection pinning. + * + * @return array{0: string, 1: string} `[canonicalHost, pinnedIp]`; equal when the host is an IP literal (no pin needed). + * @throws EgressBlockedException when the host is unparseable or resolves to a non-public address. + */ + public function resolveTarget(string $host) : array + { + $host = trim($host, '[]'); + if ($host === '') { + throw new \Piwik\Http\EgressBlockedException('Refusing to fetch: empty host.'); + } + // Fold IDN to the ASCII form the transport parses, so validation and pin match it. + if (preg_match('/[^\\x20-\\x7e]/', $host)) { + if (!function_exists('idn_to_ascii')) { + throw new \Piwik\Http\EgressBlockedException('Refusing to fetch: cannot normalise internationalised host without the intl extension.'); + } + $ascii = idn_to_ascii($host, \IDNA_DEFAULT, \INTL_IDNA_VARIANT_UTS46); + if ($ascii === \false || $ascii === '') { + throw new \Piwik\Http\EgressBlockedException('Refusing to fetch: host cannot be converted to ASCII.'); + } + $host = $ascii; + } + // Normalise to the lowercase, dot-trimmed form the pin keys on, so it matches the lookup. + $host = rtrim(strtolower($host), '.'); + if ($host === '') { + throw new \Piwik\Http\EgressBlockedException('Refusing to fetch: empty host.'); + } + // An IP literal connects directly (no DNS, no rebinding window): validate it, skip pinning. + if (filter_var($host, \FILTER_VALIDATE_IP)) { + if (!$this->isAllowedIp($host)) { + throw new \Piwik\Http\EgressBlockedException('Refusing to fetch: host resolves to a private or reserved address.'); + } + return [$host, $host]; + } + // Reject numeric/hex hosts (e.g. 2130706433, 0x7f000001, 127.1) since libc treats these as IP + // literals via inet_aton, which would sidestep the resolve pin. + if (preg_match('/^0x[0-9a-f]+$/i', $host) || preg_match('/^[0-9.]+$/', $host)) { + throw new \Piwik\Http\EgressBlockedException('Refusing to fetch: numeric or encoded host is not allowed.'); + } + if (!preg_match('/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i', $host)) { + throw new \Piwik\Http\EgressBlockedException('Refusing to fetch: host is not a valid IP or DNS name.'); + } + $ips = ($this->resolver)($host); + if (empty($ips)) { + throw new \Piwik\Http\EgressBlockedException('Refusing to fetch: host could not be resolved.'); + } + foreach ($ips as $ip) { + if (!$this->isAllowedIp($ip)) { + throw new \Piwik\Http\EgressBlockedException('Refusing to fetch: host resolves to a private or reserved address.'); + } + } + // Pin the first address so the connection cannot be rebound between validation and connect. + return [$host, $ips[0]]; + } + private function isAllowedIp(string $ip) : bool + { + if (self::isPublicIp($ip)) { + return \true; + } + // matched per address family, so an IPv4-mapped IPv6 address cannot match an IPv4 range + return !empty($this->allowedPrivateRanges) && IP::fromStringIP($ip)->isInRanges($this->allowedPrivateRanges); + } + public static function isPublicIp(string $ip) : bool + { + // Unwrap IPv4-mapped IPv6 in any textual form (::ffff:169.254.169.254, ::ffff:a9fe:a9fe, + // 0:0:0:0:0:ffff:a9fe:a9fe) via the binary form; PHP < 8.1 does not flag these. + // @todo When min PHP version >= 8.1 (Matomo 6), verify filter_var() flags IPv4-mapped IPv6 + // in the reserved/private ranges natively - if so, remove this block and rely on filter_var() + if (strpos($ip, ':') !== \false) { + $bin = @inet_pton($ip); + if ($bin !== \false && strlen($bin) === 16 && substr($bin, 0, 12) === "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff") { + $ip = inet_ntop(substr($bin, 12)); + } + } + if (\false === filter_var($ip, \FILTER_VALIDATE_IP, \FILTER_FLAG_NO_PRIV_RANGE | \FILTER_FLAG_NO_RES_RANGE)) { + return \false; + } + return !IP::fromStringIP($ip)->isInRanges(self::EXTRA_BLOCKED_RANGES); + } + /** + * @return string[] + */ + private static function resolveHostIpsViaDns(string $host) : array + { + $ips = []; + $ipv4 = @gethostbynamel($host); + if (is_array($ipv4)) { + $ips = $ipv4; + } + $records = @dns_get_record($host, \DNS_AAAA); + if (is_array($records)) { + foreach ($records as $record) { + if (!empty($record['ipv6'])) { + $ips[] = (string) $record['ipv6']; + } + } + } + return $ips; + } +} diff --git a/app/core/Plugin/LogTablesProvider.php b/app/core/Plugin/LogTablesProvider.php index 383f2e4a7..1226d0a20 100644 --- a/app/core/Plugin/LogTablesProvider.php +++ b/app/core/Plugin/LogTablesProvider.php @@ -46,6 +46,7 @@ public function getLogTable($tableNameWithoutPrefix) return $table; } } + return null; } /** * @param LogTableTemporary|null $table diff --git a/app/core/Plugin/WidgetsProvider.php b/app/core/Plugin/WidgetsProvider.php index 49609206e..8de343dcc 100644 --- a/app/core/Plugin/WidgetsProvider.php +++ b/app/core/Plugin/WidgetsProvider.php @@ -84,16 +84,16 @@ public function getWidgetContainerConfigs() public function factory($module, $action) { if (empty($module) || empty($action)) { - return; + return null; } try { if (!$this->pluginManager->isPluginActivated($module)) { - return; + return null; } $plugin = $this->pluginManager->getLoadedPlugin($module); } catch (\Exception $e) { // we are not allowed to use possible widgets, plugin is not active - return; + return null; } $widgets = $plugin->findMultipleComponents('Widgets', 'Piwik\\Widget\\Widget'); foreach ($widgets as $widgetClass) { @@ -103,6 +103,7 @@ public function factory($module, $action) return StaticContainer::get($widgetClass); } } + return null; } private function getWidgetConfigForClassName($widgetClass) { diff --git a/app/core/ReportRenderer.php b/app/core/ReportRenderer.php index e7d975244..abfcc3727 100644 --- a/app/core/ReportRenderer.php +++ b/app/core/ReportRenderer.php @@ -163,8 +163,22 @@ protected static function writeFile($filename, $extension, $content) } return $outputFilename; } + /** + * Streaming a report writes response headers and body directly, so it is reserved for the + * top-level request. A report generated as a nested API sub-request must be returned to the + * calling request instead. + * + * @throws Exception + */ + public static function checkStreamingToBrowserIsAllowed() : void + { + if (Request::isCurrentApiRequestNestedInAnotherApiRequest()) { + throw new Exception('A report can only be sent to the browser by the top-level request.'); + } + } protected static function sendToBrowser($filename, $extension, $contentType, $content) { + self::checkStreamingToBrowserIsAllowed(); $filename = \Piwik\ReportRenderer::makeFilenameWithExtension($filename, $extension); \Piwik\ProxyHttp::overrideCacheControlHeaders(); \Piwik\Common::sendHeader('Content-Description: File Transfer'); @@ -175,6 +189,7 @@ protected static function sendToBrowser($filename, $extension, $contentType, $co } protected static function inlineToBrowser($contentType, $content) { + self::checkStreamingToBrowserIsAllowed(); \Piwik\Common::sendHeader('Content-Type: ' . $contentType); echo $content; } diff --git a/app/core/ReportRenderer/Pdf.php b/app/core/ReportRenderer/Pdf.php index be69df0c5..29401ef73 100644 --- a/app/core/ReportRenderer/Pdf.php +++ b/app/core/ReportRenderer/Pdf.php @@ -145,11 +145,13 @@ public function sendToDisk($filename) } public function sendToBrowserDownload($filename) { + self::checkStreamingToBrowserIsAllowed(); $filename = ReportRenderer::makeFilenameWithExtension($filename, self::PDF_CONTENT_TYPE); $this->TCPDF->Output($filename, 'D'); } public function sendToBrowserInline($filename) { + self::checkStreamingToBrowserIsAllowed(); $filename = ReportRenderer::makeFilenameWithExtension($filename, self::PDF_CONTENT_TYPE); $this->TCPDF->Output($filename, 'I'); } diff --git a/app/core/Segment.php b/app/core/Segment.php index 1af8285d3..07ff040d5 100644 --- a/app/core/Segment.php +++ b/app/core/Segment.php @@ -349,7 +349,16 @@ protected function getCleanedExpression(array $expression) : array // then we would join an extra table per segment when we ideally want to join each table only once. However, we still need // to see which table/column it joins to join it accurately each table extra if the same table is joined with different columns; $tableAlias = $join->getTable() . '_segment_' . str_replace('.', '', $sqlName ?: ''); - $joinTable = ['table' => $join->getTable(), 'tableAlias' => $tableAlias, 'field' => $tableAlias . '.' . $join->getTargetColumn(), 'joinOn' => $sqlName . ' = ' . $tableAlias . '.' . $join->getColumn()]; + $joinConditions = [$sqlName . ' = ' . $tableAlias . '.' . $join->getColumn()]; + // additional key columns scope the join to the same row on both tables (eg the site id), + // so a value cannot match a row that only shares the primary join column + $sourceTable = strpos((string) $sqlName, '.') !== \false ? strstr($sqlName, '.', \true) : null; + if ($sourceTable !== null) { + foreach ($join->getAdditionalKeyColumns() as $keyColumn) { + $joinConditions[] = $sourceTable . '.' . $keyColumn . ' = ' . $tableAlias . '.' . $keyColumn; + } + } + $joinTable = ['table' => $join->getTable(), 'tableAlias' => $tableAlias, 'field' => $tableAlias . '.' . $join->getTargetColumn(), 'joinOn' => implode(' AND ', $joinConditions)]; if ($dbDiscriminator) { $joinTable['discriminator'] = $tableAlias . '.' . $dbDiscriminator->getColumn() . ' = \'' . $dbDiscriminator->getValue() . '\''; } diff --git a/app/core/Session/SessionAuth.php b/app/core/Session/SessionAuth.php index cd010f7f6..9de9f8d99 100644 --- a/app/core/Session/SessionAuth.php +++ b/app/core/Session/SessionAuth.php @@ -51,7 +51,7 @@ public function __construct(?UsersModel $userModel = null, $shouldDestroySession } public function getName() { - // empty + return null; } public function setTokenAuth( #[\SensitiveParameter] @@ -64,10 +64,11 @@ public function getLogin() if (isset($this->user['login'])) { return $this->user['login']; } + return null; } public function getTokenAuthSecret() { - // empty + return null; } public function setLogin($login) { diff --git a/app/core/Settings/Settings.php b/app/core/Settings/Settings.php index f476ded6d..1a5f10ceb 100644 --- a/app/core/Settings/Settings.php +++ b/app/core/Settings/Settings.php @@ -63,6 +63,7 @@ public function getSetting($name) if (array_key_exists($name, $this->settings)) { return $this->settings[$name]; } + return null; } /** * Implemented by descendants. This method should define plugin settings (via the diff --git a/app/core/SiteContentDetector.php b/app/core/SiteContentDetector.php index 2b28fa71e..f97191f71 100644 --- a/app/core/SiteContentDetector.php +++ b/app/core/SiteContentDetector.php @@ -11,6 +11,8 @@ use Matomo\Cache\Lazy; use Piwik\Config\GeneralConfig; use Piwik\Container\StaticContainer; +use Piwik\Http\EgressBlockedException; +use Piwik\Log\LoggerInterface; use Piwik\Plugins\SitesManager\SiteContentDetection\ConsentManagerDetectionAbstract; use Piwik\Plugins\SitesManager\SiteContentDetection\SiteContentDetectionAbstract; /** @@ -278,8 +280,41 @@ private function requestSiteResponse(string $url, int $timeOut) : array } $siteData = []; try { - $siteData = \Piwik\Http::sendHttpRequestBy(\Piwik\Http::getTransportMethod(), $url, $timeOut, null, null, null, 0, \false, \true, \false, \true); + // @todo PHP 8.1 min (Matomo 6): use named arguments to drop the positional null/false filler. + $siteData = \Piwik\Http::sendHttpRequestBy( + 'curl', + $url, + $timeOut, + null, + null, + null, + 0, + \false, + \true, + // $acceptInvalidSslCertificate: detected sites may use self-signed certs + \false, + \true, + // $getExtendedInfo + 'GET', + null, + null, + null, + [], + null, + \true, + // $checkHostIsAllowed + \true + ); + } catch (EgressBlockedException $e) { + // admin-fixable rejection, not a transient network error, so it must clear the default WARN level + StaticContainer::get(LoggerInterface::class)->warning('Site content detection request for {url} was refused: {message}', [ + // host only, so a configured URL carrying userinfo keeps credentials out of the log + 'url' => \Piwik\UrlHelper::getHostFromUrl($url), + 'message' => $e->getMessage(), + ]); } catch (\Exception $e) { + // intentionally fail closed, but leave a diagnostic trail + StaticContainer::get(LoggerInterface::class)->debug('Site content detection request for {url} failed: {message}', ['url' => \Piwik\UrlHelper::getHostFromUrl($url), 'message' => $e->getMessage()]); } return $siteData; } diff --git a/app/core/Tracker/Db/Mysqli.php b/app/core/Tracker/Db/Mysqli.php index 11c2e5ae8..535863ffe 100644 --- a/app/core/Tracker/Db/Mysqli.php +++ b/app/core/Tracker/Db/Mysqli.php @@ -360,12 +360,13 @@ public function rowCount($queryResult) public function beginTransaction() { if ($this->activeTransaction !== null) { - return; + return null; } if ($this->connection->autocommit(\false)) { $this->activeTransaction = uniqid(); return $this->activeTransaction; } + return null; } /** * Commit Transaction diff --git a/app/core/Tracker/Db/Pdo/Mysql.php b/app/core/Tracker/Db/Pdo/Mysql.php index 89bd165a9..016521b5a 100644 --- a/app/core/Tracker/Db/Pdo/Mysql.php +++ b/app/core/Tracker/Db/Pdo/Mysql.php @@ -316,7 +316,7 @@ public function rowCount($queryResult) public function beginTransaction() { if ($this->activeTransaction !== null) { - return; + return null; } try { $success = $this->connection->beginTransaction(); @@ -334,6 +334,7 @@ public function beginTransaction() $this->activeTransaction = uniqid(); return $this->activeTransaction; } + return null; } /** * Commit Transaction diff --git a/app/core/Tracker/GoalManager.php b/app/core/Tracker/GoalManager.php index 37a326340..07a98e793 100644 --- a/app/core/Tracker/GoalManager.php +++ b/app/core/Tracker/GoalManager.php @@ -32,6 +32,17 @@ class GoalManager public const IDGOAL_CART = -1; public const IDGOAL_ORDER = 0; public const REVENUE_PRECISION = 2; + /** + * Upper sanity limit for a single tracked ecommerce money value (item price, order + * revenue, tax, shipping, discount, subtotal). Values whose absolute value exceeds + * this are rejected at tracking time. + * + * No legitimate single transaction is this large, and such values corrupt revenue + * reports. Rejecting them also removes the archiving overflow vector: quantity is + * bounded by its INT UNSIGNED column (<= ~4.29e9), so quantity * price can no longer + * exceed the MySQL DOUBLE range and abort archiving with error 1690. + */ + public const MAX_ALLOWED_REVENUE = 1000000000000; public const MAXIMUM_PRODUCT_CATEGORIES = 5; // In the GET items parameter, each item has the following array of information public const INDEX_ITEM_SKU = 0; @@ -247,6 +258,11 @@ public function recordGoals(VisitProperties $visitProperties, \Piwik\Tracker\Req */ protected function getRevenue($revenue) { + // Reject out-of-range values (see self::MAX_ALLOWED_REVENUE); treat as no revenue. + if (abs((float) $revenue) > self::MAX_ALLOWED_REVENUE) { + StaticContainer::get(LoggerInterface::class)->debug("Ecommerce value ({$revenue}) exceeds the allowed maximum of " . self::MAX_ALLOWED_REVENUE . " and was rejected (treated as no revenue)."); + return 0; + } if (round($revenue) != $revenue) { $revenue = round($revenue, self::REVENUE_PRECISION); } diff --git a/app/core/Twig.php b/app/core/Twig.php index b0531ce95..0ca1504ad 100644 --- a/app/core/Twig.php +++ b/app/core/Twig.php @@ -243,12 +243,12 @@ protected function addFunctionSparkline() : void 'sparkline', /** * @param string $src + * @param int $width Display width in px. Defaults to Sparkline::DEFAULT_WIDTH. + * @param int $height Display height in px. Defaults to Sparkline::DEFAULT_HEIGHT. * @return string */ - function ($src) use($twigEnv) { - $width = Sparkline::DEFAULT_WIDTH; - $height = Sparkline::DEFAULT_HEIGHT; - return sprintf(\Piwik\Twig::SPARKLINE_TEMPLATE, piwik_escape_filter($twigEnv, $src, 'html_attr'), $width, $height); + function ($src, $width = Sparkline::DEFAULT_WIDTH, $height = Sparkline::DEFAULT_HEIGHT) use($twigEnv) { + return sprintf(\Piwik\Twig::SPARKLINE_TEMPLATE, piwik_escape_filter($twigEnv, $src, 'html_attr'), (int) $width, (int) $height); }, ['is_safe' => ['html']] ); diff --git a/app/core/Version.php b/app/core/Version.php index 6d6d4201c..207eb30fc 100644 --- a/app/core/Version.php +++ b/app/core/Version.php @@ -20,7 +20,7 @@ final class Version * The current Matomo version. * @var string */ - public const VERSION = '5.12.0'; + public const VERSION = '5.13.0'; public const MAJOR_VERSION = 5; public function isStableVersion($version) : bool { diff --git a/app/core/ViewDataTable/Factory.php b/app/core/ViewDataTable/Factory.php index 7fa45a81c..a0fc1c8a4 100644 --- a/app/core/ViewDataTable/Factory.php +++ b/app/core/ViewDataTable/Factory.php @@ -155,7 +155,7 @@ public static function build($defaultType = null, $apiAction = \false, $controll private static function getReport($apiAction) { if (strpos($apiAction, '.') === \false) { - return; + return null; } list($module, $action) = explode('.', $apiAction); $report = ReportsProvider::factory($module, $action); diff --git a/app/core/ViewDataTable/Manager.php b/app/core/ViewDataTable/Manager.php index f784fe01a..30c299706 100644 --- a/app/core/ViewDataTable/Manager.php +++ b/app/core/ViewDataTable/Manager.php @@ -225,7 +225,7 @@ public static function getViewDataTableParameters($login, $controllerAction, $co */ public static function saveViewDataTableParameters($login, $controllerAction, $parametersToOverride, $containerId = null) { - $params = self::getViewDataTableParameters($login, $controllerAction); + $params = self::getViewDataTableParameters($login, $controllerAction, $containerId); self::unsetComparisonParams($params); foreach ($parametersToOverride as $key => $value) { if ($key === 'viewDataTable' && !empty($params[$key]) && $params[$key] !== $value) { diff --git a/app/core/Visualization/Sparkline.php b/app/core/Visualization/Sparkline.php index ff2ebd7bd..94487cef1 100644 --- a/app/core/Visualization/Sparkline.php +++ b/app/core/Visualization/Sparkline.php @@ -9,10 +9,7 @@ namespace Piwik\Visualization; use Piwik\Common; -use Piwik\Container\StaticContainer; use Piwik\Piwik; -use Piwik\Plugins\CoreVisualizations\FeatureFlags\SparklinesRedesign; -use Piwik\Plugins\FeatureFlags\FeatureFlagManager; use Piwik\View\ViewInterface; /** * Renders a sparkline image given a PHP data array. @@ -22,10 +19,8 @@ class Sparkline implements ViewInterface { public const DEFAULT_WIDTH = 200; public const DEFAULT_HEIGHT = 50; - public const DEFAULT_LINE_THICKNESS = 1; - public const REDESIGN_LINE_THICKNESS = 4; - public const DEFAULT_POINT_SIZE = 5; - public const REDESIGN_POINT_SIZE = 6; + public const LINE_THICKNESS = 4; + public const POINT_SIZE = 6; // We now create different sized width for Sparklines based on the card designs // This max width will still be adjusted as we create new Sparkline modes. public const MAX_WIDTH = 1000; @@ -101,11 +96,10 @@ public function main() } $sparkline->setWidth($this->getWidth()); $sparkline->setHeight($this->getHeight()); - $sparkline->setLineThickness($this->getLineThickness()); + $sparkline->setLineThickness(self::LINE_THICKNESS); // Pad by at least the point radius so edge dots (first/last, and min/max at the - // top/bottom) aren't clipped by the image boundary. Legacy used a fixed 5, which - // happened to equal the legacy point size; keep them coupled as the point size grows. - $sparkline->setPadding((string) $this->getPointSize()); + // top/bottom) aren't clipped by the image boundary. + $sparkline->setPadding((string) self::POINT_SIZE); $this->sparkline = $sparkline; } /** @@ -187,7 +181,7 @@ private function setSparklineColors($sparkline, $seriesIndex) } else { $sparkline->deactivateFillColor(); } - $pointSize = $this->getPointSize(); + $pointSize = self::POINT_SIZE; if ($this->shouldApplyColor($colors['minPointColor'])) { $sparkline->addPoint("minimum", $pointSize, $colors['minPointColor'], $seriesIndex); } @@ -198,24 +192,6 @@ private function setSparklineColors($sparkline, $seriesIndex) $sparkline->addPoint("last", $pointSize, $colors['lastPointColor'], $seriesIndex); } } - private function getLineThickness() : int - { - if ($this->isSparklinesRedesignEnabled()) { - return self::REDESIGN_LINE_THICKNESS; - } - return self::DEFAULT_LINE_THICKNESS; - } - private function getPointSize() : int - { - if ($this->isSparklinesRedesignEnabled()) { - return self::REDESIGN_POINT_SIZE; - } - return self::DEFAULT_POINT_SIZE; - } - private function isSparklinesRedesignEnabled() : bool - { - return StaticContainer::get(FeatureFlagManager::class)->isFeatureActive(SparklinesRedesign::class); - } private function shouldApplyColor($color) : bool { return is_string($color) && strtolower($color) !== '#ffffff'; @@ -223,7 +199,7 @@ private function shouldApplyColor($color) : bool public function render() { if (!$this->sparkline instanceof \Davaxi\Sparkline) { - return; + return null; } if (0 === $this->sparkline->getSeriesCount()) { // ensure to have at least one series & point in sparkline to avoid possible php notices/errors @@ -232,5 +208,6 @@ public function render() } $this->sparkline->display(); $this->sparkline->destroy(); + return null; } } diff --git a/app/jest.config.js b/app/jest.config.js index cd0183c6d..69bd6297b 100644 --- a/app/jest.config.js +++ b/app/jest.config.js @@ -11,6 +11,9 @@ module.exports = { 'ts-jest': { tsconfig: 'tsconfig.spec.json', }, + 'vue-jest': { + tsConfig: 'tsconfig.spec.json', + }, }, setupFiles: ['./tests/client/bootstrap.jest.js'], }; diff --git a/app/lang/en.json b/app/lang/en.json index 392b2ce15..f9bae1fb2 100644 --- a/app/lang/en.json +++ b/app/lang/en.json @@ -268,11 +268,17 @@ "FileIntegrityWarning": "File integrity check failed and reported some errors. You should fix this issue and then refresh this page until it shows no error.", "FileIntegrityWarningReupload": "Errors below may be due to a partial or failed upload of Matomo files.", "FileIntegrityWarningReuploadBis": "Try to reupload all the Matomo files in BINARY mode.", + "FilteredTotal": "Filtered total", + "FilteredTotalMatchingFilter": "Matching current table filter", + "FilteredTotalOfReportTotal": "of %1$s report total", + "FilteredTotalOverall": "overall %1$s", + "FilteredTotalsNote": "Note: Showing filtered results. Aggregate row values are totals for the full group and are not recalculated by the filter. Matching values are summarized in the %1$s row.", "First": "First", "Flatten": "Flatten", "ForExampleShort": "eg.", "ForceSSLRecommended": "We recommend using Matomo over secure SSL connections only. To prevent insecure access over http, add %1$s to the %2$s section in your Matomo config\/config.ini.php file.", "ForcedSSL": "Forced SSL Connection", + "Forecast": "Forecast", "Forums": "Forums", "FromReferrer": "from", "GeneralInformation": "General Information", @@ -735,6 +741,58 @@ "WidgetGraphAIAgents": "AI Agents Over Time", "WidgetOverviewAIAgents": "AI Agents Overview" }, + "AIProviders": { + "AnthropicDefaultModelDescription": "Default model: claude-haiku-4-5", + "ApiKey": "API key", + "ApiKeyAlreadyConfiguredPlaceholder": "Enter a new key to replace the key.", + "ApiKeyPlaceholder": "Enter your API key", + "BedrockDescription": "Access AI models through AWS Bedrock.", + "BedrockEndpointPlaceholder": "e.g. eu-central-1", + "BedrockEndpointTitle": "AWS region", + "BedrockUseFipsEndpoint": "Use FIPS endpoint", + "ClickTestConnectionToShowAvailableModels": "Click test connection to show available models.", + "ConfigurationIntro": "Configure AI providers and default AI behavior in Matomo.", + "CustomProviderDescription": "Use your own OpenAI compatible endpoint.", + "DefaultBadge": "Default", + "DefaultCapabilityLevel": "Default capability level", + "DefaultCapabilityLevelHelp": "Choose the default capability Matomo may use. Individual features can override this.", + "DefaultProvider": "Default provider", + "DefaultProviderHelp": "Connect providers and pick a default for Matomo to use. Individual features may override this and choose their preferred connected provider.", + "DefaultsTitle": "Change provider settings and default values", + "Disconnect": "Disconnect", + "DisconnectSuccess": "AI provider disconnected.", + "Disconnecting": "Disconnecting…", + "EndpointUrl": "API base URL", + "EndpointUrlPlaceholder": "Enter your base URL", + "ErrorEndpointManaged": "The endpoint for %1$s is set in the Matomo server configuration, as \"%2$s\" in the [AIProviders] section. It cannot be changed on this page.", + "ErrorEndpointUrlManagedWithApiKey": "The API key for %1$s is set in the Matomo server configuration, so its endpoint URL must be set there as well, as \"%2$s\" in the [AIProviders] section. It cannot be changed on this page.", + "ErrorInvalidAwsRegion": "The AWS region for %s is invalid.", + "ErrorInvalidEndpointUrl": "The endpoint URL for %s is invalid.", + "ErrorProviderNotConfigured": "AI provider \"%s\" is not configured.", + "ErrorUnknownCapabilityLevel": "Unknown AI model capability level \"%s\".", + "ErrorUnknownProvider": "Unknown AI provider \"%s\".", + "GoogleDefaultModelDescription": "Default model: gemini-3.1-flash-lite", + "InstantCapability": "Instant", + "InstantCapabilityDescription": "Fast and cost-effective for simple tasks.", + "ManagedConfigurationHelp": "AI provider settings are managed for this instance and cannot be changed here.", + "MenuTitle": "AI Providers", + "Model": "Model", + "NoDefaultProviderWarning": "No AI provider can be set as the default yet. Connect a provider first.", + "OpenAIDefaultModelDescription": "Default model: gpt-5.4-mini", + "PluginDescription": "Configure AI provider connections and default model settings used by Matomo AI features.", + "RefreshModels": "Refresh models", + "RequestFailed": "AI provider request failed: %s", + "SettingsSaveSuccess": "AI provider settings saved.", + "StatusConnected": "API key saved", + "StatusNotConnected": "Not connected", + "TestConnection": "Test connection", + "TestConnectionSuccess": "%1$s connection works.", + "TestingConnection": "Testing…", + "ThinkingCapability": "Thinking", + "ThinkingCapabilityDescription": "Better reasoning for complex tasks.", + "UnexpectedError": "Unexpected error. Please check the Matomo server logs.", + "UnsavedChanges": "You have unsaved changes" + }, "API": { "ChangeTokenHint": "If you want to change this token, please go to your %1$spersonal settings page%2$s.", "GenerateVisits": "If you don't have data for today, you can generate some using the %1$s plugin by going to 'Development → Visitor Generator' in the administration area of Matomo.", @@ -1241,9 +1299,11 @@ "ExternalHelp": "Help (opens in new tab)", "FlattenDataTable": "The report is hierarchical %s Make it flat", "FormatMetrics": "Format metrics", - "ShowExportUrl": "Show Export URL", "HideExportUrl": "Hide Export URL", "HomeShortcut": "Home", + "ShowAbsoluteValues": "Show absolute values", + "ShowAbsoluteValuesDataTable": "The report is showing percentages %s Show absolute values", + "ShowExportUrl": "Show Export URL", "SupportUsOn": "Support us on", "IncludeRowsWithLowPopulation": "Rows with low population are hidden %s Show all rows", "InjectedHostEmailBody": "Hello, I tried to access Matomo today and encountered the unknown hostname warning.", @@ -1291,6 +1351,8 @@ "ShortcutRefresh": "to refresh the content", "ShortcutHelp": "to show this help", "ShowJSCode": "Show the JavaScript code to insert", + "ShowPercentageValues": "Show percentages", + "ShowPercentageValuesDataTable": "The report is showing absolute values %s Show percentages", "SkipToContent": "Skip to content", "SubscribeAndBecomePiwikSupporter": "Proceed to a secure credit card payment page (Paypal) to become a Matomo Supporter!", "SupportPiwik": "Support Matomo!", @@ -3538,108 +3600,111 @@ "VisitorLogPolicySettingRequirementNote": "Visits log is required to be disabled." }, "Login": { + "Accept": "Accept", + "AcceptPrivacyPolicy": "You need to accept the privacy policy.", + "AcceptPrivacyPolicyAndTermsAndCondition": "You need to accept the privacy policy and the terms & conditions.", + "AcceptTermsAndCondition": "You need to accept the terms & conditions.", "BruteForceLog": "Brute Force Log", + "BySigningUpPrivacyPolicy": "By signing up, I accept the %1$sprivacy policy%2$s", + "BySigningUpPrivacyPolicyAndTermsAndCondition": "By signing up, I accept the %1$sprivacy policy%2$s and the %3$sterms & conditions%4$s", + "BySigningUpTermsAndCondition": "By signing up, I accept the %1$sterms & conditions%2$s", + "CancelPasswordResetRequestRemoved": "The request has been removed", + "CancelPasswordResetRequestRemovedMessage": "We have recorded that you did not request this password reset. The reset request has been removed, and no changes have been made to your account.", + "CancelPasswordResetSecurityTip": "For added protection, we recommend enabling two-factor authentication (2FA).", + "ChangeYourPassword": "Change your password", + "ConfirmPasswordReset": "Reset password", + "ConfirmPasswordResetIntro": "Please type your new password to confirm you really requested having your password changed.", + "ConfirmPasswordResetWrongPassword": "The entered password does not match your newly made one. Reset your password again if you don't remember it. Don't do anything to keep your password if you didn't request changing it.", + "ConfirmPasswordToContinue": "Confirm your password to continue", "ConfirmationLinkPossiblySent": "If the provided details are associated with an account, you will receive an email to confirm the password reset.", "ContactAdmin": "Possible reason: your host may have disabled the mail() function.
Please contact your Matomo administrator.", + "CreatePasswordDescription": "Make sure you remember this password and keep it in a safe place.", + "CurrentlyBlockedIPs": "Blocked IPs", + "CurrentlyBlockedIPsUnblockConfirm": "Unblock all blocked IPs?", + "CurrentlyBlockedIPsUnblockInfo": "You can unblock IPs blocked by mistake to allow them to log in again.", + "DeclineInvitationInfo": "Your invitation was declined.", "ExceptionInvalidSuperUserAccessAuthenticationMethod": "A user with superuser access cannot be authenticated using the '%s' mechanism.", "ExceptionPasswordMD5HashExpected": "The password parameter is expected to be a MD5 hash of the password.", - "InvalidNonceToken": "The form security failed because of a token mismatch. Please reload the form and check that your cookies are on.", - "InvalidNonceReferrer": "The form security failed because of an invalid \"Referer\" header. If you are using a proxy server, you must %1$sconfigure Matomo to accept the proxy header%2$s that forwards the host header. Also, check that your \"Referer\" header is sent correctly. If you previously connected using HTTPS, please ensure you are connecting over a secure (SSL\/TLS) connection and try again.", - "InvalidNonceUnexpectedReferrer": "The form security failed because the \"Referer\" header is different from what was expected. Check that it is sent correctly.", + "HelpIpRange": "Enter one IP address or one IP range per line. You can use CIDR notation e.g: %1$s or you can use wildcards, e.g: %2$s or %3$s", + "IPsAlwaysBlocked": "These IPs are always blocked", "InvalidNonceOrigin": "The form security failed because of invalid origin. If you previously connected using HTTPS, please ensure you are connecting over a secure (SSL\/TLS) connection and try again.", + "InvalidNonceReferrer": "The form security failed because of an invalid \"Referer\" header. If you are using a proxy server, you must %1$sconfigure Matomo to accept the proxy header%2$s that forwards the host header. Also, check that your \"Referer\" header is sent correctly. If you previously connected using HTTPS, please ensure you are connecting over a secure (SSL\/TLS) connection and try again.", "InvalidNonceSSLMisconfigured": "Also, you may %1$sforce Matomo to use a secure connection%2$s: in your config file%3$s set %4$s below section %5$s", + "InvalidNonceToken": "The form security failed because of a token mismatch. Please reload the form and check that your cookies are on.", + "InvalidNonceUnexpectedReferrer": "The form security failed because the \"Referer\" header is different from what was expected. Check that it is sent correctly.", "InvalidOrExpiredToken": "The token is invalid or has expired.", "InvalidOrExpiredTokenV2": "The password has already been set or the link has expired.", "InvalidUsernameEmail": "Invalid username or e-mail address.", + "InvitationDeclineBody": "Decline this invitation?", + "InvitationDeclineTitle": "Decline invitation", + "InvitationHints": "(you can leave the page to cancel this action)", + "InvitationTitle": "Accept invitation", "LogIn": "Sign in", - "LoginOrEmail": "Username or e-mail", - "HelpIpRange": "Enter one IP address or one IP range per line. You can use CIDR notation e.g: %1$s or you can use wildcards, e.g: %2$s or %3$s", - "SettingBruteForceEnable": "Enable Brute-force Detection", - "SettingBruteForceEnableHelp": "Logs out users making too many password guesses within a timeframe for a while. This prevents anyone from testing all combinations. Getting a shared IP blocked also locks out its other users.", - "SettingBruteForceWhitelistIp": "Never block these IPs from logging in", - "SettingBruteForceBlacklistIp": "Always block these IPs from logging in", - "NotAllowListTakesPrecendence": "If an IP is on both the list of IPs to block and never block, it is blocked.", - "SettingBruteForceMaxFailedLogins": "Number of allowed login retries within a given time frame", - "SettingBruteForceMaxFailedLoginsHelp": "Blocks the IP if more than this number of failed logins are recorded within the time frame set below.", - "SettingBruteForceTimeRange": "Count login retries within this time range in minutes", - "SettingBruteForceTimeRangeHelp": "Enter a number of minutes.", - "SettingPasswordStrengthCheck": "Force strong passwords to be used", - "SettingPasswordStrengthCheckHelp": "Forces users to create strong passwords by requiring them to meet a series of complex rules: %1$s", - "ConfirmPasswordReset": "Reset password", - "ConfirmPasswordResetIntro": "Please type your new password to confirm you really requested having your password changed.", - "ConfirmPasswordResetWrongPassword": "The entered password does not match your newly made one. Reset your password again if you don't remember it. Don't do anything to keep your password if you didn't request changing it.", + "LoginFromDifferentCountryEmail1": "We’ve detected a login to your Matomo account from a location that is different from your previous login area.", + "LoginFromDifferentCountryEmail2": "Login details:", + "LoginFromDifferentCountryEmail3": "If this was you, no action is needed. If you did not recognise this login attempt, please take the following steps to secure your account:", + "LoginFromDifferentCountryEmail4": "Reset your password:", + "LoginFromDifferentCountryEmail5": "Enable Two-Factor Authentication (2FA) for additional security:", + "LoginFromDifferentCountryEmail6": "We also recommend reviewing your recent account activity to ensure there are no other unauthorised actions.", + "LoginFromDifferentCountryEmailLinkEnable2FA": "Enable 2FA", + "LoginFromDifferentCountryEmailLinkResetPassword": "Reset your password", + "LoginFromDifferentCountryEmailSubject": "Unusual login activity detected on your Matomo account", "LoginNotAllowedBecauseBlocked": "Too many failed logins. Please wait and try logging in again later.", - "CurrentlyBlockedIPs": "Blocked IPs", - "IPsAlwaysBlocked": "These IPs are always blocked", - "UnblockAllIPs": "Unblock all blocked IPs", - "CurrentlyBlockedIPsUnblockInfo": "You can unblock IPs blocked by mistake to allow them to log in again.", - "CurrentlyBlockedIPsUnblockConfirm": "Unblock all blocked IPs?", + "LoginNotAllowedBecauseUserLoginBlocked": "Logging in has been turned off since a suspicious amount of failed attempts were made during the last hour.", + "LoginOrEmail": "Username or e-mail", "LoginPasswordNotCorrect": "Wrong username and\/or password.", "LostYourPassword": "Lost your password?", - "ChangeYourPassword": "Change your password", "NewPassword": "New password", "NewPasswordRepeat": "New password (repeat)", + "NotAllowListTakesPrecendence": "If an IP is on both the list of IPs to block and never block, it is blocked.", "PasswordChanged": "You can now use your new password to log in.", "PasswordRepeat": "Password (repeat)", - "PasswordsDoNotMatch": "Mismatching passwords.", + "PasswordRequired": "Please enter your password to continue", "PasswordResetAlreadySent": "You requested too many password resets recently. A new request can be made in one hour. Your administrator can help you if that doesn't work.", - "PasswordResetCancelEmailSubject": "Your Matomo account has been secured", + "PasswordResetCancelConfirm": "Confirm password reset cancellation", + "PasswordResetCancelConfirmDescription": "You've requested to cancel your password reset. Please confirm your choice to ensure your request is processed. If this was not your intention, you can safely close this page.", + "PasswordResetCancelConfirmTitle": "Password reset cancellation", "PasswordResetCancelEmail1": "We’ve received your report that you did not request the recent password reset attempt for your Matomo account. The reset request has been removed, and no changes have been made to your account.", "PasswordResetCancelEmail2": "Your account remains secure. For enhanced protection, we recommend enabling two-factor authentication (2FA) to add another layer of security.", "PasswordResetCancelEmail3": "To enable 2FA, visit your personal account security settings and follow the instructions provided.", - "PasswordResetEmailLinkCancel": "This wasn't me", - "PasswordResetEmailLinkReset": "Reset your password", - "PasswordResetEmailSubject": "Reset your Matomo account password", + "PasswordResetCancelEmailSubject": "Your Matomo account has been secured", + "PasswordResetCancelTokenIssue": "We noticed you tried to cancel your password reset request. Unfortunately, this link is no longer valid because it has either expired, is invalid or has been used already.", "PasswordResetEmail1": "We received a request to reset the password for your Matomo account from the IP address %1$s. If you initiated this request, you can reset your password by clicking the link below:", "PasswordResetEmail2": "This link will expire in 24 hours for security reasons.", "PasswordResetEmail3": "If you did not request this password reset, you can cancel the request by clicking the link below:", "PasswordResetEmail4": "Your account remains secure, but we encourage you to act quickly if you did not initiate this request.", "PasswordResetEmail5": "For added security, we recommend enabling two-factor authentication (2FA) after resetting your password.", - "WrongPasswordEntered": "Please enter your password.", - "ConfirmPasswordToContinue": "Confirm your password to continue", + "PasswordResetEmailAppTokens": "Changing your password does not automatically revoke your app-specific tokens. If you suspect unauthorised access, please delete your existing tokens and generate new ones in the token management section.", + "PasswordResetEmailLinkCancel": "This wasn't me", + "PasswordResetEmailLinkReset": "Reset your password", + "PasswordResetEmailLinkValidity": "This link will expire in %1$s for security reasons.", + "PasswordResetEmailSubject": "Reset your Matomo account password", + "PasswordsDoNotMatch": "Mismatching passwords.", + "PleaseNote": "Please note", "PluginDescription": "Provides username and password login as well as password reset functionality. The login method can be changed by using another login plugin such as LoginLdap available on the marketplace.", "RememberMe": "Remember Me", - "SuspiciousLoginAttemptsInLastHourEmailSubject": "Some suspicious login attempts were made using your username…", + "SecurityTip": "Security Tip", + "SettingBruteForceBlacklistIp": "Always block these IPs from logging in", + "SettingBruteForceEnable": "Enable Brute-force Detection", + "SettingBruteForceEnableHelp": "Logs out users making too many password guesses within a timeframe for a while. This prevents anyone from testing all combinations. Getting a shared IP blocked also locks out its other users.", + "SettingBruteForceMaxFailedLogins": "Number of allowed login retries within a given time frame", + "SettingBruteForceMaxFailedLoginsHelp": "Blocks the IP if more than this number of failed logins are recorded within the time frame set below.", + "SettingBruteForceTimeRange": "Count login retries within this time range in minutes", + "SettingBruteForceTimeRangeHelp": "Enter a number of minutes.", + "SettingBruteForceWhitelistIp": "Never block these IPs from logging in", + "SettingPasswordStrengthCheck": "Force strong passwords to be used", + "SettingPasswordStrengthCheckHelp": "Forces users to create strong passwords by requiring them to meet a series of complex rules: %1$s", "SuspiciousLoginAttemptsInLastHourEmail1": "A suspiciously high number of login attempts was made on your Matomo account the last hour. Specifically %1$s login attempts from %2$s distinct IP addresses. Someone may be trying to break into your account.", "SuspiciousLoginAttemptsInLastHourEmail2": "Do the following:", "SuspiciousLoginAttemptsInLastHourEmail3": "Ensure your password is a secure, random password of at least 30 characters.", "SuspiciousLoginAttemptsInLastHourEmail4": "Set up two-factor authentication so attackers need more than just your password to log in.", "SuspiciousLoginAttemptsInLastHourEmail5": "Set up a list of IP addresses to never block if your Matomo has a limited set of users or IPs users access it from. %1$sRead the docs for more info.%2$s", - "LoginNotAllowedBecauseUserLoginBlocked": "Logging in has been turned off since a suspicious amount of failed attempts were made during the last hour.", - "InvitationTitle": "Accept invitation", - "InvitationDeclineTitle": "Decline invitation", - "InvitationDeclineBody": "Decline this invitation?", - "InvitationHints": "(you can leave the page to cancel this action)", - "Accept": "Accept", - "PasswordRequired": "Please enter your password to continue", - "DeclineInvitationInfo": "Your invitation was declined.", - "BySigningUpPrivacyPolicy": "By signing up, I accept the %1$sprivacy policy%2$s", - "BySigningUpTermsAndCondition": "By signing up, I accept the %1$sterms & conditions%2$s", - "BySigningUpPrivacyPolicyAndTermsAndCondition": "By signing up, I accept the %1$sprivacy policy%2$s and the %3$sterms & conditions%4$s", - "AcceptPrivacyPolicy": "You need to accept the privacy policy.", - "AcceptTermsAndCondition": "You need to accept the terms & conditions.", - "AcceptPrivacyPolicyAndTermsAndCondition": "You need to accept the privacy policy and the terms & conditions.", - "CreatePasswordDescription": "Make sure you remember this password and keep it in a safe place.", - "CancelPasswordResetRequestRemoved": "The request has been removed", - "CancelPasswordResetRequestRemovedMessage": "We have recorded that you did not request this password reset. The reset request has been removed, and no changes have been made to your account.", - "CancelPasswordResetSecurityTip": "For added protection, we recommend enabling two-factor authentication (2FA).", - "SecurityTip": "Security Tip", - "LoginFromDifferentCountryEmailSubject": "Unusual login activity detected on your Matomo account", - "LoginFromDifferentCountryEmail1": "We’ve detected a login to your Matomo account from a location that is different from your previous login area.", - "LoginFromDifferentCountryEmail2": "Login details:", - "LoginFromDifferentCountryEmail3": "If this was you, no action is needed. If you did not recognise this login attempt, please take the following steps to secure your account:", - "LoginFromDifferentCountryEmail4": "Reset your password:", - "LoginFromDifferentCountryEmail5": "Enable Two-Factor Authentication (2FA) for additional security:", - "LoginFromDifferentCountryEmail6": "We also recommend reviewing your recent account activity to ensure there are no other unauthorised actions.", - "LoginFromDifferentCountryEmailLinkResetPassword": "Reset your password", - "LoginFromDifferentCountryEmailLinkEnable2FA": "Enable 2FA", + "SuspiciousLoginAttemptsInLastHourEmailSubject": "Some suspicious login attempts were made using your username…", "TimeOfLogin": "Time of login", - "PasswordResetCancelConfirmTitle": "Password reset cancellation", - "PasswordResetCancelConfirm": "Confirm password reset cancellation", - "PasswordResetCancelConfirmDescription": "You've requested to cancel your password reset. Please confirm your choice to ensure your request is processed. If this was not your intention, you can safely close this page.", - "PasswordResetCancelTokenIssue": "We noticed you tried to cancel your password reset request. Unfortunately, this link is no longer valid because it has either expired, is invalid or has been used already.", - "PleaseNote": "Please note", - "PasswordResetEmailAppTokens": "Changing your password does not automatically revoke your app-specific tokens. If you suspect unauthorised access, please delete your existing tokens and generate new ones in the token management section." + "TokenAuthenticationFailed": "Unable to authenticate with the provided token. It is either invalid or expired.", + "TokenAuthenticationFailedInsecure": "Unable to authenticate with the provided token. It is either invalid, expired or is required to be sent as a POST parameter or in the HTTP Authorization header. If the token was created with the 'Only allow secure requests' option, it must be sent as a POST parameter or in the HTTP Authorization header.", + "UnblockAllIPs": "Unblock all blocked IPs", + "WrongPasswordEntered": "Please enter your password." }, "Marketplace": { "ActivateLicenseKey": "Activate", @@ -6349,6 +6414,8 @@ "EmailYourAdministrator": "%1$sE-mail your administrator about this problem%2$s.", "EnterUsernameOrEmail": "Enter a username or email address", "ExceptionAccessValues": "The parameter access must have one of the following values: [ %1$s ], '%2$s' given.", + "ExceptionCreateTokenAuthForOtherUser": "You can only create an authentication token for your own account.", + "ExceptionCreateTokenAuthWithinNestedRequest": "This method cannot be called from within another API request.", "ExceptionNoRoleSet": "No role is set but one of these needs to be set: %s", "ExceptionMultipleRoleSet": "Only one role can be set but multiple have been set. Use only one of: %s", "ExceptionAnonymousNoCapabilities": "You cannot grant any capability to the 'anonymous' user.", @@ -6504,6 +6571,7 @@ "DeleteUserPermConfirmMultiple": "Are you sure you want to change the %1$s selected users' role to %2$s for %3$s?", "AreYouSureChangeDetails": "Are you sure you want to change the user information for %s?", "AnonymousUserRoleChangeWarning": "Giving the %1$s user the %2$s role will make this website's data public and available to everyone, even if they do not have a Matomo login.", + "AdminUserRoleChangeWarning": "Giving a user the %1$s role grants them full administrative control over the affected websites, including the ability to manage other users' access.", "GiveAccessToAll": "Give this user access to All Websites", "OrManageIndividually": "Or manage this user's access to each website individually", "ChangePermToAllSitesConfirm": "Are you sure you want to give the %1$s user %2$s access to every website you currently have admin access to?", @@ -6554,7 +6622,7 @@ "InviteActionNotes": "Please notes that resending an invite or copy invite link will extend the time limit for previous invites by %1$s days.", "CopyDenied": "The request is not allowed due to your browser's settings.", "CopyDeniedHints": "Please try again by either switching browsers or copying and sharing this link directly instead: %1$s", - "AuthTokenSecureOnlyHelp": "Enable this option to only allow this token to be used in a secure way (e.g. POST requests), this is recommended as a best security practice. The token will then not be valid as a URL parameter in GET requests.", + "AuthTokenSecureOnlyHelp2": "Enable this option to only allow this token to be used in a secure way (e.g. POST requests), this is recommended as a best security practice. The token will then not be valid as a URL parameter in GET requests, so it cannot be used to embed widgets via a URL that includes token_auth. Uncheck this only if you specifically need a URL-usable token for widget embedding.", "AuthTokenSecureOnlyHelpForced": "The system administrator has configured Matomo to only allow tokens to be created for use in secure way (e.g. via POST requests), you cannot change this token option.", "OnlyAllowSecureRequests": "Only allow secure requests", "SecureUseOnly": "Secure use only", @@ -6736,6 +6804,10 @@ "DisplayDashboardInIframe": "You can also display the full Matomo dashboard in your application or website in an IFRAME (%1$ssee example%2$s). The date parameter can be set to a specific calendar date, \"today\", or \"yesterday\". The period parameter can be set to \"day\", \"week\", \"month\", or \"year\". The language parameter can be set to the language code of a translation, such as language=fr. For example, for idSite=1 and date=yesterday, you can write:", "DisplayDashboardInIframeAllSites": "You can also widgetize the all websites dashboard in an IFRAME (%1$ssee example%2$s)", "ViewableAnonymously": "If you want your widgets to be viewable by everybody, you first have to set the 'view' permissions to the anonymous user in the %1$sUsers Management section%2$s.
Alternatively, if you are publishing widgets on a password protected or private page, you don't necessarily have to allow 'anonymous' to view your reports. In this case, you can add the secret token_auth<\/code> parameter in the widget URL. You can manage your auth tokens on your %3$sSecurity page%4$s.", + "ViewableAnonymouslyUrlTokenRequirement": "Only tokens created with 'Only allow secure requests' unchecked can be used as a URL parameter. Tokens marked as secure only must be sent via POST and will not authenticate a widget URL.", + "UrlTokensDisabledByPolicy": "URL-based token_auth<\/code> is disabled by server policy on this Matomo instance (only_allow_secure_auth_tokens<\/code> is enabled). Widget URLs cannot be authenticated with a token parameter. To publish these widgets on a password-protected page, grant %1$sview access to the anonymous user%2$s, or fetch the widgets through a server-side proxy that sends the token via POST.", + "ErrorTokenAuthFailed": "This widget URL could not be authenticated with the supplied token_auth<\/code>. Widget URLs only work with tokens created with 'Only allow secure requests' unchecked. Create a URL-usable token from your %1$sSecurity page%2$s, or grant %3$sview access to the anonymous user%4$s to embed this widget without a token.", + "ErrorTokenAuthFailedDisabledByPolicy": "This widget URL could not be authenticated because URL-based token_auth<\/code> is disabled by server policy on this Matomo instance. Grant %1$sview access to the anonymous user%2$s, or fetch the widget through a server-side proxy that sends the token via POST.", "EmbedIframe": "› Embed Iframe", "DirectLink": "› Direct Link" }, diff --git a/app/lang/es.json b/app/lang/es.json index 905449863..6f991a404 100644 --- a/app/lang/es.json +++ b/app/lang/es.json @@ -3743,8 +3743,12 @@ "AnonymizeIpMaskLengthSettingTitle": "Longitud de la máscara de dirección IP", "AnonymizeIpPolicySettingRequirementNote": "Se debe habilitar la anonimización de las direcciones IP de los visitantes.", "AnonymizeIpPolicySettingTitle": "Anonimización de IP habilitada", + "AnonymizeLocation": "Anonimizar Ubicación", "AnonymizeReferrer": "Anonimizar remitente", + "AnonymizeReferrerExcludeAll": "No registres la URL de referencia, pero detecta igualmente el tipo de referencia", + "AnonymizeReferrerExcludeNone": "No ocultes la fuente de la visita", "AnonymizeReferrerExcludePath": "Solo mantener el dominio de un URL referido", + "AnonymizeReferrerExcludeQuery": "Eliminar los parámetros de consulta de la URL de referencia", "AnonymizeReferrerNote": "Matomo almacena la URL (referente) desde la que un usuario llega a su sitio. En algunos casos, dicha URL puede contener información que puede considerarse información personal. Si desea evitar que se rastree dicha información, puede restringir la cantidad de datos de referencia que Matomo almacenará cuando un visitante ingrese a su sitio web. Cuanta más información se elimine de la información de referencia, menos probable será que se registre información personal. Cuanta más información elimine de la información de referencia, menos claro será cómo un visitante llegó a su sitio web.", "AnonymizeRowDataFrom": "Anonimizar todos los datos sin procesar a partir de:", "AnonymizeRowDataTo": "Anonimizar todos los datos sin procesar hasta:", @@ -3755,8 +3759,10 @@ "Compliance": "Cumplimiento", "ComplianceComplianceUnknown": "desconocido", "ComplianceCompliant": "conforme", + "ComplianceEnforceCheckboxHelp": "Al activar esta opción, los cambios de configuración compatibles se aplicarán automáticamente en Matomo.

Ten en cuenta que algunas condiciones requieren cambios en la implementación del cliente fuera del entorno de Matomo.
Estos elementos no se controlan ni se verifican a través de esta interfaz de usuario.", "ComplianceEnforceCheckboxIntro": "Garantizar el cumplimiento siempre que sea posible", "ComplianceEnforceCheckboxTitle": "Reforzar ajustes que ayuden a alinear la exención de consentimiento con la CNIL siempre que sea posible", + "ComplianceNonCompliant": "no conforme", "ComplianceSelectSite": "A continuación selecciona un sitio para obtener una indicación si el sitio dado cumple con la ley de privacidad indicada", "ComplianceTableSettingName": "Nombre de la configuración", "ComplianceTableSettingNotes": "Notas", @@ -3870,7 +3876,8 @@ "ValidConsentRequirement6": "El consentimiento no debe ir acompañado de otros términos.", "ValidConsentRequirement7": "Se deben ofrecer opciones de consentimiento detalladas.", "ValidConsentRequirement8": "El consentimiento debe quedar registrado como prueba de cumplimiento.", - "ValidConsentRequirement9": "No se permite el seguimiento antes del consentimiento cuando este sea requerido." + "ValidConsentRequirement9": "No se permite el seguimiento antes del consentimiento cuando este sea requerido.", + "WhenDoINeedConsent": "¿Cuándo tengo que solicitar el consentimiento del usuario?" }, "ProfessionalServices": { "CTAStartFreeTrial": "Iniciar prueba gratuita", diff --git a/app/lang/lv.json b/app/lang/lv.json index 930a33256..ce81f8b9d 100644 --- a/app/lang/lv.json +++ b/app/lang/lv.json @@ -2971,10 +2971,12 @@ "CategoryClicks": "Klikšķi", "CategoryCustom": "Pielāgots", "CategoryDate": "Datums", + "CategoryDevelopers": "Izstrādātāji", "CategoryEmail": "E-pasta adrese", "CategoryErrors": "Kļūdas", "CategoryForms": "Formas", "CategoryHistory": "Vēsture", + "Change": "Mainīt", "ChooseWebsite": "Izvēlieties lapu", "ComparisonContains": "satur", "ContainerLowercase": "konteiners", @@ -3002,6 +3004,7 @@ "TagManager": "Atzīmju pārvaldnieks", "TriggerLowercase": "izsaucējs", "Type": "Tips", + "Types": "Tipi", "UserAgentVariableName": "Lietotāja aģents", "VariableLowercase": "mainīgais", "Version": "Versija" diff --git a/app/lang/pt-br.json b/app/lang/pt-br.json index 5d9d45b30..7b1db551e 100644 --- a/app/lang/pt-br.json +++ b/app/lang/pt-br.json @@ -4444,7 +4444,7 @@ "AddThisTagPubIdTitle": "AddThis PubId", "AllDownloadsClickTriggerDescription": "Acionado quando um link é clicado para um arquivo para baixar. Ele será acionado no clique esquerdo, médio e direito.", "AllDownloadsClickTriggerDownloadExtensionsTitle": "Baixar extensões", - "AllDownloadsClickTriggerHelp": "Acionado quando um usuário clica em um elemento \"A\" ou \"AREA\" e o link aponta para um arquivo com uma extensão de arquivo baixável.", + "AllDownloadsClickTriggerHelp": "Acionado quando um usuário clica em um elemento \"A\" ou \"AREA\" e o link aponta para um arquivo com uma extensão de arquivo para download. Para habilitar o rastreamento em um botão específico, adicione condições baseadas na variável \"ClickButton\" nas configurações avançadas.", "AllDownloadsClickTriggerName": "Clique em Todos os Downloas", "AllElementsClickTriggerDescription": "Acionado quando qualquer elemento é clicado. Ele será acionado no clique esquerdo, médio e direito.", "AllElementsClickTriggerHelp": "Acionado em qualquer clique em qualquer elemento. Para ouvir cliques em elementos específicos ou botão de clique específico, adicione condições baseadas em uma variável 'Click' ou em uma variável 'ClickButton' nas configurações avançadas.", @@ -4457,7 +4457,7 @@ "BackupVersionName": "Nome da versão de backup", "BackupVersionNameHelp": "Se você definir um nome de versão, uma nova versão com este nome será criada para fazer backup da atual versão de rascunho.", "BingUETTagDescription": "Adiciona a tag Universal Event Tracking do Bing Ads ao seu site para que você possa aplicar o rastreamento de conversão às suas campanhas de anúncios Bing.", - "BingUETTagHelp": "A tag rastreia o que os seus clientes estão fazendo após clicarem no seu anúncio Bing.", + "BingUETTagHelp": "A tag rastreia o que seus clientes fazem depois de clicar no seu anúncio do Bing.", "BingUETTagIdDescription": "Você pode encontrar o Bing Ad ID ao criar um novo código de rastreamento no Bing Ad Campaign Manager.", "BingUETTagIdTitle": "ID", "BingUETTagName": "Tag UET do Bing Ads", @@ -4469,7 +4469,7 @@ "BugsnagTagApiKeyTitle": "Chave da API", "BugsnagTagCollectUserIpDescription": "Isso deve ser desativado se você não quiser rastrear informações pessoais sobre seus usuários.", "BugsnagTagCollectUserIpTitle": "coletar IP do usuário", - "BugsnagTagDescription": "Adicione monitoramento de erros às suas aplicações com Bugsnag.", + "BugsnagTagDescription": "Adicione monitoramento de erros às suas aplicações com o Bugsnag.", "BugsnagTagHelp": "Esta tag adiciona a integração JavaScript padrão do Bugsnag ao seu site.", "CapabilityPublishLiveContainer": "Publicar contêiner ativo", "CapabilityPublishLiveContainerDescription": "Concede a habilidade de publicar um contêiner no ambiente ativo.", @@ -4538,7 +4538,7 @@ "ComparisonStartsWith": "começa com", "Condition": "Condição", "Conditions": "Condições", - "ConfigureEnvironmentsSuperUser": "Por favor observe que um usuário com acesso de Super Usuário pode configurar os ambientes disponíveis indo em \"Administração → Configurações gerais\".", + "ConfigureEnvironmentsSuperUser": "Observe que um usuário com acesso de Superusuário pode configurar os ambientes disponíveis acessando \"Administração → Configurações Gerais\".", "ConfigureThisTrigger": "Configure este gatilho", "ConfigureThisVariable": "Configure esta variável", "ConfigureWhatTagDoes": "Configure o que esta tag deve fazer", @@ -4591,7 +4591,7 @@ "CustomHtmlHtmlPositionTitle": "Posição", "CustomHtmlTagDescription": "Permite a você embutir qualquer HTML personalizado, por exemplo, JavaScript ou Estilos CSS.", "CustomHtmlTagDescriptionText": "Essa tag é ideal quando você precisa adicionar, por exemplo, estilos personalizados ou JavaScript personalizado ou quando procura uma tag específica que ainda não é suportada. Com essa tag, você pode anexar qualquer HTML ao final da página, adicionar estilos ou executar JavaScript. Observação: você pode substituir o conteúdo dentro do HTML por variáveis colocando um nome de variável entre chaves como este {{PageUrl}}.", - "CustomHtmlTagHelp": "A tag de HTML personalizado permite a você embutir qualquer tag que não seja suportada ainda. As possibilidades com este gatilho são praticamente ilimitadas.", + "CustomHtmlTagHelp": "A tag de HTML Personalizado permite incorporar qualquer tag que ainda não seja suportada. As possibilidades com essa tag são praticamente ilimitadas.", "CustomHtmlTagHelpText": "%1$sSaiba mais%2$s", "CustomHtmlTagName": "HTML personalizado", "CustomHtmlTagTitle": "HTML personalizado", @@ -4609,7 +4609,7 @@ "DataLayerVariableHelp": "Usando esta variável você pode acessar qualquer valor armazenado na Camada de Dados. Você pode também mandar valores para a Camada de Dados você mesmo e acessá-los desta maneira posteriormente.", "DataLayerVariableName": "Camada de Dados", "DebugUrlNoUrlErrorMessage": "Insira uma URL para iniciar a depuração.", - "DebugUrlSameUrlErrorMessage": "Já depurando o mesmo site, visite o %1$ssite%2$s ou insira uma nova URL para depurar.", + "DebugUrlSameUrlErrorMessage": "Já depurando o mesmo site, visite o %1$ssite%2$s ou insira uma nova url para depurar.", "DefaultContainer": "Contêiner padrão", "DefaultValue": "Valor padrão", "DefaultValueHelp": "Você pode configurar um valor padrão que será usado se a variável não retorna um valor. Por favor observe que uma string vazia ('') é considerada um valor e não retornará o valor padrão, configure um valor de pesquisa para este caso se necessário. Também observe que o valor padrão será aplicado antes de a tabela de pesquisa ser avaliada.", @@ -4690,8 +4690,8 @@ "ErrorPreviewReservedEnvironment": "O ambiente de pré-visualização não pode ser configurado pois é um ambiente reservado", "ErrorTriggerAtPositionXDoesNotExist": "O gatilho \"%1$s\" na posição \"%2$s\" não existe.", "ErrorTriggerNotRemovableAsInUse": "Este gatilho não pode ser excluído pois ele é usado em uma tag. Para remover este gatilho, antes atualize qualquer tag referenciada.", - "ErrorUrlVariableDescription": "Retorna a URL de um erro, quando um erro foi acionado anteriormente.", - "ErrorUrlVariableName": "URL do erro", + "ErrorUrlVariableDescription": "Retorna a URL de um erro, caso um erro tenha sido disparado anteriormente.", + "ErrorUrlVariableName": "Url de erro", "ErrorVariableInConditionAtPositionNotFound": "A variável \"%1$s\" na condição na posição \"%2$s\" não pode ser encontrada.", "ErrorVariableInvalidDefaultValue": "O valor padrão precisa ser vazio, uma string, ou um número.", "ErrorVariableNameInUseByPreconfiguredVariable": "Este nome de variável não pode ser usado pois uma variável pré-configurada já usa este nome.", @@ -4769,7 +4769,7 @@ "MatomoConfigurationMatomoUserIdTitle": "ID do usuário", "MatomoConfigurationVariableDescription": "Define uma configuração para o Matomo Analytics.", "MatomoConfigurationVariableName": "Configuração do Matomo", - "MatomoTagDescription": "O Matomo, antes conhecido como Piwik, é a plataforma de código aberto líder em analytics.", + "MatomoTagDescription": "O Matomo é a principal plataforma de análise de código aberto.", "MatomoTagEcommerceViewPrice": "Preço", "MatomoTagEcommerceViewProductName": "Nome do produto", "MatomoTagEcommerceViewProductSKU": "Produto SKU", @@ -4817,7 +4817,7 @@ "UpdatingDebugSiteUrlPleaseWait": "Atualizando URL do site de depuração, aguarde…", "UrlParameterVariableName": "Parâmetro da URL", "UserAgentVariableName": "Agente do Usuário", - "UtcDateVariableDescription": "A data atual em UTC, por exemplo \"Seg, 19 Mar 2018 14:00:00 GMT\".", + "UtcDateVariableDescription": "A data atual em UTC, por exemplo, \"Mon, 19 Mar 2018 14:00:00 GMT\".", "UtcDateVariableName": "Data UTC", "Variable": "Variável", "VariableLowercase": "variável", diff --git a/app/package-lock.json b/app/package-lock.json index d6eefff59..75bb1dea3 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -15429,9 +15429,9 @@ ] }, "node_modules/dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", "dev": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -48697,9 +48697,9 @@ } }, "dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", "dev": true, "requires": { "@types/trusted-types": "^2.0.7" diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 9de91c00b..8ae30d014 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -575,11 +575,6 @@ parameters: count: 1 path: core/ExceptionHandler.php - - - message: "#^Method Piwik\\\\Filesystem\\:\\:getFileSize\\(\\) should return float\\|null but empty return statement found\\.$#" - count: 1 - path: core/Filesystem.php - - message: "#^Negated boolean expression is always true\\.$#" count: 1 @@ -750,11 +745,6 @@ parameters: count: 1 path: core/Plugin/LogTablesProvider.php - - - message: "#^Method Piwik\\\\Plugin\\\\LogTablesProvider\\:\\:getLogTable\\(\\) should return Piwik\\\\Tracker\\\\LogTable\\|null but return statement is missing\\.$#" - count: 1 - path: core/Plugin/LogTablesProvider.php - - message: "#^Right side of \\|\\| is always false\\.$#" count: 1 @@ -815,16 +805,6 @@ parameters: count: 1 path: core/Plugin/Visualization.php - - - message: "#^Method Piwik\\\\Plugin\\\\WidgetsProvider\\:\\:factory\\(\\) should return Piwik\\\\Widget\\\\Widget\\|null but empty return statement found\\.$#" - count: 3 - path: core/Plugin/WidgetsProvider.php - - - - message: "#^Method Piwik\\\\Plugin\\\\WidgetsProvider\\:\\:factory\\(\\) should return Piwik\\\\Widget\\\\Widget\\|null but return statement is missing\\.$#" - count: 1 - path: core/Plugin/WidgetsProvider.php - - message: "#^If condition is always true\\.$#" count: 1 @@ -1090,21 +1070,6 @@ parameters: count: 1 path: core/Session/SaveHandler/DbTable.php - - - message: "#^Method Piwik\\\\Session\\\\SessionAuth\\:\\:getLogin\\(\\) should return string\\|null but return statement is missing\\.$#" - count: 1 - path: core/Session/SessionAuth.php - - - - message: "#^Method Piwik\\\\Session\\\\SessionAuth\\:\\:getName\\(\\) should return string\\|null but return statement is missing\\.$#" - count: 1 - path: core/Session/SessionAuth.php - - - - message: "#^Method Piwik\\\\Session\\\\SessionAuth\\:\\:getTokenAuthSecret\\(\\) should return string\\|null but return statement is missing\\.$#" - count: 1 - path: core/Session/SessionAuth.php - - message: "#^Parameter \\#2 \\$login of class Piwik\\\\AuthResult constructor expects string, null given\\.$#" count: 1 @@ -1130,11 +1095,6 @@ parameters: count: 1 path: core/Settings/Setting.php - - - message: "#^Method Piwik\\\\Settings\\\\Settings\\:\\:getSetting\\(\\) should return Piwik\\\\Settings\\\\Setting\\|null but return statement is missing\\.$#" - count: 1 - path: core/Settings/Settings.php - - message: "#^Property Piwik\\\\Settings\\\\Storage\\\\Backend\\\\BaseSettingsTable\\:\\:\\$db \\(Piwik\\\\Db\\\\AdapterInterface\\) in isset\\(\\) is not nullable\\.$#" count: 1 @@ -1260,16 +1220,6 @@ parameters: count: 1 path: core/Tracker/Db.php - - - message: "#^Method Piwik\\\\Tracker\\\\Db\\\\Mysqli\\:\\:beginTransaction\\(\\) should return string\\|null but empty return statement found\\.$#" - count: 1 - path: core/Tracker/Db/Mysqli.php - - - - message: "#^Method Piwik\\\\Tracker\\\\Db\\\\Mysqli\\:\\:beginTransaction\\(\\) should return string\\|null but return statement is missing\\.$#" - count: 1 - path: core/Tracker/Db/Mysqli.php - - message: "#^Method Piwik\\\\Tracker\\\\Db\\\\Mysqli\\:\\:prepare\\(\\) is unused\\.$#" count: 1 @@ -1285,16 +1235,6 @@ parameters: count: 4 path: core/Tracker/Db/Mysqli.php - - - message: "#^Method Piwik\\\\Tracker\\\\Db\\\\Pdo\\\\Mysql\\:\\:beginTransaction\\(\\) should return string\\|null but empty return statement found\\.$#" - count: 1 - path: core/Tracker/Db/Pdo/Mysql.php - - - - message: "#^Method Piwik\\\\Tracker\\\\Db\\\\Pdo\\\\Mysql\\:\\:beginTransaction\\(\\) should return string\\|null but return statement is missing\\.$#" - count: 1 - path: core/Tracker/Db/Pdo/Mysql.php - - message: "#^Method Piwik\\\\Tracker\\\\Db\\\\Pdo\\\\Mysql\\:\\:lastInsertId\\(\\) should return int but returns string\\|false\\.$#" count: 1 @@ -1580,11 +1520,6 @@ parameters: count: 1 path: core/ViewDataTable/Factory.php - - - message: "#^Method Piwik\\\\ViewDataTable\\\\Factory\\:\\:getReport\\(\\) should return Piwik\\\\Plugin\\\\Report\\|null but empty return statement found\\.$#" - count: 1 - path: core/ViewDataTable/Factory.php - - message: "#^Static property Piwik\\\\ViewDataTable\\\\Factory\\:\\:\\$defaultViewTypes is never read, only written\\.$#" count: 1 @@ -1635,16 +1570,6 @@ parameters: count: 1 path: core/ViewDataTable/RequestConfig.php - - - message: "#^Method Piwik\\\\Visualization\\\\Sparkline\\:\\:render\\(\\) should return string\\|null but empty return statement found\\.$#" - count: 1 - path: core/Visualization/Sparkline.php - - - - message: "#^Method Piwik\\\\Visualization\\\\Sparkline\\:\\:render\\(\\) should return string\\|null but return statement is missing\\.$#" - count: 1 - path: core/Visualization/Sparkline.php - - message: "#^Parameter \\#2 \\$newvalue of function ini_set expects string, int given\\.$#" count: 2 @@ -2610,11 +2535,6 @@ parameters: count: 1 path: plugins/LanguagesManager/TranslationWriter/Writer.php - - - message: "#^Method Piwik\\\\Plugins\\\\Live\\\\Reports\\\\GetLastVisits\\:\\:buildReportMetadata\\(\\) should return array\\|null but return statement is missing\\.$#" - count: 1 - path: plugins/Live/Reports/GetLastVisits.php - - message: "#^If condition is always true\\.$#" count: 1 @@ -2895,11 +2815,6 @@ parameters: count: 1 path: plugins/SitesManager/Model.php - - - message: "#^Method Piwik\\\\Plugins\\\\SitesManager\\\\SitesManager\\:\\:getTimezoneFromWebsite\\(\\) should return string\\|null but return statement is missing\\.$#" - count: 1 - path: plugins/SitesManager/SitesManager.php - - message: "#^Parameter \\#2 \\$callback of function array_filter expects \\(callable\\(string\\)\\: bool\\)\\|null, 'strlen' given\\.$#" count: 1 diff --git a/app/plugins/AIProviders/AIConversationRequest.php b/app/plugins/AIProviders/AIConversationRequest.php new file mode 100644 index 000000000..a30eeca03 --- /dev/null +++ b/app/plugins/AIProviders/AIConversationRequest.php @@ -0,0 +1,251 @@ +converse( + * (new AIConversationRequest($messages, 'AskMatomo')) + * ->withSystemPrompt($systemPrompt) + * ->withTools($toolCatalog) + * ->withMaxTokens(2048) + * ); + * + * The provider returns one assistant turn per call; running tools and + * appending their results to the history for the next call is the caller's + * responsibility. + * + * The same hard rule as for {@link AIRequest} applies: values passed to + * {@link withProviderId()} and {@link withModel()} must originate from + * plugin code constants or server-side configuration — never, directly or + * indirectly, from request input. + * + * @phpstan-import-type CanonicalMessageArray from CanonicalMessage + * @phpstan-type ToolCatalogEntryArray array{ + * name: string, + * title?: string|null, + * description: string, + * inputSchema: array, + * outputSchema?: array|null, + * readOnly?: bool|null, + * destructive?: bool|null, + * idempotent?: bool|null, + * openWorld?: bool|null + * } + */ +class AIConversationRequest +{ + public const DEFAULT_MAX_TOKENS = 2048; + public const DEFAULT_TIMEOUT_SECONDS = 60; + /** + * Conversation history in canonical shape, oldest first. + * + * @var list + */ + private $messages; + /** + * Name of the plugin issuing the request, used for accountability and + * future usage accounting (for example `'AskMatomo'`). + * + * @var string + */ + private $callerPluginName; + /** + * @var string|null + */ + private $systemPrompt = null; + /** + * Tool catalogue offered to the model, in the MCP-aligned shape produced + * by the McpServer plugin's tool catalog. The annotation hints + * (readOnly/destructive/idempotent/openWorld) are advisory metadata for + * the caller's approval flow; providers only forward name, description, + * and inputSchema. + * + * @var list + */ + private $tools = []; + /** + * @var string|null + */ + private $providerId = null; + /** + * @var string|null + */ + private $model = null; + /** + * Optional identifier of the feature issuing the request, used for + * future usage accounting. + * + * @var string|null + */ + private $featureKey = null; + /** + * @var int + */ + private $maxTokens = self::DEFAULT_MAX_TOKENS; + /** + * @var float + */ + private $temperature = \Piwik\Plugins\AIProviders\AIRequest::DEFAULT_TEMPERATURE; + /** + * Requested model capability level (see Configuration::CAPABILITY_*), or + * null to defer to the configured default. + * + * @var string|null + */ + private $capabilityLevel = null; + /** + * Provider HTTP timeout. Conversational round-trips replay the whole + * history and may produce tool calls, so the default is more generous + * than for simple completions. + * + * @var int + */ + private $timeoutSeconds = self::DEFAULT_TIMEOUT_SECONDS; + /** + * @param list $messages + */ + public function __construct(array $messages, string $callerPluginName) + { + $this->messages = $messages; + $this->callerPluginName = $callerPluginName; + } + public function withSystemPrompt(?string $systemPrompt) : self + { + $request = clone $this; + $request->systemPrompt = $systemPrompt; + return $request; + } + /** + * Offers a tool catalogue to the model. See the `$tools` property for the + * expected shape. + * + * @param list $tools + */ + public function withTools(array $tools) : self + { + $request = clone $this; + $request->tools = $tools; + return $request; + } + /** + * Requests a specific provider. Honoured on unmanaged instances and for + * allowlisted caller plugins on managed instances; otherwise the forced + * default provider wins. The value must be a plugin constant or + * server-side config value, never request input — see the class docblock. + */ + public function withProviderId(?string $providerId) : self + { + $request = clone $this; + $request->providerId = $providerId; + return $request; + } + /** + * Requests a specific model. Stripped on managed instances unless the + * caller plugin is allowlisted, because the model decides cost there. + * The value must be a plugin constant or server-side config value, never + * request input — see the class docblock. + */ + public function withModel(?string $model) : self + { + $request = clone $this; + $request->model = $model; + return $request; + } + public function withFeatureKey(?string $featureKey) : self + { + $request = clone $this; + $request->featureKey = $featureKey; + return $request; + } + public function withMaxTokens(int $maxTokens) : self + { + $request = clone $this; + $request->maxTokens = $maxTokens; + return $request; + } + public function withTemperature(float $temperature) : self + { + $request = clone $this; + $request->temperature = $temperature; + return $request; + } + public function withCapabilityLevel(?string $capabilityLevel) : self + { + $request = clone $this; + $request->capabilityLevel = $capabilityLevel; + return $request; + } + public function withTimeoutSeconds(int $timeoutSeconds) : self + { + $request = clone $this; + $request->timeoutSeconds = max(1, $timeoutSeconds); + return $request; + } + /** + * @return list + */ + public function getMessages() : array + { + return $this->messages; + } + public function getCallerPluginName() : string + { + return $this->callerPluginName; + } + public function getSystemPrompt() : ?string + { + return $this->systemPrompt; + } + /** + * @return list + */ + public function getTools() : array + { + return $this->tools; + } + public function getProviderId() : ?string + { + return $this->providerId; + } + public function getModel() : ?string + { + return $this->model; + } + public function getFeatureKey() : ?string + { + return $this->featureKey; + } + public function getMaxTokens() : int + { + return $this->maxTokens; + } + public function getTemperature() : float + { + return $this->temperature; + } + public function getCapabilityLevel() : ?string + { + return $this->capabilityLevel; + } + public function getTimeoutSeconds() : int + { + return $this->timeoutSeconds; + } +} diff --git a/app/plugins/AIProviders/AIConversationResponse.php b/app/plugins/AIProviders/AIConversationResponse.php new file mode 100644 index 000000000..e993ed296 --- /dev/null +++ b/app/plugins/AIProviders/AIConversationResponse.php @@ -0,0 +1,142 @@ + + */ + private $content; + /** + * @var string + */ + private $stopReason; + /** + * Number of input (prompt) tokens reported by the provider, or null when + * the provider does not report token usage. + * + * @var int|null + */ + private $inputTokens; + /** + * Number of output (completion) tokens reported by the provider, or null + * when the provider does not report token usage. + * + * @var int|null + */ + private $outputTokens; + /** + * Total provider request time in milliseconds, including retries. + * + * @var int|null + */ + private $executionTimeMs; + /** + * @param list $content + */ + public function __construct(string $providerId, string $providerName, string $model, array $content, string $stopReason, ?int $inputTokens = null, ?int $outputTokens = null, ?int $executionTimeMs = null) + { + $this->providerId = $providerId; + $this->providerName = $providerName; + $this->model = $model; + $this->content = $content; + $this->stopReason = $stopReason; + $this->inputTokens = $inputTokens; + $this->outputTokens = $outputTokens; + $this->executionTimeMs = $executionTimeMs; + } + public function getProviderId() : string + { + return $this->providerId; + } + public function getProviderName() : string + { + return $this->providerName; + } + public function getModel() : string + { + return $this->model; + } + /** + * @return list + */ + public function getContent() : array + { + return $this->content; + } + public function getStopReason() : string + { + return $this->stopReason; + } + /** + * Concatenated text of all canonical text blocks. Convenience for + * logging and for turns that carry no tool calls; tool-aware callers + * should read {@link getContent()} instead. + */ + public function getText() : string + { + $parts = []; + foreach ($this->content as $block) { + if (($block['type'] ?? null) === 'text' && is_string($block['text'] ?? null)) { + $parts[] = $block['text']; + } + } + return implode("\n", $parts); + } + public function getInputTokens() : ?int + { + return $this->inputTokens; + } + public function getOutputTokens() : ?int + { + return $this->outputTokens; + } + public function getExecutionTimeMs() : ?int + { + return $this->executionTimeMs; + } +} diff --git a/app/plugins/AIProviders/AIProviderResponse.php b/app/plugins/AIProviders/AIProviderResponse.php new file mode 100644 index 000000000..d1d50b1a8 --- /dev/null +++ b/app/plugins/AIProviders/AIProviderResponse.php @@ -0,0 +1,140 @@ +providerId = $providerId; + $this->providerName = $providerName; + $this->model = $model; + $this->text = $text; + $this->inputTokens = $inputTokens; + $this->outputTokens = $outputTokens; + $this->reasoningLevel = $reasoningLevel; + $this->webSearchEnabled = $webSearchEnabled; + $this->executionTimeMs = $executionTimeMs; + $this->stopReason = $stopReason; + } + public function getText() : string + { + return $this->text; + } + public function getModel() : string + { + return $this->model; + } + public function getInputTokens() : ?int + { + return $this->inputTokens; + } + public function getOutputTokens() : ?int + { + return $this->outputTokens; + } + public function getReasoningLevel() : string + { + return $this->reasoningLevel; + } + public function isWebSearchEnabled() : bool + { + return $this->webSearchEnabled; + } + public function getExecutionTimeMs() : ?int + { + return $this->executionTimeMs; + } + public function getStopReason() : ?string + { + return $this->stopReason; + } + /** + * Returns the response text decoded as a JSON array/object, or null when the + * text is not valid JSON. Intended for requests made with + * {@link AIRequest::withJsonResponse()}. + * + * @return array|null + */ + public function getJsonData() : ?array + { + $decoded = json_decode($this->stripJsonCodeFence($this->text), \true); + return is_array($decoded) ? $decoded : null; + } + private function stripJsonCodeFence(string $text) : string + { + $text = trim($text); + if (preg_match('/^```(?:json)?\\s*(.*?)\\s*```$/is', $text, $matches) === 1) { + return trim($matches[1]); + } + return $text; + } + /** + * @return array + */ + public function toArray() : array + { + return ['providerId' => $this->providerId, 'providerName' => $this->providerName, 'model' => $this->model, 'text' => $this->text, 'inputTokens' => $this->inputTokens, 'outputTokens' => $this->outputTokens, 'reasoningLevel' => $this->reasoningLevel, 'webSearchEnabled' => $this->webSearchEnabled, 'executionTimeMs' => $this->executionTimeMs, 'stopReason' => $this->stopReason]; + } +} diff --git a/app/plugins/AIProviders/AIProviderService.php b/app/plugins/AIProviders/AIProviderService.php new file mode 100644 index 000000000..8ee09c49f --- /dev/null +++ b/app/plugins/AIProviders/AIProviderService.php @@ -0,0 +1,335 @@ +complete(new AIRequest($prompt, 'Goals')); + * $text = $response->getText(); + */ +class AIProviderService +{ + /** Conversational features can run: provider configured and capable. */ + public const CONVERSATION_READY = 'ready'; + /** No conversation-capable provider is configured (missing credentials or no provider). */ + public const CONVERSATION_NOT_CONFIGURED = 'not_configured'; + /** A provider is selected but does not implement conversations. */ + public const CONVERSATION_PROVIDER_UNSUPPORTED = 'provider_unsupported'; + /** + * @var Configuration + */ + private $configuration; + public function __construct(Configuration $configuration) + { + $this->configuration = $configuration; + } + /** + * Completes the given request. + * + * The provider is resolved in this order: the provider forced by a managed + * environment, then the provider requested by + * the caller, then the configured default provider. A managed environment + * wins even when the caller requests another provider, except for callers + * on the `providerSelectionAllowlist` of the managed config, whose + * requested provider and model are honoured (see below). + * + * Allowlisted callers (for example a plugin that must query several AI + * engines because comparing the engines is the feature itself) get no + * silent fallback: an unknown requested provider throws, and an + * unconfigured one fails in the provider. Falling back to the forced + * provider would silently produce answers from the wrong engine, which is + * worse than a clear error. + * + * The requested model is forwarded for allowlisted callers and on + * unmanaged instances, and stripped otherwise. + */ + public function complete(\Piwik\Plugins\AIProviders\AIRequest $request) : \Piwik\Plugins\AIProviders\AIProviderResponse + { + // use the capability level set in the admin ui, unless overwritten through the request + if ($request->getCapabilityLevel() === null) { + $request = $request->withCapabilityLevel($this->configuration->getDefaultCapabilityLevel()); + } + $providers = \Piwik\Plugins\AIProviders\AIProviders::getAvailableProviders(); + $resolution = $this->resolveProviderId($request->getProviderId(), $request->getCallerPluginName(), $providers); + if ($resolution['stripRequestedModel']) { + $request = $request->withProviderId($resolution['providerId'])->withModel(null); + } + $provider = $this->requireProvider($providers, $resolution['providerId']); + $configuration = $this->configuration->getProviderConfiguration($provider); + $response = $this->runWithProvider($provider, $configuration, $request); + /** + * TODO: publish an observability event here so billing or monitoring can + * hook into AI usage without this plugin depending on them. Emit basic + * data, for example: + * + * Piwik::postEvent('AIProviders.usage', [[ + * 'caller' => $request->getCallerPluginName(), + * 'feature' => $request->getFeatureKey(), + * 'idSite' => $request->getIdSite(), + * 'login' => Piwik::getCurrentUserLogin(), + * 'provider' => $provider->getId(), + * 'model' => $response->getModel(), + * 'tokensIn' => $response->getInputTokens(), + * 'tokensOut' => $response->getOutputTokens(), + * ]]); + * + * Not implemented yet. + */ + return $response; + } + /** + * Runs one conversational round-trip (multi-turn messages plus optional + * tool calls) and returns the assistant's turn. + * + * The provider is resolved exactly like in {@link complete()}: forced + * provider, then the caller's requested provider when allowlisted, then + * the configured default. A provider that does not support conversations + * fails with a clear error instead of silently degrading; callers should + * gate conversational features on {@link canConverse()}. + * + * Unlike {@link complete()}, an empty text response is valid here: a turn + * may consist solely of tool_use blocks. + */ + public function converse(\Piwik\Plugins\AIProviders\AIConversationRequest $request) : \Piwik\Plugins\AIProviders\AIConversationResponse + { + // Use the capability level set in the admin UI unless the request overrides it. + if ($request->getCapabilityLevel() === null) { + $request = $request->withCapabilityLevel($this->configuration->getDefaultCapabilityLevel()); + } + $providers = \Piwik\Plugins\AIProviders\AIProviders::getAvailableProviders(); + $resolution = $this->resolveProviderId($request->getProviderId(), $request->getCallerPluginName(), $providers); + if ($resolution['stripRequestedModel']) { + $request = $request->withProviderId($resolution['providerId'])->withModel(null); + } + $provider = $this->requireProvider($providers, $resolution['providerId']); + if (!$provider->supportsConversations()) { + throw new AIProviderClientException(sprintf('%s does not support multi-turn conversations.', $provider->getName())); + } + $configuration = $this->configuration->getProviderConfiguration($provider); + // TODO: publish the same `AIProviders.usage` observability event as + // planned for complete() once it is implemented there. + return $provider->converse($request, $configuration); + } + /** + * Returns whether conversational features can run right now: the default + * provider (honouring a managed environment's forced provider) exists, is + * configured, and supports conversations. Plugins offering chat-style + * features should hide or disable themselves when this returns false; use + * {@link getConversationAvailability()} when the reason matters for the UI. + */ + public function canConverse() : bool + { + return $this->getConversationAvailability()['status'] === self::CONVERSATION_READY; + } + /** + * Reports whether conversational features can run, and why not when they + * cannot, so callers can show an actionable message instead of a generic + * "not configured" notice. + * + * `status` is one of: + * - {@link CONVERSATION_READY}: the default provider exists, supports + * conversations, and is configured. + * - {@link CONVERSATION_PROVIDER_UNSUPPORTED}: a provider is selected but + * does not implement conversations (for example a completion-only + * provider). The fix is to switch providers, so this wins over a missing + * configuration. + * - {@link CONVERSATION_NOT_CONFIGURED}: the conversation-capable provider + * has no credentials yet, or no provider could be resolved. + * + * `providerId`/`providerName` identify the resolved provider when one + * exists, so the message can name it. + * + * @return array{status: string, providerId: ?string, providerName: ?string} + */ + public function getConversationAvailability() : array + { + try { + $provider = $this->getDefaultProvider(); + } catch (InvalidArgumentException $e) { + return ['status' => self::CONVERSATION_NOT_CONFIGURED, 'providerId' => null, 'providerName' => null]; + } + $base = ['providerId' => $provider->getId(), 'providerName' => $provider->getName()]; + if (!$provider->supportsConversations()) { + return ['status' => self::CONVERSATION_PROVIDER_UNSUPPORTED] + $base; + } + if (!$provider->isConfigured($this->configuration->getProviderConfiguration($provider))) { + return ['status' => self::CONVERSATION_NOT_CONFIGURED] + $base; + } + return ['status' => self::CONVERSATION_READY] + $base; + } + /** + * Resolves which provider a request runs through: the caller's requested + * provider (unless {@link isLockedToForcedProvider()}), then the forced + * provider, then the configured default. + * + * `stripRequestedModel` is true when the forced provider overrode the + * request; the requested model must then be dropped too, because the + * model decides cost on managed instances. + * + * @return array{providerId: string, stripRequestedModel: bool} + */ + private function resolveProviderId(?string $requestedProviderId, string $callerPluginName, \Piwik\Plugins\AIProviders\AIProvidersList $providers) : array + { + $forcedProviderId = $this->configuration->getForcedProviderId(); + $hasRequestedProvider = $requestedProviderId !== null && $requestedProviderId !== ''; + if ($hasRequestedProvider && $forcedProviderId !== null && !$this->isLockedToForcedProvider($forcedProviderId, $callerPluginName)) { + // TODO: consider validating the requested model against a + // per-provider `allowedModels` list from the managed config as a + // cost backstop, once the planned `AIProviders.usage` event shows + // whether actual token usage needs it. + return ['providerId' => $requestedProviderId, 'stripRequestedModel' => \false]; + } + if ($forcedProviderId !== null) { + return ['providerId' => $forcedProviderId, 'stripRequestedModel' => \true]; + } + if ($hasRequestedProvider) { + return ['providerId' => $requestedProviderId, 'stripRequestedModel' => \false]; + } + return ['providerId' => $this->configuration->getDefaultProviderId($providers), 'stripRequestedModel' => \false]; + } + /** + * The managed-mode policy gate shared by {@link resolveProviderId()} and + * {@link getProviderStatusesForCaller()}: a caller is locked to the forced + * provider unless it is on the `providerSelectionAllowlist`. + */ + private function isLockedToForcedProvider(?string $forcedProviderId, string $callerPluginName) : bool + { + return $forcedProviderId !== null && !$this->configuration->isPluginAllowedToSelectProvider($callerPluginName); + } + private function requireProvider(\Piwik\Plugins\AIProviders\AIProvidersList $providers, string $providerId) : AIProvider + { + $provider = $providers->getProvider($providerId); + if ($provider === null) { + // Untranslated on purpose: only a plugin passing its own provider ID + // reaches this, never the settings form. + throw new InvalidArgumentException(sprintf('Unknown AI provider "%s".', $providerId)); + } + return $provider; + } + /** + * Validates a specific provider and configuration, with no provider + * resolution. Restricted to the admin "test connection" flow, which needs + * to test an unsaved provider/configuration before it is stored. Delegates + * to the provider's lightweight connection probe and throws on failure. + * + * Callers must enforce their own access control (the admin API gates this + * behind super-user access). Because it bypasses resolution — including the + * provider forced by a managed environment — it must not be used as a general + * completion entry point; use {@link complete()} for that. + * + * @param array{apiKey?: string, endpointUrl?: string, model?: string, useFipsEndpoint?: bool} $configuration + */ + public function testProviderConnection(AIProvider $provider, array $configuration) : void + { + $provider->verifyConnection($configuration); + } + /** + * Executes the request against the given provider and guards against an empty + * completion. Used by {@link complete()}. + * + * @param array{apiKey?: string, endpointUrl?: string, model?: string, useFipsEndpoint?: bool} $configuration + */ + private function runWithProvider(AIProvider $provider, array $configuration, \Piwik\Plugins\AIProviders\AIRequest $request) : \Piwik\Plugins\AIProviders\AIProviderResponse + { + $response = $provider->complete($request, $configuration); + if (trim($response->getText()) === '') { + throw new \RuntimeException(sprintf('%s returned an empty response.', $provider->getName())); + } + return $response; + } + /** + * Returns the provider that completions run through by default, honouring a + * provider forced by a managed environment. + */ + public function getDefaultProvider() : AIProvider + { + $providers = \Piwik\Plugins\AIProviders\AIProviders::getAvailableProviders(); + $forcedProviderId = $this->configuration->getForcedProviderId(); + $providerId = $forcedProviderId ?? $this->configuration->getDefaultProviderId($providers); + return $this->requireProvider($providers, $providerId); + } + /** + * Returns the configured default model capability level. + */ + public function getDefaultCapabilityLevel() : string + { + return $this->configuration->getDefaultCapabilityLevel(); + } + /** + * Returns whether the plugin runs in a managed environment, that is, the + * default provider is forced (and locked) from configuration so settings + * cannot be changed from the administration UI. + */ + public function isManaged() : bool + { + return $this->configuration->isManaged(); + } + /** + * Returns provider status metadata for the administration UI. + * + * Restricted providers (registered as non-selectable by a managed + * environment) are excluded so they stay hidden from admin surfaces; + * completion callers use {@link getProviderStatusesForCaller()} instead. + * + * @return array + */ + public function getAvailableProviderStatuses() : array + { + $providers = \Piwik\Plugins\AIProviders\AIProviders::getAvailableProviders(); + $defaultProviderId = $this->configuration->getForcedProviderId() ?? $this->configuration->getDefaultProviderId($providers); + return array_map(function (AIProvider $provider) use($defaultProviderId) : array { + $configuration = $this->configuration->getProviderConfiguration($provider); + return ['id' => $provider->getId(), 'name' => $provider->getName(), 'description' => $provider->getDescription(), 'defaultModel' => $provider->getDefaultModel(), 'isDefault' => $provider->getId() === $defaultProviderId, 'isConfigured' => $provider->isConfigured($configuration), 'supportsCustomEndpoint' => $provider->supportsCustomEndpoint(), 'endpointUrl' => $configuration['endpointUrl']]; + }, $providers->getSelectableProviders()); + } + /** + * Returns the providers the given caller can run completions through, + * flagged with whether credentials are in place. Follows the provider + * resolution of {@link complete()}. + * + * The caller name is self-declared (same trust model as complete()): + * pass a hardcoded plugin name, and gate any HTTP exposure of the result + * with the feature's usual access check. + * + * @return array + */ + public function getProviderStatusesForCaller(string $callerPluginName) : array + { + $providers = \Piwik\Plugins\AIProviders\AIProviders::getAvailableProviders(); + $forcedProviderId = $this->configuration->getForcedProviderId(); + if ($this->isLockedToForcedProvider($forcedProviderId, $callerPluginName)) { + $forced = $providers->getProvider($forcedProviderId); + $usableProviders = $forced !== null ? [$forced] : []; + } else { + $usableProviders = $providers->getProviders(); + } + return array_map(function (AIProvider $provider) : array { + return ['id' => $provider->getId(), 'name' => $provider->getName(), 'isConfigured' => $provider->isConfigured($this->configuration->getProviderConfiguration($provider))]; + }, $usableProviders); + } +} diff --git a/app/plugins/AIProviders/AIProviders.php b/app/plugins/AIProviders/AIProviders.php new file mode 100644 index 000000000..0bb5ed83e --- /dev/null +++ b/app/plugins/AIProviders/AIProviders.php @@ -0,0 +1,127 @@ + 'addAIProviders', 'Translate.getClientSideTranslationKeys' => 'getClientSideTranslationKeys']; + } + /** + * Registers the built-in AI providers. + * + * Provider IDs are unique and cannot be overwritten: the first registration + * for an ID wins (see {@link AIProvidersList::addProvider()}). This protects + * the built-in providers, and a provider that is centrally forced in a + * managed environment, from being shadowed by another plugin. + */ + public function addAIProviders(\Piwik\Plugins\AIProviders\AIProvidersList $providers) : void + { + $providers->addProvider(new \Piwik\Plugins\AIProviders\Provider\Anthropic()); + $providers->addProvider(new \Piwik\Plugins\AIProviders\Provider\Google()); + $providers->addProvider(new \Piwik\Plugins\AIProviders\Provider\OpenAI()); + $providers->addProvider(new \Piwik\Plugins\AIProviders\Provider\Bedrock()); + $providers->addProvider(new \Piwik\Plugins\AIProviders\Provider\CustomProvider()); + } + /** + * Returns all AI providers registered by active plugins. + */ + public static function getAvailableProviders() : \Piwik\Plugins\AIProviders\AIProvidersList + { + $providers = new \Piwik\Plugins\AIProviders\AIProvidersList(); + /** + * Triggered to let plugins register AI providers. + * + * Plugins can add providers by calling `$providers->addProvider()` with + * a `Piwik\Plugins\AIProviders\Provider\AIProvider` instance. + * + * **Example** + * + * public function registerEvents() + * { + * return ['AIProviders.addAIProviders' => 'addAIProviders']; + * } + * + * public function addAIProviders(AIProvidersList $providers): void + * { + * $providers->addProvider(new MyProvider()); + * } + * + * @param AIProvidersList $providers Provider registry to mutate. + */ + Piwik::postEvent('AIProviders.addAIProviders', [$providers]); + /** + * Triggered after providers have been registered, so plugins can remove + * or adjust providers before they are shown or used. + * + * A managed environment that wants providers hidden from users but + * still available to allowlisted plugins should demote them with + * `$providers->setSelectable($id, false)` instead of removing them + * (see {@link AIProvidersList}). To replace a built-in provider's + * implementation under the same ID (registration is first-wins, so + * shadowing it in `addAIProviders` is not possible), remove it here + * and register the replacement. + * + * @param AIProvidersList $providers Provider registry to mutate. + */ + Piwik::postEvent('AIProviders.filterAIProviders', [$providers]); + return $providers; + } + public function getClientSideTranslationKeys(array &$translations) : void + { + $translations[] = 'AIProviders_AnthropicDefaultModelDescription'; + $translations[] = 'AIProviders_ApiKey'; + $translations[] = 'AIProviders_ApiKeyAlreadyConfiguredPlaceholder'; + $translations[] = 'AIProviders_ApiKeyPlaceholder'; + $translations[] = 'AIProviders_BedrockDescription'; + $translations[] = 'AIProviders_BedrockEndpointPlaceholder'; + $translations[] = 'AIProviders_BedrockEndpointTitle'; + $translations[] = 'AIProviders_BedrockUseFipsEndpoint'; + $translations[] = 'AIProviders_ClickTestConnectionToShowAvailableModels'; + $translations[] = 'AIProviders_ConfigurationIntro'; + $translations[] = 'AIProviders_CustomProviderDescription'; + $translations[] = 'AIProviders_DefaultBadge'; + $translations[] = 'AIProviders_DefaultCapabilityLevel'; + $translations[] = 'AIProviders_DefaultCapabilityLevelHelp'; + $translations[] = 'AIProviders_DefaultProvider'; + $translations[] = 'AIProviders_DefaultProviderHelp'; + $translations[] = 'AIProviders_DefaultsTitle'; + $translations[] = 'AIProviders_Disconnect'; + $translations[] = 'AIProviders_DisconnectSuccess'; + $translations[] = 'AIProviders_Disconnecting'; + $translations[] = 'AIProviders_EndpointUrl'; + $translations[] = 'AIProviders_EndpointUrlPlaceholder'; + $translations[] = 'AIProviders_GoogleDefaultModelDescription'; + $translations[] = 'AIProviders_InstantCapability'; + $translations[] = 'AIProviders_InstantCapabilityDescription'; + $translations[] = 'AIProviders_ManagedConfigurationHelp'; + $translations[] = 'AIProviders_MenuTitle'; + $translations[] = 'AIProviders_Model'; + $translations[] = 'AIProviders_NoDefaultProviderWarning'; + $translations[] = 'AIProviders_OpenAIDefaultModelDescription'; + $translations[] = 'AIProviders_RefreshModels'; + $translations[] = 'AIProviders_RequestFailed'; + $translations[] = 'AIProviders_SettingsSaveSuccess'; + $translations[] = 'AIProviders_StatusConnected'; + $translations[] = 'AIProviders_StatusNotConnected'; + $translations[] = 'AIProviders_TestConnection'; + $translations[] = 'AIProviders_TestConnectionSuccess'; + $translations[] = 'AIProviders_TestingConnection'; + $translations[] = 'AIProviders_ThinkingCapability'; + $translations[] = 'AIProviders_ThinkingCapabilityDescription'; + $translations[] = 'AIProviders_UnexpectedError'; + $translations[] = 'AIProviders_UnsavedChanges'; + $translations[] = 'General_Cancel'; + $translations[] = 'General_LoadingData'; + } +} diff --git a/app/plugins/AIProviders/AIProvidersList.php b/app/plugins/AIProviders/AIProvidersList.php new file mode 100644 index 000000000..ab1e6f1de --- /dev/null +++ b/app/plugins/AIProviders/AIProvidersList.php @@ -0,0 +1,120 @@ + + */ + private $providers = []; + /** + * Selectable flag per provider ID, see the class docblock. + * + * @var array + */ + private $selectable = []; + /** + * Registers a provider, unless its ID is already taken. + * + * Provider IDs are unique and cannot be overwritten: the first registration + * for a given ID wins and later registrations are ignored. This protects the + * built-in providers (and a provider that is centrally forced in a managed + * multi-tenant environment) from being shadowed by another plugin. The + * selectable flag of the first registration is kept for the same reason. + * + * @return bool True when the provider was added, false when an entry for the + * same ID already existed and this registration was ignored + * (also logged as a warning so the collision is visible). + */ + public function addProvider(AIProvider $provider, bool $selectable = \true) : bool + { + $providerId = $provider->getId(); + if (isset($this->providers[$providerId])) { + StaticContainer::get(LoggerInterface::class)->warning('AI provider "{id}" is already registered as {existing}; ignoring duplicate registration of {ignored}.', ['id' => $providerId, 'existing' => get_class($this->providers[$providerId]), 'ignored' => get_class($provider)]); + return \false; + } + $this->providers[$providerId] = $provider; + $this->selectable[$providerId] = $selectable; + return \true; + } + public function removeProvider(string $providerId) : void + { + unset($this->providers[$providerId], $this->selectable[$providerId]); + } + /** + * Marks a registered provider as selectable or restricted. Intended for + * `AIProviders.filterAIProviders` subscribers; unknown IDs are ignored. + */ + public function setSelectable(string $providerId, bool $selectable) : void + { + if (isset($this->providers[$providerId])) { + $this->selectable[$providerId] = $selectable; + } + } + /** + * Returns whether the provider may be offered in the administration UI and + * used as the default provider. Unknown providers are not selectable. + */ + public function isSelectable(string $providerId) : bool + { + return $this->selectable[$providerId] ?? \false; + } + public function hasProvider(string $providerId) : bool + { + return isset($this->providers[$providerId]); + } + public function getProvider(string $providerId) : ?AIProvider + { + return $this->providers[$providerId] ?? null; + } + /** + * Returns all registered providers, including restricted ones. Completion + * requests resolve against this list; admin surfaces must use + * {@link getSelectableProviders()} instead so restricted providers stay + * hidden. + * + * @return list + */ + public function getProviders() : array + { + return array_values($this->providers); + } + /** + * Returns the providers that may be shown in the administration UI and + * chosen as the default provider. + * + * @return list + */ + public function getSelectableProviders() : array + { + return array_values(array_filter($this->providers, function (AIProvider $provider) : bool { + return $this->isSelectable($provider->getId()); + })); + } +} diff --git a/app/plugins/AIProviders/AIRequest.php b/app/plugins/AIProviders/AIRequest.php new file mode 100644 index 000000000..d4f18b377 --- /dev/null +++ b/app/plugins/AIProviders/AIRequest.php @@ -0,0 +1,286 @@ +complete( + * (new AIRequest('Summarise this report.', 'Goals')) + * ->withSystemPrompt('You are a concise web analytics assistant.') + * ->withCapabilityLevel(Configuration::CAPABILITY_THINKING) + * ->withIdSite($idSite) + * ); + * + * The selected `providerId` is treated as a hint: on a managed environment + * it is overridden by the forced default provider, + * unless the caller plugin is on the centrally managed + * `[AIProviders] providerSelectionAllowlist`, in which case the requested + * provider and model are honoured. See {@link AIProviderService::complete()}. + * + * **Hard rule:** the values passed to {@link withProviderId()} and + * {@link withModel()} must originate from plugin code constants or + * server-side configuration — never, directly or indirectly, from request + * input (`Request::fromRequest()`, `Common::getRequestVar()`, superglobals). + * Forwarding request input would let any API/HTTP client pick the provider + * and model the server calls, which on managed hosting means circumventing + * the centrally managed provider and its cost controls. If users may choose + * between engines in a UI, the request parameter must be a key into a + * server-side map defined by the plugin, never the provider/model string + * itself. + */ +class AIRequest +{ + public const DEFAULT_MAX_TOKENS = 1024; + public const DEFAULT_TEMPERATURE = 0.2; + public const REASONING_NONE = 'none'; + public const FORMAT_TEXT = 'text'; + public const FORMAT_JSON = 'json'; + /** + * @var string + */ + private $userPrompt; + /** + * Name of the plugin issuing the request, used for accountability and + * future usage accounting (for example `'Goals'`). + * + * @var string + */ + private $callerPluginName; + /** + * @var string|null + */ + private $systemPrompt = null; + /** + * @var string|null + */ + private $providerId = null; + /** + * @var string|null + */ + private $model = null; + /** + * Requested model capability level (see Configuration::CAPABILITY_*). + * Advisory for now: providers do not yet map this to a specific model. + * + * @var string|null + */ + private $capabilityLevel = null; + /** + * Optional identifier of the feature issuing the request (for example + * `'goal-recommendation'`), used for future usage accounting. + * + * @var string|null + */ + private $featureKey = null; + /** + * @var int|null + */ + private $idSite = null; + /** + * @var int + */ + private $maxTokens = self::DEFAULT_MAX_TOKENS; + /** + * @var float + */ + private $temperature = self::DEFAULT_TEMPERATURE; + /** + * Desired response format: FORMAT_TEXT (default) or FORMAT_JSON. In JSON mode + * the provider is asked to return a single JSON object — natively where the + * provider supports it, otherwise via an explicit instruction. + * + * @var string + */ + private $responseFormat = self::FORMAT_TEXT; + /** + * Requested reasoning level. Currently advisory only; providers do not send + * provider-specific reasoning controls until those request formats are + * confirmed. + * + * @var string + */ + private $reasoningLevel = self::REASONING_NONE; + /** + * Whether the caller wants provider web search. Currently advisory only; + * providers do not enable provider-specific web search tools yet. + * + * @var bool + */ + private $webSearchEnabled = \false; + /** + * Future provider-specific thinking budget. Not applied to requests yet. + * + * @var int|null + */ + private $thinkingBudget = null; + public function __construct(string $userPrompt, string $callerPluginName) + { + $this->userPrompt = $userPrompt; + $this->callerPluginName = $callerPluginName; + } + public function withSystemPrompt(?string $systemPrompt) : self + { + $request = clone $this; + $request->systemPrompt = $systemPrompt; + return $request; + } + /** + * Requests a specific provider. Honoured on unmanaged instances and for + * allowlisted caller plugins on managed instances; otherwise the forced + * default provider wins (see the class docblock). + * + * The value must be a plugin constant or server-side config value, never + * request input — see the hard rule in the class docblock. + */ + public function withProviderId(?string $providerId) : self + { + $request = clone $this; + $request->providerId = $providerId; + return $request; + } + /** + * Requests a specific model. Stripped on managed instances unless the + * caller plugin is allowlisted, because the model decides cost there. + * + * The value must be a plugin constant or server-side config value, never + * request input — see the hard rule in the class docblock. + */ + public function withModel(?string $model) : self + { + $request = clone $this; + $request->model = $model; + return $request; + } + public function withCapabilityLevel(?string $capabilityLevel) : self + { + $request = clone $this; + $request->capabilityLevel = $capabilityLevel; + return $request; + } + public function withFeatureKey(?string $featureKey) : self + { + $request = clone $this; + $request->featureKey = $featureKey; + return $request; + } + public function withIdSite(?int $idSite) : self + { + $request = clone $this; + $request->idSite = $idSite; + return $request; + } + public function withMaxTokens(int $maxTokens) : self + { + $request = clone $this; + $request->maxTokens = $maxTokens; + return $request; + } + public function withTemperature(float $temperature) : self + { + $request = clone $this; + $request->temperature = $temperature; + return $request; + } + public function withReasoningLevel(?string $reasoningLevel) : self + { + $request = clone $this; + $reasoningLevel = trim((string) $reasoningLevel); + $request->reasoningLevel = $reasoningLevel !== '' ? $reasoningLevel : self::REASONING_NONE; + return $request; + } + public function withWebSearchEnabled(bool $webSearchEnabled) : self + { + $request = clone $this; + $request->webSearchEnabled = $webSearchEnabled; + return $request; + } + public function withThinkingBudget(?int $thinkingBudget) : self + { + $request = clone $this; + $request->thinkingBudget = $thinkingBudget; + return $request; + } + /** + * Requests a JSON response. The provider is asked to return a single valid + * JSON object; read it with {@link AIProviderResponse::getJsonData()}. + */ + public function withJsonResponse() : self + { + $request = clone $this; + $request->responseFormat = self::FORMAT_JSON; + return $request; + } + public function getUserPrompt() : string + { + return $this->userPrompt; + } + public function getCallerPluginName() : string + { + return $this->callerPluginName; + } + public function getSystemPrompt() : ?string + { + return $this->systemPrompt; + } + public function getProviderId() : ?string + { + return $this->providerId; + } + public function getModel() : ?string + { + return $this->model; + } + public function getCapabilityLevel() : ?string + { + return $this->capabilityLevel; + } + public function getFeatureKey() : ?string + { + return $this->featureKey; + } + public function getIdSite() : ?int + { + return $this->idSite; + } + public function getMaxTokens() : int + { + return $this->maxTokens; + } + public function getTemperature() : float + { + return $this->temperature; + } + public function getResponseFormat() : string + { + return $this->responseFormat; + } + public function getReasoningLevel() : string + { + return $this->reasoningLevel; + } + public function isWebSearchEnabled() : bool + { + return $this->webSearchEnabled; + } + public function getThinkingBudget() : ?int + { + return $this->thinkingBudget; + } + public function isJsonResponse() : bool + { + return $this->responseFormat === self::FORMAT_JSON; + } +} diff --git a/app/plugins/AIProviders/API.php b/app/plugins/AIProviders/API.php new file mode 100644 index 000000000..59210ad28 --- /dev/null +++ b/app/plugins/AIProviders/API.php @@ -0,0 +1,145 @@ +configuration = $configuration; + $this->aiProviderService = $aiProviderService; + } + /** + * Returns AI provider settings for the administration UI. + * + * @return array Provider metadata and masked configuration values. + */ + public function getSettings() : array + { + Piwik::checkUserHasSuperUserAccess(); + $providers = \Piwik\Plugins\AIProviders\AIProviders::getAvailableProviders(); + return $this->configuration->getSettings($providers); + } + /** + * Saves AI provider settings from the administration UI. + * + * API keys should be submitted as POST data and are never returned by this + * API. In a managed environment the provider is + * forced from configuration, so the submitted default provider, credentials, + * and capability level are all ignored. + * + * All parameters are optional so the form can be saved before any provider + * is connected: an empty default provider clears the stored default (or + * falls back to the first usable provider), and an empty capability level + * keeps the stored one. This lets a super user save credentials or the + * default capability level on their own without first connecting a provider. + * + * @param string $defaultProviderId Provider ID to use by default. Empty to + * clear the default / let it fall back. + * @param string $defaultCapabilityLevel Default model capability level. + * Empty to keep the stored value. + * @param string $providerConfigurations JSON object keyed by provider ID + * with connection settings. + * @return array Updated provider metadata and masked configuration values. + */ + public function saveSettings(string $defaultProviderId = '', string $defaultCapabilityLevel = '', +#[\SensitiveParameter] +string $providerConfigurations = '{}') : array + { + Piwik::checkUserHasSuperUserAccess(); + $providers = \Piwik\Plugins\AIProviders\AIProviders::getAvailableProviders(); + $configuration = $this->configuration; + $configuration->saveSettings($providers, $defaultProviderId, $defaultCapabilityLevel, $providerConfigurations); + return $configuration->getSettings($providers); + } + /** + * Tests one provider using the submitted connection settings. + * + * @param string $providerId Provider ID to test. + * @param string $providerConfiguration JSON object with unsaved apiKey + * and endpointUrl values. + * @return array{providerId: string, providerName: string, models: list} + * Tested provider's metadata and the models it can serve (empty for + * providers that do not expose a model listing). + */ + public function testConnection(string $providerId, +#[\SensitiveParameter] +string $providerConfiguration = '{}') : array + { + Piwik::checkUserHasSuperUserAccess(); + $providers = \Piwik\Plugins\AIProviders\AIProviders::getAvailableProviders(); + $provider = $this->getSelectableProvider($providers, $providerId); + $configuration = $this->configuration->getProviderConfigurationForUse($provider, $this->decodeProviderConfiguration($providerConfiguration)); + $this->aiProviderService->testProviderConnection($provider, $configuration); + return ['providerId' => $provider->getId(), 'providerName' => $provider->getName(), 'models' => $provider->listModels($configuration)]; + } + /** + * Removes a stored provider connection. + * + * @param string $providerId Provider ID to disconnect. + * @return array Updated provider metadata and masked configuration values. + */ + public function disconnectProvider(string $providerId) : array + { + Piwik::checkUserHasSuperUserAccess(); + $providers = \Piwik\Plugins\AIProviders\AIProviders::getAvailableProviders(); + $this->getSelectableProvider($providers, $providerId); + $this->configuration->removeProviderConfiguration($providerId); + return $this->configuration->getSettings($providers); + } + /** + * Resolves a provider for the administration endpoints. Restricted + * providers (registered as non-selectable by a managed environment) are + * reported as unknown on purpose, so admin surfaces neither reveal nor + * operate on them. + */ + private function getSelectableProvider(\Piwik\Plugins\AIProviders\AIProvidersList $providers, string $providerId) : AIProvider + { + $provider = $providers->getProvider($providerId); + if ($provider === null || !$providers->isSelectable($providerId)) { + throw new \InvalidArgumentException(Piwik::translate('AIProviders_ErrorUnknownProvider', $providerId)); + } + return $provider; + } + /** + * @return array + */ + private function decodeProviderConfiguration( +#[\SensitiveParameter] +string $providerConfigurationJson) : array + { + $decoded = json_decode($providerConfigurationJson, \true); + if (!is_array($decoded)) { + throw new \InvalidArgumentException('Provider configuration must be a JSON object.'); + } + return $decoded; + } +} diff --git a/app/plugins/AIProviders/CanonicalMessage.php b/app/plugins/AIProviders/CanonicalMessage.php new file mode 100644 index 000000000..2ceee1f11 --- /dev/null +++ b/app/plugins/AIProviders/CanonicalMessage.php @@ -0,0 +1,121 @@ + + * } + * + * Roles: + * - 'user' Human-authored text. + * - 'assistant' Model output (text and/or tool_use blocks). + * - 'tool' Tool execution results fed back to the model. + * + * Content block shapes: + * + * Text block (user or assistant): + * { type: 'text', text: string } + * + * Reasoning block (assistant only): + * { type: 'reasoning', text: string } + * + * Model reasoning surfaced separately from the answer. This block is + * display-only and providers must not replay it to a model on later turns. + * + * Tool use block (assistant only): + * { type: 'tool_use', id: string, name: string, input: array } + * + * `id` is the provider's correlation id: callers must never invent or + * rewrite it. `input` is a JSON object; providers must keep an empty + * object as `{}` (stdClass) on the wire to satisfy providers that reject + * `[]` for object slots. + * + * Tool result block (tool role only): + * { + * type: 'tool_result', + * tool_use_id: string, + * content: list, + * structuredContent?: array|null, + * is_error: bool + * } + * + * Where `content` is the MCP content block list emitted by the tool + * (each `{type: 'text' | 'image' | …, …}`) and `structuredContent` + * carries the tool's optional structured output word for word. Providers with + * a native JSON return path will prefer `structuredContent` when present. + * + * ## Importable PHPStan types + * + * The request/response and provider boundaries import these aliases (with + * `@phpstan-import-type ... from CanonicalMessage`) instead of restating the + * shape inline, so the contract has a single home that the type checker pins: + * + * @phpstan-type CanonicalContentBlockArray array + * @phpstan-type CanonicalMessageArray array{role: string, content: list} + * + * `CanonicalContentBlockArray` is intentionally an open map rather than a union of + * the text/tool_use/tool_result shapes above: providers build and read blocks + * dynamically, so the per-type shapes are documented here as the contract but + * not enforced as a closed type. + * + * The class is never instantiated; it hosts this contract, the PHPStan + * aliases the boundaries import, and small static helpers for reading the + * canonical shape that producers and providers would otherwise reimplement. + */ +final class CanonicalMessage +{ + private function __construct() + { + } + /** + * Extracts the valid `tool_use` blocks from a canonical content list, in + * order, as `{id, name, input}`. Non-tool_use blocks and malformed + * tool_use blocks (missing id/name or a non-object input) are skipped, and + * input keys are normalised to strings so the result always carries a plain + * arguments object. + * + * @param list $content canonical content blocks + * @return list}> + */ + public static function toolUseBlocks(array $content) : array + { + $blocks = []; + foreach ($content as $block) { + if (($block['type'] ?? null) !== 'tool_use') { + continue; + } + $id = $block['id'] ?? null; + $name = $block['name'] ?? null; + $input = $block['input'] ?? []; + if (!is_string($id) || !is_string($name) || !is_array($input)) { + continue; + } + $normalizedInput = []; + foreach ($input as $key => $value) { + if (is_string($key)) { + $normalizedInput[$key] = $value; + } + } + $blocks[] = ['id' => $id, 'name' => $name, 'input' => $normalizedInput]; + } + return $blocks; + } +} diff --git a/app/plugins/AIProviders/Controller.php b/app/plugins/AIProviders/Controller.php new file mode 100644 index 000000000..5061839d4 --- /dev/null +++ b/app/plugins/AIProviders/Controller.php @@ -0,0 +1,41 @@ +configuration = $configuration; + } + public function index() : string + { + Piwik::checkUserHasSuperUserAccess(); + /** + * In a managed environment the provider is + * forced from configuration and there is nothing to configure, so the + * settings page is intentionally unavailable (the menu entry is hidden + * too). This guards against direct URL access. + */ + if ($this->configuration->isManaged()) { + throw new Exception('AI provider settings are managed and cannot be changed on this instance.'); + } + return $this->renderTemplate('index'); + } +} diff --git a/app/plugins/AIProviders/Exception/AIProviderClientException.php b/app/plugins/AIProviders/Exception/AIProviderClientException.php new file mode 100644 index 000000000..2ad259334 --- /dev/null +++ b/app/plugins/AIProviders/Exception/AIProviderClientException.php @@ -0,0 +1,20 @@ +configuration = $configuration; + } + public function configureAdminMenu(MenuAdmin $menu) : void + { + if (!Piwik::hasUserSuperUserAccess()) { + return; + } + /** + * In a managed environment the provider is + * forced from configuration and there is nothing to configure, so the + * settings page is hidden entirely. + */ + if ($this->configuration->isManaged()) { + return; + } + $menu->addSystemItem('AIProviders_MenuTitle', $this->urlForAction('index'), 36); + } +} diff --git a/app/plugins/AIProviders/Model/Configuration.php b/app/plugins/AIProviders/Model/Configuration.php new file mode 100644 index 000000000..8b9af725b --- /dev/null +++ b/app/plugins/AIProviders/Model/Configuration.php @@ -0,0 +1,702 @@ + ['apiKey' => '...', 'endpointUrl' => '']]`. + * + * @var SystemSetting + */ + private $providerCredentials; + public function __construct() + { + $this->defaultProvider = new SystemSetting(self::SETTING_DEFAULT_PROVIDER, '', FieldConfig::TYPE_STRING, self::PLUGIN_NAME); + $this->defaultCapabilityLevel = new SystemSetting(self::SETTING_DEFAULT_CAPABILITY_LEVEL, self::CAPABILITY_INSTANT, FieldConfig::TYPE_STRING, self::PLUGIN_NAME); + $this->providerCredentials = new SystemSetting(self::SETTING_PROVIDER_CREDENTIALS, [], FieldConfig::TYPE_ARRAY, self::PLUGIN_NAME); + } + /** + * Returns masked AI provider settings for the administration UI. + * + * Only selectable providers are included, so providers a managed + * environment registered as restricted (usable by allowlisted plugins + * only) are never revealed to admin surfaces. + * + * @return array{ + * defaultProviderId: string, + * defaultCapabilityLevel: string, + * canEditProviderConfiguration: bool, + * canEditCapabilityLevel: bool, + * capabilityLevels: array, + * providers: array> + * } Provider metadata and masked configuration values. + */ + public function getSettings(AIProvidersList $providers) : array + { + return ['defaultProviderId' => $this->getDefaultProviderId($providers), 'defaultCapabilityLevel' => $this->getDefaultCapabilityLevel(), 'canEditProviderConfiguration' => $this->canEditProviderConfiguration(), 'canEditCapabilityLevel' => $this->canEditCapabilityLevel(), 'capabilityLevels' => $this->getCapabilityLevels(), 'providers' => array_map(function (AIProvider $provider) : array { + $providerConfiguration = $this->getProviderConfiguration($provider); + return array_merge($provider->toArray(), ['configuration' => ['hasApiKey' => !empty($providerConfiguration['apiKey']), 'endpointUrl' => $providerConfiguration['endpointUrl'], 'model' => $providerConfiguration['model'], 'useFipsEndpoint' => $providerConfiguration['useFipsEndpoint'], 'isUsable' => $provider->isConfigured($providerConfiguration)]]); + }, $providers->getSelectableProviders())]; + } + /** + * Saves the default provider, capability level, and provider connection settings. + * + * Both are optional so the form can be saved before any provider is + * connected: an empty default provider clears it (or falls back to the + * first usable one), and an empty capability level keeps the stored value. + * + * In a managed environment the provider and its credentials + * are forced from configuration, so nothing is persisted here. + */ + public function saveSettings(AIProvidersList $providers, string $defaultProviderId, string $defaultCapabilityLevel, +#[\SensitiveParameter] +string $providerConfigurationsJson) : void + { + if ($this->canEditProviderConfiguration()) { + $submittedProviderConfigurations = $this->decodeProviderConfigurations($providerConfigurationsJson); + $this->saveProviderConfigurations($providers, $submittedProviderConfigurations); + $this->saveDefaultProviderId($providers, $defaultProviderId); + } + if ($this->canEditCapabilityLevel() && trim($defaultCapabilityLevel) !== '') { + $this->saveDefaultCapabilityLevel($defaultCapabilityLevel); + } + } + /** + * Returns the effective server-side provider configuration including the API key. + * + * The returned array contains secrets. It is internal to the AIProviders + * plugin and its providers and must never be returned from API methods, + * logged, or exposed to other plugins. Other plugins run completions via + * {@link \Piwik\Plugins\AIProviders\AIProviderService::complete()} and + * never see credentials. + * + * @internal + * @return array{apiKey: string, endpointUrl: string, model: string, useFipsEndpoint: bool} + */ + public function getProviderConfiguration(AIProvider $provider) : array + { + $providerId = $provider->getId(); + $storedConfiguration = $this->getProviderConfigurations()[$providerId] ?? ['apiKey' => '', 'endpointUrl' => '', 'model' => '', 'useFipsEndpoint' => \false]; + $configFileConfiguration = $this->getConfigFileProviderConfiguration($providerId); + return ['apiKey' => $configFileConfiguration['apiKey'] !== '' ? $configFileConfiguration['apiKey'] : $storedConfiguration['apiKey'], 'endpointUrl' => $this->resolveEndpointUrl($provider, $configFileConfiguration, $storedConfiguration), 'model' => $configFileConfiguration['model'] !== '' ? $configFileConfiguration['model'] : $storedConfiguration['model'], 'useFipsEndpoint' => $configFileConfiguration['useFipsEndpoint'] !== null ? $configFileConfiguration['useFipsEndpoint'] : $storedConfiguration['useFipsEndpoint']]; + } + /** + * @param array{apiKey: string, endpointUrl: string, model: string, useFipsEndpoint: bool|null} $configFileConfiguration + * @param array{apiKey: string, endpointUrl: string, model: string, useFipsEndpoint: bool} $storedConfiguration + */ + private function resolveEndpointUrl(AIProvider $provider, +#[\SensitiveParameter] +array $configFileConfiguration, +#[\SensitiveParameter] +array $storedConfiguration) : string + { + if (!$provider->supportsCustomEndpoint()) { + return ''; + } + if ($configFileConfiguration['endpointUrl'] !== '') { + return $configFileConfiguration['endpointUrl']; + } + if ($this->isEndpointUrlPinnedBySuppliedApiKey($provider)) { + return ''; + } + return $storedConfiguration['endpointUrl']; + } + private function isEndpointUrlPinnedBySuppliedApiKey(AIProvider $provider) : bool + { + return $this->getConfigFileProviderConfiguration($provider->getId())['apiKey'] !== '' && $provider->supportsCustomEndpoint() && $provider->endpointFieldRequiresUrl(); + } + /** + * Returns a validated server-side provider configuration, optionally using + * unsaved values from the admin UI for connection testing. + * + * @param array $submittedProviderConfiguration + * @return array{apiKey: string, endpointUrl: string, model: string, useFipsEndpoint: bool} + */ + public function getProviderConfigurationForUse(AIProvider $provider, +#[\SensitiveParameter] +array $submittedProviderConfiguration = []) : array + { + $providerId = $provider->getId(); + // Checked against the submitted values before the merge below fills the + // endpoint in from the effective configuration. + $this->checkSubmittedEndpointUrlIsAllowed($provider, $submittedProviderConfiguration, $this->getSubmittedEndpointUrl($submittedProviderConfiguration, $provider)); + // Base the merge on the effective configuration so a connection test + // exercises what complete() would actually use, including config-file + // credentials. Only an omitted or matching endpoint survives the check + // above. + $existingConfiguration = $this->getProviderConfiguration($provider); + $submittedProviderConfiguration = array_merge($existingConfiguration, $submittedProviderConfiguration); + return ['apiKey' => $this->getSubmittedApiKey($submittedProviderConfiguration, [$providerId => $existingConfiguration], $providerId), 'endpointUrl' => $this->getSubmittedEndpointUrl($submittedProviderConfiguration, $provider), 'model' => $this->getSubmittedModel($submittedProviderConfiguration, $provider), 'useFipsEndpoint' => $this->getSubmittedUseFipsEndpoint($submittedProviderConfiguration, $provider)]; + } + /** + * Removes the database-stored connection settings for a provider. + * + * Credentials supplied via the config file or environment (see the class + * docblock) are not touched: they are not stored in the database and can + * only be removed where they were defined. + */ + public function removeProviderConfiguration(string $providerId) : void + { + $providerConfigurations = $this->getProviderConfigurations(); + unset($providerConfigurations[$providerId]); + $this->providerCredentials->setValue($providerConfigurations); + $this->providerCredentials->save(); + } + /** + * @return array + */ + public function getCapabilityLevels() : array + { + return [self::CAPABILITY_INSTANT => ['label' => 'AIProviders_InstantCapability', 'description' => 'AIProviders_InstantCapabilityDescription'], self::CAPABILITY_THINKING => ['label' => 'AIProviders_ThinkingCapability', 'description' => 'AIProviders_ThinkingCapabilityDescription']]; + } + /** + * Returns whether provider connections can be edited in the UI. + * + * Provider configuration is locked whenever the default provider is forced + * from configuration (a managed environment). + */ + public function canEditProviderConfiguration() : bool + { + return !$this->isManaged() && $this->defaultProvider->isWritableByCurrentUser(); + } + public function canEditCapabilityLevel() : bool + { + return !$this->isManaged() && $this->defaultCapabilityLevel->isWritableByCurrentUser(); + } + /** + * Returns the forced provider ID when running in a managed environment, or + * null when the default provider may be chosen freely. + * + * Trusted callers use this to honour the centrally managed provider even + * when they request a specific one. + */ + public function getForcedProviderId() : ?string + { + if (!$this->isManaged()) { + return null; + } + $providerId = $this->defaultProvider->getValue(); + return is_string($providerId) && $providerId !== '' ? $providerId : null; + } + /** + * Returns the configured default provider ID, falling back to the first + * configured provider. Only selectable providers are considered, so a + * restricted provider can never become the default. + */ + public function getDefaultProviderId(AIProvidersList $providers) : string + { + $providerId = $this->defaultProvider->getValue(); + /** + * When forced from configuration, use the + * configured provider as-is so misconfiguration surfaces a clear error + * from the provider rather than silently falling back. + */ + if ($this->isManaged() && is_string($providerId) && $providerId !== '' && $providers->hasProvider($providerId)) { + return $providerId; + } + if (is_string($providerId) && $providers->isSelectable($providerId) && $this->isProviderUsable($providers->getProvider($providerId))) { + return $providerId; + } + if ($providers->isSelectable(self::DEFAULT_PROVIDER_ID) && $this->isProviderUsable($providers->getProvider(self::DEFAULT_PROVIDER_ID))) { + return self::DEFAULT_PROVIDER_ID; + } + return $this->getFirstConfiguredProviderId($providers); + } + private function saveDefaultProviderId(AIProvidersList $providers, string $providerId) : void + { + $providerId = trim($providerId); + if ($providerId === '') { + $providerId = $this->getFirstConfiguredProviderId($providers); + if ($providerId === '') { + $this->defaultProvider->setValue(''); + $this->defaultProvider->save(); + return; + } + } + // Restricted providers are reported as unknown on purpose: admin + // surfaces must not reveal that they exist. + $provider = $providers->getProvider($providerId); + if ($provider === null || !$providers->isSelectable($providerId)) { + throw new InvalidArgumentException(Piwik::translate('AIProviders_ErrorUnknownProvider', $providerId)); + } + if (!$this->isProviderUsable($provider)) { + throw new InvalidArgumentException(Piwik::translate('AIProviders_ErrorProviderNotConfigured', $provider->getName())); + } + $this->defaultProvider->setValue($providerId); + $this->defaultProvider->save(); + } + /** + * Returns the configured default model capability level. + */ + public function getDefaultCapabilityLevel() : string + { + $capabilityLevel = $this->defaultCapabilityLevel->getValue(); + if (!is_string($capabilityLevel) || !array_key_exists($capabilityLevel, $this->getCapabilityLevels())) { + return self::CAPABILITY_INSTANT; + } + return $capabilityLevel; + } + private function saveDefaultCapabilityLevel(string $capabilityLevel) : void + { + $capabilityLevel = trim($capabilityLevel); + if (!array_key_exists($capabilityLevel, $this->getCapabilityLevels())) { + throw new InvalidArgumentException(Piwik::translate('AIProviders_ErrorUnknownCapabilityLevel', $capabilityLevel)); + } + $this->defaultCapabilityLevel->setValue($capabilityLevel); + $this->defaultCapabilityLevel->save(); + } + /** + * Returns whether the plugin runs in a managed environment, that is, the + * default provider is forced (and locked) from configuration. + */ + public function isManaged() : bool + { + $config = Config::getInstance()->AIProviders; + $providerId = is_array($config) ? $config[self::SETTING_DEFAULT_PROVIDER] ?? null : null; + return is_string($providerId) && trim($providerId) !== ''; + } + /** + * @return array + */ + private function getProviderConfigurations() : array + { + $rawValue = $this->providerCredentials->getValue(); + if (!is_array($rawValue)) { + return []; + } + $providerConfigurations = []; + foreach ($rawValue as $providerId => $providerConfiguration) { + if (!is_string($providerId) || !is_array($providerConfiguration)) { + continue; + } + $providerConfigurations[$providerId] = ['apiKey' => isset($providerConfiguration['apiKey']) && is_string($providerConfiguration['apiKey']) ? $providerConfiguration['apiKey'] : '', 'endpointUrl' => isset($providerConfiguration['endpointUrl']) && is_string($providerConfiguration['endpointUrl']) ? $providerConfiguration['endpointUrl'] : '', 'model' => isset($providerConfiguration['model']) && is_string($providerConfiguration['model']) ? $providerConfiguration['model'] : '', 'useFipsEndpoint' => !empty($providerConfiguration['useFipsEndpoint'])]; + } + return $providerConfigurations; + } + /** + * @return array> + */ + private function decodeProviderConfigurations( +#[\SensitiveParameter] +string $providerConfigurationsJson) : array + { + $decoded = json_decode($providerConfigurationsJson, \true); + if (!is_array($decoded)) { + throw new InvalidArgumentException('Provider configurations must be a JSON object.'); + } + return $decoded; + } + /** + * Persists the submitted provider connection settings to the database. + * + * Only selectable providers are accepted, so restricted providers cannot + * be configured through the administration flow. The API key fallback + * reads the stored (database) value on purpose: config-file credentials + * must never be copied into the database. The endpoint is kept out of it + * the same way while a supplied key pins it (see + * {@link getEndpointUrlToStore()}). + * + * @param array $submittedProviderConfigurations + */ + private function saveProviderConfigurations(AIProvidersList $providers, +#[\SensitiveParameter] +array $submittedProviderConfigurations) : void + { + $existingProviderConfigurations = $this->getProviderConfigurations(); + $providerConfigurations = []; + foreach ($providers->getSelectableProviders() as $provider) { + $providerId = $provider->getId(); + $submittedProviderConfiguration = $submittedProviderConfigurations[$providerId] ?? []; + if (!is_array($submittedProviderConfiguration)) { + throw new InvalidArgumentException(sprintf('Invalid configuration for AI provider "%s".', $providerId)); + } + $apiKey = $this->getSubmittedApiKey($submittedProviderConfiguration, $existingProviderConfigurations, $providerId); + $endpointUrl = $this->getSubmittedEndpointUrl($submittedProviderConfiguration, $provider); + $this->checkSubmittedEndpointUrlIsAllowed($provider, $submittedProviderConfiguration, $endpointUrl); + $endpointUrl = $this->getEndpointUrlToStore($provider, $endpointUrl, $existingProviderConfigurations); + $model = $this->getSubmittedModel($submittedProviderConfiguration, $provider); + $useFipsEndpoint = $this->getSubmittedUseFipsEndpoint($submittedProviderConfiguration, $provider); + // The model counts on its own: when the credentials and the endpoint + // are supplied centrally, neither is stored (see + // getEndpointUrlToStore()), and dropping the entry would discard the + // model the admin picked and leave the provider unable to run. + if ($apiKey === '' && $endpointUrl === '' && $model === '' && !$useFipsEndpoint) { + continue; + } + $providerConfigurations[$providerId] = ['apiKey' => $apiKey, 'endpointUrl' => $endpointUrl, 'model' => $model, 'useFipsEndpoint' => $useFipsEndpoint]; + } + $this->providerCredentials->setValue($providerConfigurations); + $this->providerCredentials->save(); + } + /** + * Rejects a submitted endpoint that the central sources decide instead. + * + * Only a value that actually differs from the effective one is rejected: the + * admin form prefills the endpoint field and resubmits it unchanged on every + * save, and a caller may omit the field entirely. Rejecting rather than + * silently dropping the value keeps the save and connection-test paths + * agreeing on what a connection can be, so a test cannot report a pairing a + * save would refuse to store. + * + * @param array $submittedProviderConfiguration + */ + private function checkSubmittedEndpointUrlIsAllowed(AIProvider $provider, +#[\SensitiveParameter] +array $submittedProviderConfiguration, string $endpointUrl) : void + { + if (!array_key_exists('endpointUrl', $submittedProviderConfiguration) || !$this->isEndpointUrlSuppliedCentrally($provider) || $endpointUrl === $this->getEffectiveEndpointUrl($provider)) { + return; + } + // Getting here without a supplied endpoint means a supplied API key is + // what decides it, the one case where the admin has to be told to add the + // endpoint rather than to look up the value already in place. + $messageKey = $this->getConfigFileProviderConfiguration($provider->getId())['endpointUrl'] === '' ? 'AIProviders_ErrorEndpointUrlManagedWithApiKey' : 'AIProviders_ErrorEndpointManaged'; + throw new InvalidArgumentException(Piwik::translate($messageKey, [$provider->getName(), $provider->getId() . 'EndpointUrl'])); + } + /** + * Returns the effective endpoint normalized the way a submitted one is (see + * {@link getSubmittedEndpointUrl()}), so both are compared as the request + * URL they produce. A value the provider rejects has no such URL, so it is + * left as it is and matches nothing. + */ + private function getEffectiveEndpointUrl(AIProvider $provider) : string + { + $endpointUrl = $this->getProviderConfiguration($provider)['endpointUrl']; + try { + return $provider->normalizeEndpointUrl($endpointUrl); + } catch (AIProviderClientException $e) { + return $endpointUrl; + } + } + /** + * Returns the endpoint URL to persist, keeping the stored one untouched + * while the effective endpoint comes from the central sources. + * + * The form posts the effective value back on every save, and neither half of + * it belongs in the database: storing the empty one would erase the endpoint + * the instance had configured before, and storing the supplied one would copy + * central configuration into the database, where it would outlive its + * removal. + * + * @param array $existingProviderConfigurations + */ + private function getEndpointUrlToStore(AIProvider $provider, string $endpointUrl, +#[\SensitiveParameter] +array $existingProviderConfigurations) : string + { + if (!$this->isEndpointUrlSuppliedCentrally($provider)) { + return $endpointUrl; + } + return $existingProviderConfigurations[$provider->getId()]['endpointUrl'] ?? ''; + } + /** + * Returns whether the endpoint the provider is called with is decided by the + * DI/config-file sources rather than the database, either because one was + * supplied there or because a supplied API key pins it. A submitted endpoint + * can neither take effect nor be persisted in that case: it would sit + * shadowed in the database and then silently take over the moment the central + * value is removed. + * + * Providers without an endpoint field are never affected, so a value supplied + * for one of them stays ignored rather than locking their settings. + */ + private function isEndpointUrlSuppliedCentrally(AIProvider $provider) : bool + { + if (!$provider->supportsCustomEndpoint()) { + return \false; + } + return $this->getConfigFileProviderConfiguration($provider->getId())['endpointUrl'] !== '' || $this->isEndpointUrlPinnedBySuppliedApiKey($provider); + } + /** + * An empty API key means "keep the existing one": the UI cannot prefill a + * write-only field, so it always submits an empty value unless the admin + * typed a new key. + * + * @param array $submittedProviderConfiguration + * @param array $existingProviderConfigurations + */ + private function getSubmittedApiKey( +#[\SensitiveParameter] +array $submittedProviderConfiguration, +#[\SensitiveParameter] +array $existingProviderConfigurations, string $providerId) : string + { + if ($this->hasSubmittedApiKey($submittedProviderConfiguration)) { + return trim((string) $submittedProviderConfiguration['apiKey']); + } + return $existingProviderConfigurations[$providerId]['apiKey'] ?? ''; + } + /** + * @param array $submittedProviderConfiguration + */ + private function hasSubmittedApiKey( +#[\SensitiveParameter] +array $submittedProviderConfiguration) : bool + { + return isset($submittedProviderConfiguration['apiKey']) && is_string($submittedProviderConfiguration['apiKey']) && trim($submittedProviderConfiguration['apiKey']) !== ''; + } + /** + * @param array $submittedProviderConfiguration + */ + private function getSubmittedEndpointUrl(array $submittedProviderConfiguration, AIProvider $provider) : string + { + if (!$provider->supportsCustomEndpoint()) { + return ''; + } + $endpointUrl = isset($submittedProviderConfiguration['endpointUrl']) && is_string($submittedProviderConfiguration['endpointUrl']) ? trim($submittedProviderConfiguration['endpointUrl']) : ''; + if ($endpointUrl === '') { + return ''; + } + try { + $endpointUrl = $provider->normalizeEndpointUrl($endpointUrl); + } catch (AIProviderClientException $e) { + // The provider's own message stays English for the request-time + // callers it shares; the admin reads the translated wording and the + // original as the cause. + throw $this->invalidEndpointFieldException($provider, $e); + } + if (!$provider->endpointFieldRequiresUrl()) { + return $endpointUrl; + } + $parsedUrl = parse_url($endpointUrl); + $scheme = is_array($parsedUrl) ? $parsedUrl['scheme'] ?? '' : ''; + if (!filter_var($endpointUrl, \FILTER_VALIDATE_URL) || !in_array($scheme, ['http', 'https'], \true)) { + throw $this->invalidEndpointFieldException($provider); + } + return $endpointUrl; + } + /** + * Both rejection paths in {@link getSubmittedEndpointUrl()} share this, so a + * provider that only accepts shorthand states its wording once, on + * {@link AIProvider::getEndpointFieldErrorMessage()}. + */ + private function invalidEndpointFieldException(AIProvider $provider, ?AIProviderClientException $cause = null) : InvalidArgumentException + { + return new InvalidArgumentException(Piwik::translate($provider->getEndpointFieldErrorMessage(), $provider->getName()), 0, $cause); + } + /** + * @param array $submittedProviderConfiguration + */ + private function getSubmittedUseFipsEndpoint(array $submittedProviderConfiguration, AIProvider $provider) : bool + { + return $provider->supportsFipsEndpoint() && !empty($submittedProviderConfiguration['useFipsEndpoint']); + } + /** + * The model only applies to providers with a custom endpoint (the admin + * picks it from the server's discovered models); it is empty for fixed + * hosted providers, which use their own default model. + * + * @param array $submittedProviderConfiguration + */ + private function getSubmittedModel(array $submittedProviderConfiguration, AIProvider $provider) : string + { + if (!$provider->supportsCustomEndpoint()) { + return ''; + } + return isset($submittedProviderConfiguration['model']) && is_string($submittedProviderConfiguration['model']) ? trim($submittedProviderConfiguration['model']) : ''; + } + private function getFirstConfiguredProviderId(AIProvidersList $providers) : string + { + foreach ($providers->getSelectableProviders() as $provider) { + if ($this->isProviderUsable($provider)) { + return $provider->getId(); + } + } + return ''; + } + /** + * Returns whether the provider can run completions with its effective + * configuration (database merged with config file/environment). + */ + private function isProviderUsable(?AIProvider $provider) : bool + { + if ($provider === null) { + return \false; + } + return $provider->isConfigured($this->getProviderConfiguration($provider)); + } + /** + * Returns whether the given plugin may target a specific provider (and + * model) per request even though a managed environment forces the default + * provider. Controlled by the `[AIProviders] providerSelectionAllowlist[]` + * config entries, which a managed environment keeps in its locked, + * centrally managed config. + * + * This is a policy gate for centrally deployed plugins, not a sandbox: + * the caller plugin name on an {@link \Piwik\Plugins\AIProviders\AIRequest} + * is self-declared, and PHP code on the same instance can ultimately not + * be restrained from anything. The guarantee that matters is that on a + * managed instance neither this allowlist nor the values an allowlisted + * plugin passes can be influenced by users (see the hard rule on + * {@link \Piwik\Plugins\AIProviders\AIRequest::withProviderId()}). + */ + public function isPluginAllowedToSelectProvider(string $pluginName) : bool + { + if ($pluginName === '') { + return \false; + } + return in_array($pluginName, $this->getProviderSelectionAllowlist(), \true); + } + /** + * @return string[] + */ + private function getProviderSelectionAllowlist() : array + { + $config = Config::getInstance()->AIProviders; + $allowlist = is_array($config) ? $config[self::CONFIG_PROVIDER_SELECTION_ALLOWLIST] ?? [] : []; + // A single `providerSelectionAllowlist = "X"` entry (without `[]`) + // parses as a string; accept it as a one-element list. + if (is_string($allowlist)) { + $allowlist = [$allowlist]; + } + if (!is_array($allowlist)) { + return []; + } + return array_values(array_filter($allowlist, 'is_string')); + } + /** + * @return array{apiKey: string, endpointUrl: string, model: string, useFipsEndpoint: bool|null} + */ + private function getConfigFileProviderConfiguration(string $providerId) : array + { + return ['apiKey' => $this->getConfigFileValue($providerId, 'ApiKey', 'API_KEY'), 'endpointUrl' => $this->getConfigFileValue($providerId, 'EndpointUrl', 'ENDPOINT_URL'), 'model' => $this->getConfigFileValue($providerId, 'Model', 'MODEL'), 'useFipsEndpoint' => $this->getConfigFileBooleanValue($providerId, 'UseFipsEndpoint', 'USE_FIPS_ENDPOINT')]; + } + private function getConfigFileBooleanValue(string $providerId, string $configSuffix, string $envSuffix) : ?bool + { + $diKey = self::PLUGIN_NAME . '.' . $providerId . $configSuffix; + $container = StaticContainer::getContainer(); + if ($container->has($diKey)) { + return $this->isTruthy($container->get($diKey)); + } + $config = Config::getInstance()->AIProviders; + $configKey = $providerId . $configSuffix; + if (is_array($config) && array_key_exists($configKey, $config)) { + return $this->isTruthy($config[$configKey]); + } + $envKey = 'MATOMO_AIPROVIDERS_' . strtoupper(str_replace('-', '_', $providerId)) . '_' . $envSuffix; + $envValue = getenv($envKey); + if (is_string($envValue)) { + return $this->isTruthy($envValue); + } + return null; + } + /** + * @param mixed $value + */ + private function isTruthy($value) : bool + { + return is_scalar($value) && filter_var($value, \FILTER_VALIDATE_BOOLEAN); + } + private function getConfigFileValue(string $providerId, string $configSuffix, string $envSuffix) : string + { + $diValue = $this->getDiConfigValue($providerId, $configSuffix); + if ($diValue !== '') { + return $diValue; + } + $config = Config::getInstance()->AIProviders; + $configKey = $providerId . $configSuffix; + if (is_array($config) && isset($config[$configKey]) && is_string($config[$configKey]) && trim($config[$configKey]) !== '') { + return trim($config[$configKey]); + } + $envKey = 'MATOMO_AIPROVIDERS_' . strtoupper(str_replace('-', '_', $providerId)) . '_' . $envSuffix; + $envValue = getenv($envKey); + if (is_string($envValue) && trim($envValue) !== '') { + return trim($envValue); + } + return ''; + } + private function getDiConfigValue(string $providerId, string $configSuffix) : string + { + $diKey = self::PLUGIN_NAME . '.' . $providerId . $configSuffix; + $container = StaticContainer::getContainer(); + if (!$container->has($diKey)) { + return ''; + } + $value = $container->get($diKey); + return is_string($value) && trim($value) !== '' ? trim($value) : ''; + } +} diff --git a/app/plugins/AIProviders/Provider/AIProvider.php b/app/plugins/AIProviders/Provider/AIProvider.php new file mode 100644 index 000000000..a43749b75 --- /dev/null +++ b/app/plugins/AIProviders/Provider/AIProvider.php @@ -0,0 +1,918 @@ +id = $id; + $this->name = $name; + $this->description = $description; + $this->supportsCustomEndpoint = $supportsCustomEndpoint; + } + public function getId() : string + { + return $this->id; + } + public function getName() : string + { + return $this->name; + } + public function getDescription() : string + { + return $this->description; + } + public function supportsCustomEndpoint() : bool + { + return $this->supportsCustomEndpoint; + } + public function supportsFipsEndpoint() : bool + { + return \false; + } + /** + * Returns whether the endpoint field is the request URL itself, and can + * therefore name any host. Return false only when the field is expanded into + * a host the provider controls, the way a region is (see Bedrock). + * + * This decides whether a centrally managed API key may be paired with an + * endpoint configured on the instance, so a provider whose field does reach + * the request URL must not return false. + */ + public function endpointFieldRequiresUrl() : bool + { + return \true; + } + public function getDefaultEndpointUrl() : string + { + return ''; + } + /** + * Expands shorthand endpoint input (e.g. a bare AWS region) into the URL + * to validate and store. Unrecognized values pass through unchanged so + * URL validation still rejects them. + */ + public function normalizeEndpointUrl(string $endpointUrl) : string + { + return $endpointUrl; + } + /** Translation key for the endpoint field's label in the admin UI. */ + public function getEndpointFieldTitle() : string + { + return 'AIProviders_EndpointUrl'; + } + /** Translation key for the endpoint field's placeholder in the admin UI. */ + public function getEndpointFieldPlaceholder() : string + { + return 'AIProviders_EndpointUrlPlaceholder'; + } + /** + * Translation key for the error shown when the settings form rejects the + * endpoint field's value. Takes the provider name as its only argument. + * Providers whose field is not a URL override this to name what they do + * expect, the way they already override the field's label. + */ + public function getEndpointFieldErrorMessage() : string + { + return 'AIProviders_ErrorInvalidEndpointUrl'; + } + public function getDefaultModel() : string + { + return ''; + } + protected function isTrustedRequestHost(string $host) : bool + { + return \false; + } + /** + * Completes the given request using the provider. + * + * Implementations should honor the request's system prompt, model, + * max tokens, and temperature, and populate token usage on the response + * when the provider reports it. + * + * @param array{apiKey?: string, endpointUrl?: string, model?: string, useFipsEndpoint?: bool} $configuration + */ + public abstract function complete(AIRequest $request, array $configuration) : AIProviderResponse; + /** + * Returns whether the provider implements {@link converse()}: multi-turn + * conversations with tool calling. Callers should check + * {@link \Piwik\Plugins\AIProviders\AIProviderService::canConverse()} + * before offering conversational features. + */ + public function supportsConversations() : bool + { + return \false; + } + /** + * Runs one conversational round-trip and returns the assistant's turn in + * the canonical shape (see {@link \Piwik\Plugins\AIProviders\CanonicalMessage}). + * + * Implementations must translate the canonical messages and the tool + * catalogue to their wire format, honour the request's system prompt, + * model, max tokens, temperature, and timeout, and map their stop-reason + * vocabulary to the AIConversationResponse::STOP_* constants where a + * mapping exists. Providers that override this method must also override + * {@link supportsConversations()} to return true. + * + * @param array{apiKey?: string, endpointUrl?: string, model?: string, useFipsEndpoint?: bool} $configuration + */ + public function converse(AIConversationRequest $request, array $configuration) : AIConversationResponse + { + throw new AIProviderClientException(sprintf('%s does not support multi-turn conversations.', $this->getName())); + } + /** + * Validates the given connection settings, throwing on failure. + * + * Used by the admin "test connection" flow before a configuration is + * stored. The default implementation proves the full round-trip with a + * tiny completion; providers that expose a cheaper auth/health endpoint + * (e.g. a models listing) should override this to avoid spending + * generation tokens. Returns normally when the connection works. + * + * @param array{apiKey?: string, endpointUrl?: string, model?: string, useFipsEndpoint?: bool} $configuration + */ + public function verifyConnection(array $configuration) : void + { + $this->verifyConnectionWithCompletion($configuration); + } + /** + * Probes the connection with completion. Providers with a cheaper + * probe override verifyConnection() but can reuse this. + * + * @param array{apiKey?: string, endpointUrl?: string, model?: string, useFipsEndpoint?: bool} $configuration + */ + protected function verifyConnectionWithCompletion(array $configuration) : void + { + $request = (new AIRequest('Reply with the single word: OK', 'AIProviders'))->withFeatureKey('test-connection')->withMaxTokens(16); + $response = $this->complete($request, $configuration); + if (trim($response->getText()) === '') { + throw new AIProviderServerException(sprintf('%s returned an empty response.', $this->getName())); + } + } + /** + * Returns the list of model identifiers the provider can serve with the + * given configuration, used to populate the model picker in the admin UI. + * The default implementation returns no models; providers that can discover + * them (e.g. an OpenAI-compatible `/models` listing) should override this. + * + * @param array{apiKey?: string, endpointUrl?: string, model?: string, useFipsEndpoint?: bool} $configuration + * @return list + */ + public function listModels(array $configuration) : array + { + return []; + } + /** + * Returns whether the provider has everything it needs to run completions. + * + * Providers that talk to a fixed hosted API require an API key. Providers + * that support a custom endpoint instead require the endpoint URL; their + * API key is optional, because local LLM servers (Ollama, LM Studio, + * llama.cpp, vLLM, …) commonly run with authentication disabled. Providers + * whose credentials are supplied by the environment (rather than stored + * configuration) should override this method. + * + * @param array{apiKey?: string, endpointUrl?: string, model?: string, useFipsEndpoint?: bool} $configuration + */ + public function isConfigured(array $configuration) : bool + { + if ($this->supportsCustomEndpoint()) { + return trim($configuration['endpointUrl'] ?? '') !== ''; + } + return trim($configuration['apiKey'] ?? '') !== ''; + } + /** + * Returns the model to use for the request, falling back to the provider default. + */ + protected function resolveModel(AIRequest $request) : string + { + $model = $request->getModel(); + // The capability level (instant/thinking) is not a different model: each + // provider's default model handles both, toggled by provider-specific + // thinking parameters (see wantsThinking()). An explicit per-request + // model still wins. + return $model !== null && $model !== '' ? $model : $this->getDefaultModel(); + } + /** + * Returns the model to use for the conversation request, falling back to + * the provider default. + */ + protected function resolveConversationModel(AIConversationRequest $request) : string + { + $model = $request->getModel(); + return $model !== null && $model !== '' ? $model : $this->getDefaultModel(); + } + /** + * Returns the system prompt for the request, augmented with a JSON-output + * instruction when JSON mode is requested. Providers should use this rather + * than reading the request's system prompt directly, so JSON mode works even + * for providers without a native JSON option (and so the word "JSON" is + * present, which some providers require to enable their JSON mode). + */ + protected function getSystemPrompt(AIRequest $request) : ?string + { + $systemPrompt = $request->getSystemPrompt(); + if (!$request->isJsonResponse()) { + return $systemPrompt; + } + if ($systemPrompt === null || trim($systemPrompt) === '') { + return self::JSON_RESPONSE_INSTRUCTION; + } + return rtrim($systemPrompt) . "\n\n" . self::JSON_RESPONSE_INSTRUCTION; + } + /** + * @param int|null $inputTokens Prompt tokens reported by the provider, if any. + * @param int|null $outputTokens Completion tokens reported by the provider, if any. + */ + protected function buildResponse(AIRequest $request, string $model, string $text, ?int $inputTokens = null, ?int $outputTokens = null, ?string $stopReason = null) : AIProviderResponse + { + return new AIProviderResponse($this->getId(), $this->getName(), $model, trim($text), $inputTokens, $outputTokens, $this->getReasoningLevelUsed($request), $this->isWebSearchUsed($request), $this->lastRequestExecutionTimeMs, $stopReason); + } + /** + * @param list $content canonical assistant content blocks + * @param int|null $inputTokens Prompt tokens reported by the provider, if any. + * @param int|null $outputTokens Completion tokens reported by the provider, if any. + */ + protected function buildConversationResponse(string $model, array $content, string $stopReason, ?int $inputTokens = null, ?int $outputTokens = null) : AIConversationResponse + { + return new AIConversationResponse($this->getId(), $this->getName(), $model, $content, $stopReason, $inputTokens, $outputTokens, $this->lastRequestExecutionTimeMs); + } + protected function getReasoningLevelUsed(AIRequest $request) : string + { + // Reported back on the response so callers can see whether the request + // actually ran with thinking. Providers that enable thinking per the + // resolved capability level all map onto this single flag. + return $this->wantsThinking($request) ? Configuration::CAPABILITY_THINKING : AIRequest::REASONING_NONE; + } + protected function isWebSearchUsed(AIRequest $request) : bool + { + // TODO: Implement provider-specific web search/tool configuration for + // OpenAI, Google, Anthropic, and managed providers separately. + return \false; + } + /** + * Completes a request against an OpenAI-compatible Chat Completions endpoint. + * + * Shared by providers that speak the OpenAI `/chat/completions` wire format + * (system + user messages, `max_tokens`/`temperature`, and a `usage` object + * with `prompt_tokens`/`completion_tokens`). + * + * @param array $headers Additional request headers, such as authentication. + */ + protected function completeChatCompletion(AIRequest $request, string $endpointUrl, array $headers) : AIProviderResponse + { + $messages = []; + $systemPrompt = $this->getSystemPrompt($request); + if ($systemPrompt !== null && $systemPrompt !== '') { + $messages[] = ['role' => 'system', 'content' => $systemPrompt]; + } + $messages[] = ['role' => 'user', 'content' => $request->getUserPrompt()]; + $model = $this->resolveModel($request); + $payload = ['model' => $model, 'messages' => $messages]; + $payload[$this->chatCompletionTokenLimitField()] = $request->getMaxTokens(); + if ($this->chatCompletionSupportsTemperature()) { + $payload['temperature'] = $request->getTemperature(); + } + $payload = array_merge($payload, $this->getExtraChatCompletionPayload($request)); + if ($request->isJsonResponse()) { + $payload['response_format'] = ['type' => 'json_object']; + } + $response = $this->sendJsonRequest($endpointUrl, $headers, $payload); + $text = $response['choices'][0]['message']['content'] ?? ''; + $finishReason = is_string($response['choices'][0]['finish_reason'] ?? null) ? $response['choices'][0]['finish_reason'] : null; + return $this->buildResponse($request, $model, is_string($text) ? $text : '', isset($response['usage']['prompt_tokens']) ? (int) $response['usage']['prompt_tokens'] : null, isset($response['usage']['completion_tokens']) ? (int) $response['usage']['completion_tokens'] : null, $finishReason); + } + /** + * @return array + */ + protected function getExtraChatCompletionPayload(AIRequest $request) : array + { + return []; + } + /** + * The Chat Completions field that bounds output length. Defaults to the + * classic `max_tokens`; OpenAI's reasoning models require + * `max_completion_tokens` instead. + */ + protected function chatCompletionTokenLimitField() : string + { + return 'max_tokens'; + } + /** + * Whether the provider accepts a custom `temperature`. Reasoning models + * (e.g. OpenAI's gpt-5 family) only allow the default temperature, so they + * override this to false and the field is omitted. + */ + protected function chatCompletionSupportsTemperature() : bool + { + return \true; + } + /** + * Whether this request should run with the provider's "thinking" mode on. + * + * The capability level set on the request — or the configured default, + * applied by {@link \Piwik\Plugins\AIProviders\AIProviderService::complete()} + * — decides this, but an explicit per-request thinking budget always wins: + * a budget of 0 forces instant, a positive budget forces thinking. This is + * how a caller overrides the configured default for a single request (see + * {@link AIRequest::withThinkingBudget()}). + */ + protected function wantsThinking(AIRequest $request) : bool + { + $budget = $request->getThinkingBudget(); + if ($budget !== null) { + return $budget > 0; + } + return $request->getCapabilityLevel() === Configuration::CAPABILITY_THINKING; + } + /** + * The thinking token budget to send when thinking is on: the caller's + * explicit positive budget, otherwise {@link self::DEFAULT_THINKING_BUDGET}. + */ + protected function thinkingBudget(AIRequest $request) : int + { + $budget = $request->getThinkingBudget(); + return $budget !== null && $budget > 0 ? $budget : self::DEFAULT_THINKING_BUDGET; + } + /** + * Runs one conversational round-trip against an OpenAI-compatible Chat + * Completions endpoint. + * + * Shared by providers that speak the OpenAI `/chat/completions` wire format + * for multi-turn, tool-calling conversations. The canonical message shape + * is translated to OpenAI `messages` (system/user/assistant/tool roles, + * assistant `tool_calls` with JSON-string arguments, and one `tool` message + * per tool result), the tool catalogue to `tools`, and the response's + * `choices[0]` back to canonical content blocks with stop reasons mapped + * onto the AIConversationResponse::STOP_* constants. + * + * @see https://platform.openai.com/docs/api-reference/chat/create + * @param array $headers Additional request headers, such as authentication. + */ + protected function converseChatCompletion(AIConversationRequest $request, string $endpointUrl, array $headers) : AIConversationResponse + { + $model = $this->resolveConversationModel($request); + $payload = ['model' => $model, 'messages' => $this->canonicalMessagesToOpenAI($request->getMessages(), $request->getSystemPrompt())]; + $payload[$this->chatCompletionTokenLimitField()] = $request->getMaxTokens(); + if ($this->chatCompletionSupportsTemperature()) { + $payload['temperature'] = $request->getTemperature(); + } + $tools = $this->toolCatalogToOpenAI($request->getTools()); + if ($tools !== null) { + $payload['tools'] = $tools; + } + $response = $this->sendJsonRequest($endpointUrl, $headers, $payload, $request->getTimeoutSeconds()); + $message = is_array($response['choices'][0]['message'] ?? null) ? $response['choices'][0]['message'] : []; + $finishReason = is_string($response['choices'][0]['finish_reason'] ?? null) ? $response['choices'][0]['finish_reason'] : ''; + return $this->buildConversationResponse($model, $this->openAIMessageToCanonical($message), $this->mapOpenAIFinishReason($finishReason), isset($response['usage']['prompt_tokens']) ? (int) $response['usage']['prompt_tokens'] : null, isset($response['usage']['completion_tokens']) ? (int) $response['usage']['completion_tokens'] : null); + } + /** + * @param list $messages canonical messages + * @return list> + */ + private function canonicalMessagesToOpenAI(array $messages, ?string $systemPrompt) : array + { + $openAIMessages = []; + if ($systemPrompt !== null && $systemPrompt !== '') { + $openAIMessages[] = ['role' => 'system', 'content' => $systemPrompt]; + } + foreach ($messages as $message) { + if ($message['role'] === 'assistant') { + $openAIMessages[] = $this->canonicalAssistantToOpenAI($message['content']); + continue; + } + if ($message['role'] === 'tool') { + // OpenAI expects one message per tool result, so a single + // canonical 'tool' message fans out into N 'tool' messages. + foreach ($this->canonicalToolResultsToOpenAI($message['content']) as $toolMessage) { + $openAIMessages[] = $toolMessage; + } + continue; + } + // Canonical 'user' messages carry only text blocks. + $openAIMessages[] = ['role' => 'user', 'content' => $this->textBlocksToString($message['content'])]; + } + return $openAIMessages; + } + /** + * @param list $content canonical assistant content blocks + * @return array + */ + private function canonicalAssistantToOpenAI(array $content) : array + { + $toolCalls = []; + foreach (CanonicalMessage::toolUseBlocks($content) as $block) { + // tool_call.arguments must be a JSON string holding an object even + // when empty; json_encode collapses `[]` to `[]`, so coerce empty + // inputs to stdClass so the arguments string is `{}`. + $arguments = json_encode($block['input'] === [] ? new \stdClass() : $block['input']); + $toolCalls[] = ['id' => $block['id'], 'type' => 'function', 'function' => ['name' => $block['name'], 'arguments' => $arguments === \false ? '{}' : $arguments]]; + } + // OpenAI requires the content key to be present even when tool_calls + // carry the turn; an empty string keeps it valid. + $message = ['role' => 'assistant', 'content' => $this->textBlocksToString($content)]; + if ($toolCalls !== []) { + $message['tool_calls'] = $toolCalls; + } + return $message; + } + /** + * @param list $content canonical tool_result blocks + * @return list> + */ + private function canonicalToolResultsToOpenAI(array $content) : array + { + $messages = []; + foreach ($content as $block) { + if (($block['type'] ?? null) !== 'tool_result') { + continue; + } + $toolUseId = $block['tool_use_id'] ?? null; + if (!is_string($toolUseId)) { + continue; + } + $structured = is_array($block['structuredContent'] ?? null) ? $block['structuredContent'] : null; + $mcpContent = is_array($block['content'] ?? null) ? $block['content'] : []; + $messages[] = ['role' => 'tool', 'tool_call_id' => $toolUseId, 'content' => $this->toolResultContentToOpenAI($structured, $mcpContent)]; + } + return $messages; + } + /** + * OpenAI tool messages carry a single string content. The tool's + * structured output, when present, is serialised; otherwise MCP text + * blocks are concatenated, with non-text blocks JSON-stringified so their + * data still reaches the model. + * + * @param array|null $structured + * @param list> $mcpContent + */ + private function toolResultContentToOpenAI(?array $structured, array $mcpContent) : string + { + if ($structured !== null) { + $serialised = json_encode($structured); + return $serialised === \false ? '' : $serialised; + } + $parts = []; + foreach ($mcpContent as $block) { + if (($block['type'] ?? null) === 'text' && is_string($block['text'] ?? null)) { + $parts[] = $block['text']; + continue; + } + $serialised = json_encode($block); + if ($serialised !== \false) { + $parts[] = $serialised; + } + } + return implode("\n", $parts); + } + /** + * Concatenates the text blocks of a canonical content list, ignoring any + * non-text blocks. Returns '' when there are none. + * + * @param list $content + */ + private function textBlocksToString(array $content) : string + { + $parts = []; + foreach ($content as $block) { + if (($block['type'] ?? null) === 'text' && is_string($block['text'] ?? null)) { + $parts[] = $block['text']; + } + } + return implode("\n", $parts); + } + /** + * @param list $tools + * @return list}>|null + */ + private function toolCatalogToOpenAI(array $tools) : ?array + { + if ($tools === []) { + return null; + } + $openAITools = []; + foreach ($tools as $tool) { + $openAITools[] = ['type' => 'function', 'function' => ['name' => $tool['name'], 'description' => $tool['description'], 'parameters' => $this->toToolParametersObjectSchema($tool['inputSchema'])]]; + } + return $openAITools; + } + /** + * Makes a tool's parameter schema acceptable to OpenAI and Google. + * + * Both reject certain keywords at the top level of the schema + * (`oneOf`/`anyOf`/`allOf`/`not`/`enum`/`const`), so this method strips them and + * forces a plain top-level `type: object`. + * + * These are only validation hints, and the tool server re-validates arguments + * on the actual call, so nothing is really loosened. Nested property schemas + * are left untouched, and `additionalProperties` are kept on purpose + * (OpenAI's strict mode requires it). + * + * Anthropic accepts the original schema and skips this entirely. + * Google is stricter and adds a deeper recursive strip on top, in + * {@see Google::googleParameterSchema()}. + * + * @param array $schema + * @return array + */ + protected function toToolParametersObjectSchema(array $schema) : array + { + unset($schema['oneOf'], $schema['anyOf'], $schema['allOf'], $schema['not'], $schema['enum'], $schema['const']); + if (($schema['type'] ?? null) !== 'object') { + $schema['type'] = 'object'; + } + return $schema; + } + /** + * @param array $message OpenAI assistant message + * @return list canonical assistant content blocks + */ + private function openAIMessageToCanonical(array $message) : array + { + $canonical = []; + $text = $message['content'] ?? null; + if (is_string($text) && $text !== '') { + $canonical[] = ['type' => 'text', 'text' => $text]; + } + $toolCalls = is_array($message['tool_calls'] ?? null) ? $message['tool_calls'] : []; + foreach ($toolCalls as $toolCall) { + if (!is_array($toolCall)) { + continue; + } + $id = $toolCall['id'] ?? null; + $name = $toolCall['function']['name'] ?? null; + if (!is_string($id) || !is_string($name)) { + continue; + } + $arguments = $toolCall['function']['arguments'] ?? null; + $decoded = is_string($arguments) && $arguments !== '' ? json_decode($arguments, \true) : []; + $input = []; + if (is_array($decoded)) { + foreach ($decoded as $key => $value) { + if (is_string($key)) { + $input[$key] = $value; + } + } + } + $canonical[] = ['type' => 'tool_use', 'id' => $id, 'name' => $name, 'input' => $input]; + } + return $canonical; + } + /** + * Maps an OpenAI `finish_reason` onto the canonical stop reasons, passing + * unknown values through unchanged. + */ + private function mapOpenAIFinishReason(string $finishReason) : string + { + switch ($finishReason) { + case 'tool_calls': + return AIConversationResponse::STOP_TOOL_USE; + case 'stop': + return AIConversationResponse::STOP_END_TURN; + case 'length': + return AIConversationResponse::STOP_MAX_TOKENS; + case 'content_filter': + return AIConversationResponse::STOP_GUARDRAIL_INTERVENED; + default: + return $finishReason; + } + } + /** + * @return array{ + * id: string, + * name: string, + * description: string, + * supportsCustomEndpoint: bool, + * supportsFipsEndpoint: bool, + * defaultEndpointUrl: string, + * endpointFieldTitle: string, + * endpointFieldPlaceholder: string, + * defaultModel: string + * } + */ + public function toArray() : array + { + return ['id' => $this->getId(), 'name' => $this->getName(), 'description' => $this->getDescription(), 'supportsCustomEndpoint' => $this->supportsCustomEndpoint(), 'supportsFipsEndpoint' => $this->supportsFipsEndpoint(), 'defaultEndpointUrl' => $this->getDefaultEndpointUrl(), 'endpointFieldTitle' => $this->getEndpointFieldTitle(), 'endpointFieldPlaceholder' => $this->getEndpointFieldPlaceholder(), 'defaultModel' => $this->getDefaultModel()]; + } + /** + * @param array $configuration + */ + protected function getApiKey(array $configuration) : string + { + $apiKey = trim($configuration['apiKey'] ?? ''); + if ($apiKey === '') { + throw new AIProviderClientException(sprintf('No API key is configured for %s.', $this->getName())); + } + return $apiKey; + } + /** + * Builds the bearer Authorization header for OpenAI-compatible providers, + * omitting it entirely when no API key is configured. This lets custom + * endpoints point at local LLM servers that run without authentication + * while still sending the key when one is provided. + * + * @param array $configuration + * @return array + */ + protected function getBearerAuthorizationHeaders(array $configuration) : array + { + $apiKey = trim($configuration['apiKey'] ?? ''); + return $apiKey === '' ? [] : ['Authorization' => 'Bearer ' . $apiKey]; + } + /** + * @param array $configuration + */ + protected function getEndpointUrl(array $configuration) : string + { + $endpointUrl = $this->supportsCustomEndpoint() ? trim($configuration['endpointUrl'] ?? '') : ''; + if ($endpointUrl === '') { + $endpointUrl = $this->getDefaultEndpointUrl(); + } + if ($endpointUrl === '') { + throw new AIProviderClientException(sprintf('No endpoint URL is configured for %s.', $this->getName())); + } + $parsedUrl = parse_url($endpointUrl); + $scheme = is_array($parsedUrl) ? $parsedUrl['scheme'] ?? '' : ''; + if (!filter_var($endpointUrl, \FILTER_VALIDATE_URL) || !in_array($scheme, ['http', 'https'], \true)) { + throw new AIProviderClientException(sprintf('The endpoint URL for %s is invalid.', $this->getName())); + } + return $endpointUrl; + } + /** + * Derives the OpenAI-compatible models-listing endpoint from a chat + * completions endpoint, e.g. `.../v1/chat/completions` or a bare `.../v1` + * base both become `.../v1/models` — the standard `GET {base}/models` + * probe used by the "test connection" flow. + */ + protected function openAiCompatibleModelsEndpoint(string $chatEndpointUrl) : string + { + $base = preg_replace('#/chat/completions/?$#', '', $chatEndpointUrl) ?? $chatEndpointUrl; + return rtrim($base, '/') . '/models'; + } + /** + * Sends a JSON request to the provider and returns the decoded JSON object. + * + * Transient errors (HTTP 429/500/503/504/529) are retried with backoff. Failures are + * classified: {@link AIProviderClientException} for authentication and + * 4xx responses, {@link AIProviderServerException} for 5xx responses + * after retries, {@link AIProviderException} for transport and protocol + * failures. + * + * @param array $headers + * @param array $payload + * @return array + */ + protected function sendJsonRequest(string $url, array $headers, array $payload, int $timeoutSeconds = 30) : array + { + $requestBody = json_encode($payload); + if (!is_string($requestBody)) { + throw new AIProviderException(sprintf('Could not encode request body for %s.', $this->getName())); + } + return $this->sendRequest('POST', $url, $headers, $requestBody, $timeoutSeconds, $payload); + } + /** + * Sends a GET request to the provider and returns the decoded JSON object, + * used by the "test connection" probe. Failures are classified exactly as + * in {@link sendJsonRequest()}. + * + * @param array $headers + * @return array + */ + protected function sendGetRequest(string $url, array $headers, int $timeoutSeconds = 10) : array + { + return $this->sendRequest('GET', $url, $headers, null, $timeoutSeconds); + } + /** + * Performs the actual HTTP exchange, retrying transient errors and + * classifying failures. Shared by {@link sendJsonRequest()} and + * {@link sendGetRequest()}. + * + * @param array $headers + * @param array|null $debugPayload + * @return array + */ + private function sendRequest(string $method, string $url, array $headers, ?string $requestBody, int $timeoutSeconds, ?array $debugPayload = null) : array + { + $requestHeaders = []; + if ($requestBody !== null) { + $requestHeaders[] = 'Content-Type: application/json'; + } + foreach ($headers as $name => $value) { + $requestHeaders[] = $name . ': ' . $value; + } + $retryDelays = self::TRANSIENT_ERROR_RETRY_DELAYS; + $startedAt = microtime(\true); + $host = parse_url($url, \PHP_URL_HOST); + $checkHostIsAllowed = !is_string($host) || !$this->isTrustedRequestHost($host); + for ($attempt = 0; $attempt <= count($retryDelays); $attempt++) { + $attemptStartedAt = microtime(\true); + $this->logProviderRequestDebugMetadata($method, $url, $timeoutSeconds, strlen($requestBody ?? ''), $attempt + 1, $debugPayload); + try { + $response = Http::sendHttpRequestBy(Http::getTransportMethod(), $url, max(1, $timeoutSeconds), null, null, null, 0, \false, \false, \false, \true, $method, null, null, $requestBody, $requestHeaders, null, $checkHostIsAllowed); + } catch (Exception $e) { + $this->logProviderTransportDebugMetadata($method, $url, $timeoutSeconds, $attempt + 1, $e); + throw new AIProviderException(sprintf('Could not connect to %s.', $this->getName()), 0, $e); + } + $status = (int) ($response['status'] ?? 0); + $body = is_string($response['data'] ?? null) ? $response['data'] : ''; + $decoded = $body !== '' ? json_decode($body, \true) : null; + $this->logProviderResponseDebugMetadata($method, $url, $timeoutSeconds, $status, strlen($body), (int) round((microtime(\true) - $attemptStartedAt) * 1000), $attempt + 1, $debugPayload); + if ($status >= 200 && $status < 300) { + if (!is_array($decoded)) { + throw new AIProviderException(sprintf('%s returned invalid JSON.', $this->getName())); + } + $this->lastRequestExecutionTimeMs = (int) round((microtime(\true) - $startedAt) * 1000); + return $decoded; + } + $providerError = $this->getProviderErrorMessage(is_array($decoded) ? $decoded : []); + if ($this->isAuthenticationError($providerError)) { + throw new AIProviderClientException(sprintf('%s rejected the API key. Check the key and try again.', $this->getName())); + } + if ($this->shouldRetryTransientError($status, $attempt, $retryDelays)) { + sleep($retryDelays[$attempt]); + continue; + } + $errorSuffix = $providerError !== '' ? ': ' . mb_strimwidth($providerError, 0, 300, '…', 'UTF-8') : ''; + $message = sprintf('%s request failed%s.', $this->getName(), $errorSuffix); + if ($status >= 500) { + throw new AIProviderServerException($message); + } + if ($status >= 400) { + throw new AIProviderClientException($message); + } + throw new AIProviderException($message); + } + throw new AIProviderServerException(sprintf('%s request failed.', $this->getName())); + } + /** + * @param array $response + */ + private function getProviderErrorMessage(array $response) : string + { + $message = ''; + if (isset($response['error']) && is_string($response['error'])) { + $message = $response['error']; + } elseif (isset($response['error']) && is_array($response['error'])) { + $error = $response['error']; + if (isset($error['message']) && is_string($error['message'])) { + $message = $error['message']; + } elseif (isset($error['type']) && is_string($error['type'])) { + $message = $error['type']; + } + } elseif (isset($response['message']) && is_string($response['message'])) { + // AWS Bedrock errors carry a top-level `message` without an + // `error` wrapper. + $message = $response['message']; + } + return $message; + } + /** + * Debug-only metadata for local provider testing. Intentionally does not + * log request/response content, headers, API keys, prompts, or model output. + * + * @param array|null $payload + */ + private function logProviderRequestDebugMetadata(string $method, string $url, int $timeoutSeconds, int $requestBytes, int $attempt, ?array $payload) : void + { + if (!Development::isEnabled()) { + return; + } + StaticContainer::get(LoggerInterface::class)->debug('AIProviders debug provider request metadata: provider={provider}, method={method}, ' . 'host={host}, path={path}, model={model}, thinking={thinking}, timeoutSeconds={timeoutSeconds}, ' . 'attempt={attempt}, requestBytes={requestBytes}', array_merge($this->getProviderRequestDebugMetadata($url, $payload), ['provider' => $this->getName(), 'method' => $method, 'timeoutSeconds' => $timeoutSeconds, 'attempt' => $attempt, 'requestBytes' => $requestBytes])); + } + /** + * @param array|null $payload + */ + private function logProviderResponseDebugMetadata(string $method, string $url, int $timeoutSeconds, int $status, int $responseBytes, int $durationMs, int $attempt, ?array $payload) : void + { + if (!Development::isEnabled()) { + return; + } + StaticContainer::get(LoggerInterface::class)->debug('AIProviders debug provider response metadata: provider={provider}, method={method}, ' . 'host={host}, path={path}, model={model}, thinking={thinking}, timeoutSeconds={timeoutSeconds}, ' . 'attempt={attempt}, status={status}, durationMs={durationMs}, responseBytes={responseBytes}', array_merge($this->getProviderRequestDebugMetadata($url, $payload), ['provider' => $this->getName(), 'method' => $method, 'timeoutSeconds' => $timeoutSeconds, 'attempt' => $attempt, 'status' => $status, 'durationMs' => $durationMs, 'responseBytes' => $responseBytes])); + } + private function logProviderTransportDebugMetadata(string $method, string $url, int $timeoutSeconds, int $attempt, Exception $exception) : void + { + if (!Development::isEnabled()) { + return; + } + StaticContainer::get(LoggerInterface::class)->debug('AIProviders debug provider transport metadata: provider={provider}, method={method}, ' . 'host={host}, path={path}, timeoutSeconds={timeoutSeconds}, attempt={attempt}, ' . 'exceptionClass={exceptionClass}', array_merge($this->getProviderRequestDebugMetadata($url, null), ['provider' => $this->getName(), 'method' => $method, 'timeoutSeconds' => $timeoutSeconds, 'attempt' => $attempt, 'exceptionClass' => get_class($exception)])); + } + /** + * @param array|null $payload + * @return array{host: string, path: string, model: string, thinking: string} + */ + private function getProviderRequestDebugMetadata(string $url, ?array $payload) : array + { + $path = (string) parse_url($url, \PHP_URL_PATH); + return ['host' => (string) parse_url($url, \PHP_URL_HOST), 'path' => $path, 'model' => $this->getDebugModel($payload, $path), 'thinking' => $this->getDebugThinking($payload)]; + } + /** + * @param array|null $payload + */ + private function getDebugModel(?array $payload, string $path) : string + { + if (isset($payload['model']) && is_string($payload['model'])) { + return $payload['model']; + } + // Google carries the model at /models/:, Bedrock at + // /model//converse. + if (preg_match('~/models?/([^/:]+)~', $path, $matches)) { + return rawurldecode($matches[1]); + } + return ''; + } + /** + * @param array|null $payload + */ + private function getDebugThinking(?array $payload) : string + { + if ($payload === null) { + return 'unknown'; + } + if (isset($payload['reasoning_effort']) && is_string($payload['reasoning_effort'])) { + return $payload['reasoning_effort'] === 'none' ? 'no' : 'yes'; + } + if (isset($payload['thinking']) && is_array($payload['thinking'])) { + return 'yes'; + } + $thinkingBudget = $payload['generationConfig']['thinkingConfig']['thinkingBudget'] ?? null; + if (is_int($thinkingBudget) || is_float($thinkingBudget)) { + return $thinkingBudget > 0 ? 'yes' : 'no'; + } + return 'unknown'; + } + private function isAuthenticationError(string $message) : bool + { + $message = strtolower($message); + return strpos($message, 'api key') !== \false || strpos($message, 'x-api-key') !== \false || strpos($message, 'authentication') !== \false || strpos($message, 'unauthorized') !== \false || strpos($message, 'invalid key') !== \false; + } + /** + * @param int[] $retryDelays + */ + private function shouldRetryTransientError(int $status, int $attempt, array $retryDelays) : bool + { + return in_array($status, self::TRANSIENT_ERROR_STATUS_CODES, \true) && array_key_exists($attempt, $retryDelays); + } +} diff --git a/app/plugins/AIProviders/Provider/Anthropic.php b/app/plugins/AIProviders/Provider/Anthropic.php new file mode 100644 index 000000000..f09b0ba3a --- /dev/null +++ b/app/plugins/AIProviders/Provider/Anthropic.php @@ -0,0 +1,286 @@ += 1024 and strictly < max_tokens; this + // headroom keeps room for the visible answer on top of the thinking budget. + private const THINKING_MIN_BUDGET = 1024; + private const THINKING_OUTPUT_HEADROOM = 1024; + public function __construct() + { + parent::__construct('anthropic', 'Anthropic', 'AIProviders_AnthropicDefaultModelDescription', \false); + } + public function getDefaultEndpointUrl() : string + { + return 'https://api.anthropic.com/v1/messages'; + } + public function getDefaultModel() : string + { + return self::DEFAULT_MODEL; + } + /** + * Custom Anthropic chat completion method. + * @see https://platform.claude.com/docs/en/api/messages/create + * @param array $configuration + */ + public function complete(AIRequest $request, array $configuration) : AIProviderResponse + { + $model = $this->resolveModel($request); + $payload = ['model' => $model, 'max_tokens' => $request->getMaxTokens(), 'temperature' => $request->getTemperature(), 'messages' => [['role' => 'user', 'content' => $request->getUserPrompt()]]]; + $this->applyThinking($payload, $request); + $systemPrompt = $this->getSystemPrompt($request); + if ($systemPrompt !== null && $systemPrompt !== '') { + $payload['system'] = $systemPrompt; + } + $response = $this->sendJsonRequest($this->getEndpointUrl($configuration), ['anthropic-version' => self::ANTHROPIC_VERSION, 'x-api-key' => $this->getApiKey($configuration)], $payload); + $text = $this->getFirstTextBlock($response['content'] ?? []); + $stopReason = is_string($response['stop_reason'] ?? null) ? $response['stop_reason'] : null; + return $this->buildResponse($request, $model, $text, isset($response['usage']['input_tokens']) ? (int) $response['usage']['input_tokens'] : null, isset($response['usage']['output_tokens']) ? (int) $response['usage']['output_tokens'] : null, $stopReason); + } + /** + * With thinking enabled, anthropic returns the text block at a different index. + * + * @param mixed $content + */ + private function getFirstTextBlock($content) : string + { + if (!is_array($content)) { + return ''; + } + foreach ($content as $block) { + if (!is_array($block)) { + continue; + } + if (($block['type'] ?? null) === 'text' && is_string($block['text'] ?? null)) { + return $block['text']; + } + } + return ''; + } + /** + * Turns on Anthropic extended thinking when the request resolves to the + * thinking capability. Enabling it has three wire requirements: the + * `thinking` block with a budget of at least 1024 tokens, a `max_tokens` + * strictly larger than that budget (bumped here when needed), and no custom + * `temperature` (only the default is allowed with thinking), so it is + * dropped. Instant requests leave the payload untouched. + * + * @param array $payload + */ + private function applyThinking(array &$payload, AIRequest $request) : void + { + if (!$this->wantsThinking($request)) { + return; + } + $budget = max(self::THINKING_MIN_BUDGET, $this->thinkingBudget($request)); + $payload['thinking'] = ['type' => 'enabled', 'budget_tokens' => $budget]; + $payload['max_tokens'] = max($request->getMaxTokens(), $budget + self::THINKING_OUTPUT_HEADROOM); + unset($payload['temperature']); + } + /** + * Validates credentials and reachability with a cheap models listing + * (`GET /v1/models`) instead of spending generation tokens. + * + * @see https://platform.claude.com/docs/en/api/models-list + * @param array $configuration + */ + public function verifyConnection(array $configuration) : void + { + $modelsEndpoint = preg_replace('#/messages/?$#', '/models', $this->getEndpointUrl($configuration)) ?? $this->getEndpointUrl($configuration); + $this->sendGetRequest($modelsEndpoint, ['anthropic-version' => self::ANTHROPIC_VERSION, 'x-api-key' => $this->getApiKey($configuration)]); + } + public function supportsConversations() : bool + { + return \true; + } + /** + * Runs one conversational round-trip against the Anthropic Messages API. + * + * The canonical message shape maps almost one-to-one onto the Anthropic + * wire format: roles, text/tool_use/tool_result blocks, and the + * end_turn/tool_use/max_tokens/stop_sequence stop reasons all match. + * Canonical 'tool' messages fold into 'user' messages carrying + * tool_result blocks; consecutive same-role messages are valid input + * (the API combines them), so no merging is needed. + * + * @see https://platform.claude.com/docs/en/api/messages/create + * @param array $configuration + */ + public function converse(AIConversationRequest $request, array $configuration) : AIConversationResponse + { + $model = $this->resolveConversationModel($request); + $payload = ['model' => $model, 'max_tokens' => $request->getMaxTokens(), 'temperature' => $request->getTemperature(), 'messages' => $this->canonicalMessagesToAnthropic($request->getMessages())]; + $systemPrompt = $request->getSystemPrompt(); + if ($systemPrompt !== null && $systemPrompt !== '') { + $payload['system'] = $systemPrompt; + } + $tools = $this->toolCatalogToAnthropic($request->getTools()); + if ($tools !== null) { + $payload['tools'] = $tools; + } + $response = $this->sendJsonRequest($this->getEndpointUrl($configuration), ['anthropic-version' => self::ANTHROPIC_VERSION, 'x-api-key' => $this->getApiKey($configuration)], $payload, $request->getTimeoutSeconds()); + $rawContent = is_array($response['content'] ?? null) ? $response['content'] : []; + $stopReason = is_string($response['stop_reason'] ?? null) ? $response['stop_reason'] : ''; + return $this->buildConversationResponse($model, $this->anthropicContentToCanonical($rawContent), $stopReason, isset($response['usage']['input_tokens']) ? (int) $response['usage']['input_tokens'] : null, isset($response['usage']['output_tokens']) ? (int) $response['usage']['output_tokens'] : null); + } + /** + * @param list $messages canonical messages + * @return list>}> + */ + private function canonicalMessagesToAnthropic(array $messages) : array + { + $anthropicMessages = []; + foreach ($messages as $message) { + // Anthropic uses two roles; canonical 'tool' rows carry their + // tool_result blocks under role=user so the tool_use/tool_result + // pairing survives the round trip. + $role = $message['role'] === 'assistant' ? 'assistant' : 'user'; + $blocks = []; + foreach ($message['content'] as $block) { + $translated = $this->canonicalBlockToAnthropic($block); + if ($translated !== null) { + $blocks[] = $translated; + } + } + $anthropicMessages[] = ['role' => $role, 'content' => $blocks]; + } + return $anthropicMessages; + } + /** + * @param array $block + * @return array|null null when the block shape is unrecognised + */ + private function canonicalBlockToAnthropic(array $block) : ?array + { + $type = $block['type'] ?? null; + if ($type === 'text' && is_string($block['text'] ?? null)) { + return ['type' => 'text', 'text' => $block['text']]; + } + if ($type === 'tool_use') { + $id = $block['id'] ?? null; + $name = $block['name'] ?? null; + $input = $block['input'] ?? []; + if (!is_string($id) || !is_string($name) || !is_array($input)) { + return null; + } + // tool_use.input must be a JSON object even when empty; + // json_decode collapses `{}` to `[]`, so coerce empty inputs back + // to stdClass so json_encode produces `{}` again. + return ['type' => 'tool_use', 'id' => $id, 'name' => $name, 'input' => $input === [] ? new \stdClass() : $input]; + } + if ($type === 'tool_result') { + $toolUseId = $block['tool_use_id'] ?? null; + if (!is_string($toolUseId)) { + return null; + } + $structured = is_array($block['structuredContent'] ?? null) ? $block['structuredContent'] : null; + $mcpContent = is_array($block['content'] ?? null) ? $block['content'] : []; + return ['type' => 'tool_result', 'tool_use_id' => $toolUseId, 'content' => $this->toolResultContentToAnthropic($structured, $mcpContent), 'is_error' => !empty($block['is_error'])]; + } + return null; + } + /** + * Anthropic tool_result content is a list of text/image blocks. The + * tool's structured output, when present, is serialised into a single + * text block; otherwise each MCP content block is translated, with + * non-text blocks JSON-stringified so their data still reaches the model. + * + * @param array|null $structured + * @param list> $mcpContent + * @return list> + */ + private function toolResultContentToAnthropic(?array $structured, array $mcpContent) : array + { + if ($structured !== null) { + $serialised = json_encode($structured); + return [['type' => 'text', 'text' => $serialised === \false ? '' : $serialised]]; + } + $blocks = []; + foreach ($mcpContent as $block) { + if (($block['type'] ?? null) === 'text' && is_string($block['text'] ?? null)) { + $blocks[] = ['type' => 'text', 'text' => $block['text']]; + continue; + } + $serialised = json_encode($block); + if ($serialised !== \false) { + $blocks[] = ['type' => 'text', 'text' => $serialised]; + } + } + if ($blocks === []) { + $blocks[] = ['type' => 'text', 'text' => '']; + } + return $blocks; + } + /** + * @param list $tools + * @return list}>|null + */ + private function toolCatalogToAnthropic(array $tools) : ?array + { + if ($tools === []) { + return null; + } + $anthropicTools = []; + foreach ($tools as $tool) { + $anthropicTools[] = ['name' => $tool['name'], 'description' => $tool['description'], 'input_schema' => $tool['inputSchema']]; + } + return $anthropicTools; + } + /** + * @param list $content Anthropic assistant content blocks + * @return list canonical assistant content blocks + */ + private function anthropicContentToCanonical(array $content) : array + { + $canonical = []; + foreach ($content as $block) { + if (!is_array($block)) { + continue; + } + if (($block['type'] ?? null) === 'text' && is_string($block['text'] ?? null)) { + $canonical[] = ['type' => 'text', 'text' => $block['text']]; + continue; + } + if (($block['type'] ?? null) === 'tool_use') { + $id = $block['id'] ?? null; + $name = $block['name'] ?? null; + $input = $block['input'] ?? []; + if (!is_string($id) || !is_string($name) || !is_array($input)) { + continue; + } + $normalizedInput = []; + foreach ($input as $key => $value) { + if (is_string($key)) { + $normalizedInput[$key] = $value; + } + } + $canonical[] = ['type' => 'tool_use', 'id' => $id, 'name' => $name, 'input' => $normalizedInput]; + continue; + } + // Other block shapes (thinking, server tool results, …) are + // dropped until a canonical block type exists for them. + } + return $canonical; + } +} diff --git a/app/plugins/AIProviders/Provider/Bedrock.php b/app/plugins/AIProviders/Provider/Bedrock.php new file mode 100644 index 000000000..5de300ce6 --- /dev/null +++ b/app/plugins/AIProviders/Provider/Bedrock.php @@ -0,0 +1,611 @@ + tags in + * the answer text, so that reasoning has to be split out here instead. + * + * @see https://docs.aws.amazon.com/nova/latest/userguide/prompting-chain-of-thought.html + */ + private const NOVA_GEN1_MODEL_PATTERN = '/(?:^|[.\\/])amazon\\.nova-(?:micro|lite|pro|premier)-v1:0$/'; + /** Balanced leading inline reasoning blocks emitted by Nova gen-1 responses. */ + private const LEADING_REASONING_PATTERN = '/^\\s*<(reasoning|think|thinking)>/i'; + /** + * Mistral Large models. Unlike other Converse families, Large only returns + * a structured toolUse block when a tool call is forced with + * `toolChoice: {any}`; under the default (auto) its tool calls leak back as + * plain text and are lost. Observed behaviour: with `any` set the model + * still answers directly when no tool is needed (contrary to the documented + * "must request at least one tool"), so forcing it here does not break + * conceptual or clarifying turns. This is undocumented Bedrock behaviour and + * is scoped to Large only; every other family works under auto. + */ + private const MISTRAL_LARGE_MODEL_PATTERN = '/(?:^|[.\\/])mistral\\.mistral-large-/i'; + public function __construct() + { + parent::__construct(self::ID, 'AWS Bedrock', 'AIProviders_BedrockDescription', \true); + } + public function getDefaultEndpointUrl() : string + { + return self::DEFAULT_REGION; + } + public function getEndpointFieldTitle() : string + { + return 'AIProviders_BedrockEndpointTitle'; + } + public function getEndpointFieldPlaceholder() : string + { + return 'AIProviders_BedrockEndpointPlaceholder'; + } + public function getEndpointFieldErrorMessage() : string + { + return 'AIProviders_ErrorInvalidAwsRegion'; + } + public function supportsFipsEndpoint() : bool + { + return \true; + } + public function endpointFieldRequiresUrl() : bool + { + return \false; + } + public function getDefaultModel() : string + { + return self::DEFAULT_MODEL; + } + /** + * The endpoint has a working default (it only carries the region), so the + * API key is the one required piece. + * + * @param array $configuration + */ + public function isConfigured(array $configuration) : bool + { + return trim($configuration['apiKey'] ?? '') !== ''; + } + /** + * Matomo's HTTP layer blocks `*.amazonaws.com` as SSRF protection (see + * `http.blocklist.hosts`). Only the genuine Bedrock service hostnames are + * trusted; other AWS services and custom endpoints stay blocked. + */ + protected function isTrustedRequestHost(string $host) : bool + { + return preg_match('/^bedrock(-runtime)?(-fips)?\\.[a-z]{2}(?:-[a-z0-9]+)+-[0-9]+\\.amazonaws\\.com$/i', $host) === 1; + } + /** + * @param array $configuration + */ + public function complete(AIRequest $request, array $configuration) : AIProviderResponse + { + $model = $this->resolveConfiguredModel($request->getModel(), $configuration); + $payload = ['messages' => [['role' => 'user', 'content' => [['text' => $request->getUserPrompt()]]]], 'inferenceConfig' => ['maxTokens' => $request->getMaxTokens(), 'temperature' => $request->getTemperature()]]; + // Completions carry a per-request thinking budget that overrides the + // capability level, so resolve the effective intent via wantsThinking(). + $this->applyReasoningConfiguration($payload, $model, $this->wantsThinking($request)); + $systemPrompt = $this->getSystemPrompt($request); + if ($systemPrompt !== null && $systemPrompt !== '') { + $payload['system'] = [['text' => $systemPrompt]]; + } + $response = $this->sendConverseRequest($model, $payload, self::COMPLETE_TIMEOUT_SECONDS, $configuration); + $stopReason = is_string($response['stopReason'] ?? null) ? $response['stopReason'] : null; + return $this->buildResponse($request, $model, $this->extractText($response, $this->isNovaGen1Model($model)), isset($response['usage']['inputTokens']) ? (int) $response['usage']['inputTokens'] : null, isset($response['usage']['outputTokens']) ? (int) $response['usage']['outputTokens'] : null, $stopReason); + } + public function supportsConversations() : bool + { + return \true; + } + /** + * Runs one conversational round-trip, translating the canonical message + * shape to and from the Converse wire format. + * + * @see https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html + * @param array $configuration + */ + public function converse(AIConversationRequest $request, array $configuration) : AIConversationResponse + { + $model = $this->resolveConfiguredModel($request->getModel(), $configuration); + $payload = ['messages' => $this->canonicalMessagesToBedrock($request->getMessages()), 'inferenceConfig' => ['maxTokens' => $request->getMaxTokens(), 'temperature' => $request->getTemperature()]]; + // Conversation requests have no thinking budget, so the capability + // level alone decides whether reasoning is on. + $thinking = $request->getCapabilityLevel() === Configuration::CAPABILITY_THINKING; + $this->applyReasoningConfiguration($payload, $model, $thinking); + $systemPrompt = $request->getSystemPrompt(); + if ($systemPrompt !== null && $systemPrompt !== '') { + $payload['system'] = [['text' => $systemPrompt]]; + } + $toolConfig = $this->toolCatalogToBedrock($request->getTools()); + if ($toolConfig !== null) { + // Mistral Large only emits a structured toolUse block when tool use + // is forced (see MISTRAL_LARGE_MODEL_PATTERN). stdClass keeps the + // json_encode output as `{}` rather than `[]`. + if ($this->isMistralLargeModel($model)) { + $toolConfig['toolChoice'] = ['any' => new \stdClass()]; + } + $payload['toolConfig'] = $toolConfig; + } + $response = $this->sendConverseRequest($model, $payload, $request->getTimeoutSeconds(), $configuration); + $rawContent = $response['output']['message']['content'] ?? null; + $stopReason = is_string($response['stopReason'] ?? null) ? $response['stopReason'] : ''; + return $this->buildConversationResponse($model, $this->bedrockContentToCanonical(is_array($rawContent) ? $rawContent : [], $this->isNovaGen1Model($model), $thinking), $stopReason, isset($response['usage']['inputTokens']) ? (int) $response['usage']['inputTokens'] : null, isset($response['usage']['outputTokens']) ? (int) $response['usage']['outputTokens'] : null); + } + /** + * Probes with the model listing instead of spending generation tokens. + * + * @param array $configuration + */ + public function verifyConnection(array $configuration) : void + { + $this->listModels($configuration); + } + /** + * Discovers invokable models via the control-plane listing (host + * `bedrock.`, not the runtime `bedrock-runtime.`, same region). + * `byInferenceType=ON_DEMAND` drops models that need an inference profile. + * + * @see https://docs.aws.amazon.com/bedrock/latest/APIReference/API_ListFoundationModels.html + * @param array $configuration + * @return list + */ + public function listModels(array $configuration) : array + { + $response = $this->sendGetRequest($this->getModelListingEndpoint($this->getRegion($configuration), $configuration), $this->getAuthorizationHeader($configuration)); + $models = []; + foreach ($response['modelSummaries'] ?? [] as $summary) { + if (is_array($summary) && isset($summary['modelId']) && is_string($summary['modelId']) && $summary['modelId'] !== '') { + $models[] = $summary['modelId']; + } + } + sort($models); + return $models; + } + /** + * Normalizes the AWS region. Bedrock endpoints are derived internally so + * user/config input never decides the request host directly. + */ + public function normalizeEndpointUrl(string $endpointUrl) : string + { + return $this->normalizeRegion($endpointUrl); + } + public function normalizeRegion(string $region) : string + { + $region = strtolower(trim($region)); + if ($region === '') { + return self::DEFAULT_REGION; + } + if (preg_match('/^[a-z]{2}(?:-[a-z0-9]+)+-[0-9]+$/', $region) !== 1) { + throw new AIProviderClientException(sprintf('The AWS region for %s is invalid.', $this->getName())); + } + return $region; + } + /** + * Applies region normalization on use as well: config-file credentials + * bypass the admin-side normalization. + * + * @param array $configuration + */ + protected function getEndpointUrl(array $configuration) : string + { + return $this->getRuntimeEndpoint($this->getRegion($configuration), $configuration); + } + /** + * @param array $configuration + */ + private function getRegion(array $configuration) : string + { + return $this->normalizeRegion((string) ($configuration['endpointUrl'] ?? '')); + } + /** + * @param array $configuration + */ + private function getRuntimeEndpoint(string $region, array $configuration) : string + { + $service = $this->useFipsEndpoint($configuration) ? 'bedrock-runtime-fips' : 'bedrock-runtime'; + return sprintf('https://%s.%s.amazonaws.com', $service, $region); + } + /** + * @param array $configuration + */ + private function getControlPlaneEndpoint(string $region, array $configuration) : string + { + $service = $this->useFipsEndpoint($configuration) ? 'bedrock-fips' : 'bedrock'; + return sprintf('https://%s.%s.amazonaws.com', $service, $region); + } + /** + * @param array $configuration + */ + private function useFipsEndpoint(array $configuration) : bool + { + return !empty($configuration['useFipsEndpoint']); + } + /** + * Resolves the model: per-request, then saved configuration, then default. + * + * @param array $configuration + */ + protected function resolveConfiguredModel(?string $requestModel, array $configuration) : string + { + if ($requestModel !== null && $requestModel !== '') { + return $requestModel; + } + $configuredModel = trim($configuration['model'] ?? ''); + return $configuredModel !== '' ? $configuredModel : $this->getDefaultModel(); + } + /** + * POSTs the Converse payload to the regional runtime endpoint with + * bearer-key auth. + * + * @param array $payload Converse request body, without the model + * @param array $configuration + * @return array + */ + protected function sendConverseRequest(string $model, array $payload, int $timeoutSeconds, array $configuration) : array + { + return $this->sendJsonRequest($this->getConverseEndpoint($this->getEndpointUrl($configuration), $model), $this->getAuthorizationHeader($configuration), $payload, $timeoutSeconds); + } + /** + * The model travels in the URL path, not the payload. Inference-profile + * IDs and ARNs contain `:` and `/`, so the model must be encoded as a + * single path segment. + */ + private function getConverseEndpoint(string $endpointUrl, string $model) : string + { + return rtrim($endpointUrl, '/') . '/model/' . rawurlencode($model) . '/converse'; + } + /** + * @param array $configuration + */ + private function getModelListingEndpoint(string $region, array $configuration) : string + { + return $this->getControlPlaneEndpoint($region, $configuration) . '/foundation-models?byInferenceType=ON_DEMAND'; + } + /** + * @param array $configuration + * @return array + */ + private function getAuthorizationHeader(array $configuration) : array + { + return ['Authorization' => 'Bearer ' . $this->getApiKey($configuration)]; + } + /** + * Translates canonical messages into Bedrock Converse messages. + * + * Converse requires turns to alternate user/assistant, and a canonical + * 'tool' row folds into a 'user' turn. A tool-result turn followed by the + * user's next message would emit two consecutive 'user' turns, which + * Bedrock rejects, so runs of same-role messages are merged into one turn + * (concatenating their content blocks is valid Converse input). + * + * @param list $messages canonical messages + * @return list>}> + */ + private function canonicalMessagesToBedrock(array $messages) : array + { + $bedrockMessages = []; + foreach ($messages as $message) { + // Converse uses two roles ('user', 'assistant') and folds tool + // results into a 'user' message body so the toolUse/toolResult + // pairing survives a round trip. + $role = $message['role'] === 'assistant' ? 'assistant' : 'user'; + $blocks = []; + foreach ($message['content'] as $block) { + $translated = $this->canonicalBlockToBedrock($block); + if ($translated !== null) { + $blocks[] = $translated; + } + } + $lastIndex = count($bedrockMessages) - 1; + if ($lastIndex >= 0 && $bedrockMessages[$lastIndex]['role'] === $role) { + $bedrockMessages[$lastIndex]['content'] = array_merge($bedrockMessages[$lastIndex]['content'], $blocks); + continue; + } + $bedrockMessages[] = ['role' => $role, 'content' => $blocks]; + } + return $bedrockMessages; + } + /** + * @param array $block + * @return array|null null when the block shape is unrecognised + */ + private function canonicalBlockToBedrock(array $block) : ?array + { + $type = $block['type'] ?? null; + if ($type === 'text' && is_string($block['text'] ?? null)) { + return ['text' => $block['text']]; + } + if ($type === 'reasoning') { + // Canonical reasoning is display-only and must never be replayed. + return null; + } + if ($type === 'tool_use') { + $id = $block['id'] ?? null; + $name = $block['name'] ?? null; + $input = $block['input'] ?? []; + if (!is_string($id) || !is_string($name) || !is_array($input)) { + return null; + } + // Bedrock requires toolUse.input to be a JSON object, even when + // empty. `json_decode($body, true)` collapses `{}` to `[]`; if + // that round-trips back to Bedrock it would emit `"[]"` and the + // next turn rejects it with a 400. Coerce empty inputs back to + // stdClass so json_encode produces `{}`. + $inputForWire = $input === [] ? new \stdClass() : $input; + return ['toolUse' => ['toolUseId' => $id, 'name' => $name, 'input' => $inputForWire]]; + } + if ($type === 'tool_result') { + $toolUseId = $block['tool_use_id'] ?? null; + if (!is_string($toolUseId)) { + return null; + } + $structured = is_array($block['structuredContent'] ?? null) ? $block['structuredContent'] : null; + $mcpContent = is_array($block['content'] ?? null) ? $block['content'] : []; + return ['toolResult' => ['toolUseId' => $toolUseId, 'content' => $this->toolResultContentToBedrock($structured, $mcpContent), 'status' => !empty($block['is_error']) ? 'error' : 'success']]; + } + return null; + } + /** + * Chooses the optimal Bedrock `toolResult.content` shape: + * + * - structuredContent → `[{json: }]`. The model receives the + * tool's structured output natively without re-parsing an escaped JSON + * string from a text block. + * - Otherwise each MCP content block is translated to its Bedrock + * counterpart, with non-text blocks JSON-stringified so the model + * still sees the data. + * + * @param array|null $structured + * @param list> $mcpContent + * @return list> + */ + private function toolResultContentToBedrock(?array $structured, array $mcpContent) : array + { + if ($structured !== null) { + return [['json' => $structured]]; + } + $bedrockBlocks = []; + foreach ($mcpContent as $block) { + if (($block['type'] ?? null) === 'text' && is_string($block['text'] ?? null)) { + $bedrockBlocks[] = ['text' => $block['text']]; + continue; + } + $serialised = json_encode($block); + if ($serialised !== \false) { + $bedrockBlocks[] = ['text' => $serialised]; + } + } + if ($bedrockBlocks === []) { + $bedrockBlocks[] = ['text' => '']; + } + return $bedrockBlocks; + } + /** + * Translates the tool catalogue into the Converse `toolConfig` shape, or + * null for an empty catalogue so `toolConfig` is omitted entirely. + * + * @param list $tools + * @return array{tools: list>}|null + */ + private function toolCatalogToBedrock(array $tools) : ?array + { + if ($tools === []) { + return null; + } + $bedrockTools = []; + foreach ($tools as $tool) { + $bedrockTools[] = ['toolSpec' => ['name' => $tool['name'], 'description' => $tool['description'], 'inputSchema' => ['json' => $tool['inputSchema']]]]; + } + return ['tools' => $bedrockTools]; + } + /** + * @param list $content Bedrock assistant content blocks + * @return list canonical assistant content blocks + */ + private function bedrockContentToCanonical(array $content, bool $splitInlineReasoning, bool $includeReasoning) : array + { + $canonical = []; + foreach ($content as $block) { + if (!is_array($block)) { + continue; + } + if (is_array($block['reasoningContent'] ?? null)) { + $reasoningText = $this->bedrockReasoningText($block['reasoningContent']); + if ($includeReasoning && is_string($reasoningText) && trim($reasoningText) !== '' && !$this->isRedactedReasoning($reasoningText)) { + $canonical[] = ['type' => 'reasoning', 'text' => $reasoningText]; + } + continue; + } + if (is_string($block['text'] ?? null)) { + if ($splitInlineReasoning) { + foreach ($this->splitLeadingReasoning($block['text']) as $splitBlock) { + if ($includeReasoning || $splitBlock['type'] !== 'reasoning') { + $canonical[] = $splitBlock; + } + } + } else { + $canonical[] = ['type' => 'text', 'text' => $block['text']]; + } + continue; + } + if (is_array($block['toolUse'] ?? null)) { + $toolUse = $block['toolUse']; + $id = $toolUse['toolUseId'] ?? null; + $name = $toolUse['name'] ?? null; + $input = $toolUse['input'] ?? []; + if (!is_string($id) || !is_string($name) || !is_array($input)) { + continue; + } + $normalizedInput = []; + foreach ($input as $key => $value) { + if (is_string($key)) { + $normalizedInput[$key] = $value; + } + } + $canonical[] = ['type' => 'tool_use', 'id' => $id, 'name' => $name, 'input' => $normalizedInput]; + continue; + } + // Unknown Bedrock block shapes are dropped until a canonical + // block type exists for them. + } + return $canonical; + } + /** + * @param array $reasoningContent + */ + private function bedrockReasoningText(array $reasoningContent) : ?string + { + $reasoningText = $reasoningContent['reasoningText'] ?? null; + return is_array($reasoningText) && is_string($reasoningText['text'] ?? null) ? $reasoningText['text'] : null; + } + private function isRedactedReasoning(string $text) : bool + { + return preg_match(self::REDACTED_REASONING_PATTERN, trim($text)) === 1; + } + /** + * Adds the model-family-specific reasoning control to the request payload, + * leaving models without a reasoning API untouched. + * + * gpt-oss exposes a flat `reasoning_effort`, while Nova 2 uses a + * structured `reasoningConfig`. Nova gen-1 has no reasoning API and is + * handled on the response side instead (see NOVA_GEN1_MODEL_PATTERN). + * + * @param array $payload + */ + private function applyReasoningConfiguration(array &$payload, string $model, bool $thinking) : void + { + if ($this->isGptOssModel($model)) { + $payload['additionalModelRequestFields']['reasoning_effort'] = $thinking ? 'medium' : 'low'; + return; + } + if ($this->isNovaReasoningModel($model)) { + $payload['additionalModelRequestFields']['reasoningConfig'] = $thinking ? ['type' => 'enabled', 'maxReasoningEffort' => 'medium'] : ['type' => 'disabled']; + } + } + private function isGptOssModel(string $model) : bool + { + return preg_match(self::GPT_OSS_MODEL_PATTERN, $model) === 1; + } + private function isNovaReasoningModel(string $model) : bool + { + return preg_match(self::NOVA_2_MODEL_PATTERN, $model) === 1; + } + private function isNovaGen1Model(string $model) : bool + { + return preg_match(self::NOVA_GEN1_MODEL_PATTERN, $model) === 1; + } + private function isMistralLargeModel(string $model) : bool + { + return preg_match(self::MISTRAL_LARGE_MODEL_PATTERN, $model) === 1; + } + /** + * Splits balanced leading reasoning tags from the remaining answer text. + * + * Nova gen-1 models emit their chain-of-thought as inline tags + * at the start of the answer instead of as structured reasoningContent, so + * the reasoning has to be separated from the visible answer here. + * + * @return list + */ + private function splitLeadingReasoning(string $raw) : array + { + $blocks = []; + $rest = $raw; + while (preg_match(self::LEADING_REASONING_PATTERN, $rest, $open, \PREG_OFFSET_CAPTURE) === 1) { + $reasoningStart = strlen($open[0][0]); + $closePattern = '/<\\/' . preg_quote($open[1][0], '/') . '>/i'; + if (preg_match($closePattern, $rest, $close, \PREG_OFFSET_CAPTURE, $reasoningStart) !== 1) { + break; + } + $reasoning = trim(substr($rest, $reasoningStart, $close[0][1] - $reasoningStart)); + if ($reasoning !== '') { + $blocks[] = ['type' => 'reasoning', 'text' => $reasoning]; + } + $rest = substr($rest, $close[0][1] + strlen($close[0][0])); + } + if (trim($rest) !== '') { + $blocks[] = ['type' => 'text', 'text' => $rest]; + } + return $blocks; + } + /** + * @param array $response + */ + private function extractText(array $response, bool $stripInlineReasoning) : string + { + $content = $response['output']['message']['content'] ?? []; + if (is_array($content)) { + foreach ($content as $block) { + if (isset($block['text']) && is_string($block['text'])) { + if (!$stripInlineReasoning) { + return $block['text']; + } + foreach ($this->splitLeadingReasoning($block['text']) as $splitBlock) { + if ($splitBlock['type'] === 'text') { + return $splitBlock['text']; + } + } + } + } + } + return ''; + } +} diff --git a/app/plugins/AIProviders/Provider/CustomProvider.php b/app/plugins/AIProviders/Provider/CustomProvider.php new file mode 100644 index 000000000..64bb70b3e --- /dev/null +++ b/app/plugins/AIProviders/Provider/CustomProvider.php @@ -0,0 +1,114 @@ + $configuration + */ + public function complete(AIRequest $request, array $configuration) : AIProviderResponse + { + return $this->completeChatCompletion($request->withModel($this->resolveConfiguredModel($request->getModel(), $configuration)), $this->getChatCompletionsEndpoint($this->getEndpointUrl($configuration)), $this->getBearerAuthorizationHeaders($configuration)); + } + /** + * Validates the connection with the standard OpenAI-compatible + * `GET {base}/models` probe instead of spending generation tokens. The + * API key is optional; getBearerAuthorizationHeaders() omits the header + * when no key was provided. + * + * @param array $configuration + */ + public function verifyConnection(array $configuration) : void + { + $this->listModels($configuration); + } + /** + * Discovers the models the configured server can serve via its standard + * OpenAI-compatible `GET {base}/models` listing. Doubles as the + * connection probe (it fails the same way an unreachable server would). + * + * @param array $configuration + * @return list + */ + public function listModels(array $configuration) : array + { + $response = $this->sendGetRequest($this->openAiCompatibleModelsEndpoint($this->getEndpointUrl($configuration)), $this->getBearerAuthorizationHeaders($configuration)); + $models = []; + foreach ($response['data'] ?? [] as $model) { + if (is_array($model) && isset($model['id']) && is_string($model['id']) && $model['id'] !== '') { + $models[] = $model['id']; + } + } + sort($models); + return $models; + } + public function supportsConversations() : bool + { + return \true; + } + /** + * Custom servers are expected to be OpenAI-compatible. The model comes from + * the request or the saved configuration (see {@link complete()}). + * + * @param array $configuration + */ + public function converse(AIConversationRequest $request, array $configuration) : AIConversationResponse + { + return $this->converseChatCompletion($request->withModel($this->resolveConfiguredModel($request->getModel(), $configuration)), $this->getChatCompletionsEndpoint($this->getEndpointUrl($configuration)), $this->getBearerAuthorizationHeaders($configuration)); + } + /** + * Resolves the model to send: the per-request model wins, otherwise the + * model saved in the provider configuration. There is no hardcoded fallback + * because model names are server-specific; a missing model is a clear + * configuration error rather than a silent wrong-model call. + * + * @param array $configuration + */ + private function resolveConfiguredModel(?string $requestModel, array $configuration) : string + { + $model = $requestModel !== null && $requestModel !== '' ? $requestModel : trim($configuration['model'] ?? ''); + if ($model === '') { + throw new AIProviderClientException('No model is configured for the custom provider. Select a model in the AI Providers settings.'); + } + return $model; + } + /** + * @return array + */ + protected function getExtraChatCompletionPayload(AIRequest $request) : array + { + return ['think' => $this->wantsThinking($request)]; + } + private function getChatCompletionsEndpoint(string $endpointUrl) : string + { + if (preg_match('#/chat/completions/?$#', $endpointUrl)) { + return $endpointUrl; + } + return rtrim($endpointUrl, '/') . '/chat/completions'; + } +} diff --git a/app/plugins/AIProviders/Provider/Google.php b/app/plugins/AIProviders/Provider/Google.php new file mode 100644 index 000000000..81b2098a5 --- /dev/null +++ b/app/plugins/AIProviders/Provider/Google.php @@ -0,0 +1,357 @@ +getEndpointUrlForModel($this->getDefaultModel()); + } + public function getDefaultModel() : string + { + return self::DEFAULT_MODEL; + } + /** + * Custom Google chat completion method. + * @see https://ai.google.dev/gemini-api/docs/text-generation + * @param array $configuration + */ + public function complete(AIRequest $request, array $configuration) : AIProviderResponse + { + $model = $this->resolveModel($request); + $payload = ['contents' => [['parts' => [['text' => $request->getUserPrompt()]]]], 'generationConfig' => ['maxOutputTokens' => $request->getMaxTokens(), 'temperature' => $request->getTemperature(), 'thinkingConfig' => ['thinkingBudget' => $this->wantsThinking($request) ? $this->thinkingBudget($request) : 0]]]; + if ($request->isJsonResponse()) { + $payload['generationConfig']['responseMimeType'] = 'application/json'; + } + $systemPrompt = $this->getSystemPrompt($request); + if ($systemPrompt !== null && $systemPrompt !== '') { + $payload['systemInstruction'] = ['parts' => [['text' => $systemPrompt]]]; + } + $response = $this->sendJsonRequest($this->getEndpointUrlForModel($model), ['x-goog-api-key' => $this->getApiKey($configuration)], $payload); + $text = $response['candidates'][0]['content']['parts'][0]['text'] ?? ''; + return $this->buildResponse($request, $model, is_string($text) ? $text : '', isset($response['usageMetadata']['promptTokenCount']) ? (int) $response['usageMetadata']['promptTokenCount'] : null, isset($response['usageMetadata']['candidatesTokenCount']) ? (int) $response['usageMetadata']['candidatesTokenCount'] : null); + } + /** + * Validates credentials and reachability with a cheap models listing + * instead of spending generation tokens. + * + * @see https://ai.google.dev/api/models#method:-models.list + * @param array $configuration + */ + public function verifyConnection(array $configuration) : void + { + $this->sendGetRequest('https://generativelanguage.googleapis.com/v1beta/models', ['x-goog-api-key' => $this->getApiKey($configuration)]); + } + public function supportsConversations() : bool + { + return \true; + } + /** + * Runs one conversational round-trip against the Google generateContent API. + * + * Google diverges from the canonical shape in two ways that this method + * reconciles. First, its roles are 'user' and 'model' (not 'assistant'), + * and canonical 'tool' result messages fold into 'user' messages carrying + * functionResponse parts. Second, and trickier, Google has no tool-call + * IDs: a functionResponse correlates with its functionCall purely by + * function NAME (and order). The canonical tool_result only carries the + * originating tool_use_id, so this method first walks the history building + * an id => name map from every assistant tool_use block, then resolves + * each tool_result's name from that map. On the way out, responses get a + * synthesized deterministic id per functionCall so the next turn's + * tool_result can be matched back through the same map. + * + * @see https://ai.google.dev/gemini-api/docs/function-calling + * @param array $configuration + */ + public function converse(AIConversationRequest $request, array $configuration) : AIConversationResponse + { + $model = $this->resolveConversationModel($request); + $payload = ['contents' => $this->canonicalMessagesToGoogle($request->getMessages()), 'generationConfig' => ['maxOutputTokens' => $request->getMaxTokens(), 'temperature' => $request->getTemperature()]]; + $systemPrompt = $request->getSystemPrompt(); + if ($systemPrompt !== null && $systemPrompt !== '') { + $payload['systemInstruction'] = ['parts' => [['text' => $systemPrompt]]]; + } + $tools = $this->toolCatalogToGoogle($request->getTools()); + if ($tools !== null) { + $payload['tools'] = $tools; + } + $response = $this->sendJsonRequest($this->getEndpointUrlForModel($model), ['x-goog-api-key' => $this->getApiKey($configuration)], $payload, $request->getTimeoutSeconds()); + $parts = is_array($response['candidates'][0]['content']['parts'] ?? null) ? $response['candidates'][0]['content']['parts'] : []; + $finishReason = is_string($response['candidates'][0]['finishReason'] ?? null) ? $response['candidates'][0]['finishReason'] : ''; + $content = $this->googlePartsToCanonical($parts); + $stopReason = $this->resolveStopReason($content, $finishReason); + return $this->buildConversationResponse($model, $content, $stopReason, isset($response['usageMetadata']['promptTokenCount']) ? (int) $response['usageMetadata']['promptTokenCount'] : null, isset($response['usageMetadata']['candidatesTokenCount']) ? (int) $response['usageMetadata']['candidatesTokenCount'] : null); + } + /** + * @param list $messages canonical messages + * @return list>}> + */ + private function canonicalMessagesToGoogle(array $messages) : array + { + // Google correlates tool results to tool calls by function name, not + // id, so build an id => name map from all tool_use blocks first. + $toolUseNamesById = $this->buildToolUseNameLookup($messages); + $contents = []; + foreach ($messages as $message) { + $role = $message['role']; + if ($role === 'assistant') { + $parts = $this->assistantBlocksToGoogleParts($message['content']); + $contents[] = ['role' => 'model', 'parts' => $parts]; + continue; + } + if ($role === 'tool') { + $parts = $this->toolResultBlocksToGoogleParts($message['content'], $toolUseNamesById); + $contents[] = ['role' => 'user', 'parts' => $parts]; + continue; + } + // 'user' and any unknown role fold into a user message of text parts. + $parts = []; + foreach ($message['content'] as $block) { + if (($block['type'] ?? null) === 'text' && is_string($block['text'] ?? null)) { + $parts[] = ['text' => $block['text']]; + } + } + $contents[] = ['role' => 'user', 'parts' => $parts]; + } + return $contents; + } + /** + * Builds the tool_use id => name lookup used to resolve functionResponse + * names, since the canonical tool_result only carries the tool_use_id. + * + * @param list $messages + * @return array + */ + private function buildToolUseNameLookup(array $messages) : array + { + $lookup = []; + foreach ($messages as $message) { + foreach (CanonicalMessage::toolUseBlocks($message['content']) as $block) { + $lookup[$block['id']] = $block['name']; + } + } + return $lookup; + } + /** + * @param list $blocks canonical assistant content blocks + * @return list> + */ + private function assistantBlocksToGoogleParts(array $blocks) : array + { + $parts = []; + foreach ($blocks as $block) { + $type = $block['type'] ?? null; + if ($type === 'text' && is_string($block['text'] ?? null)) { + $parts[] = ['text' => $block['text']]; + continue; + } + if ($type === 'tool_use') { + $name = $block['name'] ?? null; + $input = $block['input'] ?? []; + if (!is_string($name) || !is_array($input)) { + continue; + } + // functionCall.args must be a JSON object even when empty; + // json_decode collapses `{}` to `[]`, so coerce empty inputs + // back to stdClass so json_encode produces `{}` again. + $parts[] = ['functionCall' => ['name' => $name, 'args' => $input === [] ? new \stdClass() : $input]]; + } + } + return $parts; + } + /** + * Translates canonical tool_result blocks into Google functionResponse + * parts, resolving each result's function name from the id => name lookup. + * + * @param list $blocks canonical tool_result blocks + * @param array $toolUseNamesById + * @return list> + */ + private function toolResultBlocksToGoogleParts(array $blocks, array $toolUseNamesById) : array + { + $parts = []; + foreach ($blocks as $block) { + if (($block['type'] ?? null) !== 'tool_result') { + continue; + } + $toolUseId = $block['tool_use_id'] ?? null; + // Google correlates by name; recover it from the prior tool_use + // block, falling back to a stable placeholder when unknown. + $name = is_string($toolUseId) && isset($toolUseNamesById[$toolUseId]) ? $toolUseNamesById[$toolUseId] : 'unknown'; + $structured = is_array($block['structuredContent'] ?? null) ? $block['structuredContent'] : null; + $mcpContent = is_array($block['content'] ?? null) ? $block['content'] : []; + $parts[] = ['functionResponse' => ['name' => $name, 'response' => $this->toolResultResponseObject($structured, $mcpContent, !empty($block['is_error']))]]; + } + return $parts; + } + /** + * Google expects functionResponse.response to be a JSON object. Structured + * output is used verbatim when present; otherwise the MCP content blocks + * are folded into a single {content: ...} object, with non-text blocks + * JSON-stringified so their data still reaches the model. An error flag is + * surfaced as {error: true}. + * + * @param array|null $structured + * @param list> $mcpContent + * @return array + */ + private function toolResultResponseObject(?array $structured, array $mcpContent, bool $isError) : array + { + if ($structured !== null) { + $response = $structured; + } else { + $texts = []; + foreach ($mcpContent as $block) { + if (($block['type'] ?? null) === 'text' && is_string($block['text'] ?? null)) { + $texts[] = $block['text']; + continue; + } + $serialised = json_encode($block); + if ($serialised !== \false) { + $texts[] = $serialised; + } + } + $response = ['content' => implode("\n", $texts)]; + } + if ($isError) { + $response['error'] = \true; + } + return $response; + } + /** + * @param list $tools + * @return list}>}>|null + */ + private function toolCatalogToGoogle(array $tools) : ?array + { + if ($tools === []) { + return null; + } + $declarations = []; + foreach ($tools as $tool) { + $declarations[] = ['name' => $tool['name'], 'description' => $tool['description'], 'parameters' => $this->googleParameterSchema($tool['inputSchema'])]; + } + return [['functionDeclarations' => $declarations]]; + } + /** + * Google's function-declaration parameters accept only a restricted subset + * of the OpenAPI 3.0 schema and reject standard JSON Schema keywords such as + * `additionalProperties` or `$schema` — and not just at the top level: it + * rejects them at every nesting depth (e.g. inside `properties[...]`). The + * shared {@see toToolParametersObjectSchema} only normalises the top level, + * which is all OpenAI needs, so Google layers a recursive strip on top. + * + * The removed keywords are validation hints only; the tool server + * re-validates arguments when the tool actually runs, so dropping them keeps + * the tool callable without loosening real enforcement. + * + * @param array $schema + * @return array + */ + private function googleParameterSchema(array $schema) : array + { + return $this->stripGoogleUnsupportedKeywords($this->toToolParametersObjectSchema($schema)); + } + /** + * Recursively removes JSON Schema keywords Google rejects anywhere in the + * tree, walking into `properties`, `items`, and `anyOf`/`oneOf`/`allOf` + * branches so nested object/array schemas are cleaned too. + * + * @param array $schema + * @return array + */ + private function stripGoogleUnsupportedKeywords(array $schema) : array + { + unset($schema['additionalProperties'], $schema['$schema'], $schema['$id'], $schema['$ref'], $schema['$defs'], $schema['definitions'], $schema['const'], $schema['patternProperties'], $schema['unevaluatedProperties']); + foreach ($schema as $key => $value) { + if (is_array($value)) { + $schema[$key] = $this->stripGoogleUnsupportedKeywords($value); + } + } + return $schema; + } + /** + * @param list $parts Google candidate content parts + * @return list canonical assistant content blocks + */ + private function googlePartsToCanonical(array $parts) : array + { + $canonical = []; + foreach ($parts as $index => $part) { + if (!is_array($part)) { + continue; + } + if (is_string($part['text'] ?? null) && $part['text'] !== '') { + $canonical[] = ['type' => 'text', 'text' => $part['text']]; + continue; + } + if (is_array($part['functionCall'] ?? null)) { + $call = $part['functionCall']; + $name = is_string($call['name'] ?? null) ? $call['name'] : ''; + $args = is_array($call['args'] ?? null) ? $call['args'] : []; + $normalizedInput = array_filter($args, function ($key) { + return is_string($key); + }, \ARRAY_FILTER_USE_KEY); + // Google supplies no id; synthesize a deterministic one so the + // caller can echo it back and the id => name resolver can + // recover the function name on the next turn. + $canonical[] = ['type' => 'tool_use', 'id' => sprintf('google-%d-%s', $index, $name), 'name' => $name, 'input' => $normalizedInput]; + } + } + return $canonical; + } + /** + * Any functionCall in the turn means the model wants a tool run; otherwise + * the Google finishReason maps onto the canonical stop reasons, passing + * unrecognised values through. + * + * @param list $content canonical assistant content blocks + */ + private function resolveStopReason(array $content, string $finishReason) : string + { + foreach ($content as $block) { + if (($block['type'] ?? null) === 'tool_use') { + return AIConversationResponse::STOP_TOOL_USE; + } + } + switch ($finishReason) { + case 'STOP': + return AIConversationResponse::STOP_END_TURN; + case 'MAX_TOKENS': + return AIConversationResponse::STOP_MAX_TOKENS; + case 'SAFETY': + case 'RECITATION': + return AIConversationResponse::STOP_GUARDRAIL_INTERVENED; + default: + return $finishReason; + } + } + private function getEndpointUrlForModel(string $model) : string + { + return sprintf('https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent', $model); + } +} diff --git a/app/plugins/AIProviders/Provider/OpenAI.php b/app/plugins/AIProviders/Provider/OpenAI.php new file mode 100644 index 000000000..59c53d1a3 --- /dev/null +++ b/app/plugins/AIProviders/Provider/OpenAI.php @@ -0,0 +1,84 @@ + $configuration + */ + public function complete(AIRequest $request, array $configuration) : AIProviderResponse + { + return $this->completeChatCompletion($request, $this->getEndpointUrl($configuration), ['Authorization' => 'Bearer ' . $this->getApiKey($configuration)]); + } + /** + * Validates credentials and reachability with a cheap models listing + * (`GET /v1/models`) instead of spending generation tokens. + * + * @param array $configuration + */ + public function verifyConnection(array $configuration) : void + { + $this->sendGetRequest($this->openAiCompatibleModelsEndpoint($this->getEndpointUrl($configuration)), ['Authorization' => 'Bearer ' . $this->getApiKey($configuration)]); + } + public function supportsConversations() : bool + { + return \true; + } + /** + * gpt-5 reasoning models expose thinking through `reasoning_effort` rather + * than a thinking budget: "none" disables reasoning for fast, cheap instant + * answers; "medium" turns it on for better reasoning. (Supported because + * gpt-5.4-mini is post-gpt-5.1, where "none" became valid.) + */ + protected function getExtraChatCompletionPayload(AIRequest $request) : array + { + return ['reasoning_effort' => $this->wantsThinking($request) ? 'medium' : 'none']; + } + /** + * gpt-5 reasoning models require `max_completion_tokens` and reject a custom + * `temperature` (only the default is allowed), so omit it. + */ + protected function chatCompletionTokenLimitField() : string + { + return 'max_completion_tokens'; + } + protected function chatCompletionSupportsTemperature() : bool + { + return \false; + } + /** + * Runs one conversational round-trip against the OpenAI Chat Completions API. + * + * @see https://platform.openai.com/docs/api-reference/chat/create + * @param array $configuration + */ + public function converse(AIConversationRequest $request, array $configuration) : AIConversationResponse + { + return $this->converseChatCompletion($request, $this->getEndpointUrl($configuration), ['Authorization' => 'Bearer ' . $this->getApiKey($configuration)]); + } +} diff --git a/app/plugins/AIProviders/README.md b/app/plugins/AIProviders/README.md new file mode 100644 index 000000000..1aa5bac8d --- /dev/null +++ b/app/plugins/AIProviders/README.md @@ -0,0 +1,156 @@ +# AIProviders + +Configure AI provider connections and default model settings used by Matomo AI features. + +Built-in providers: Anthropic, OpenAI, Google, AWS Bedrock, and a generic custom provider for OpenAI-compatible endpoints. + +## Configuration + +Settings are managed from **Administration > System > AI Providers**. + +The plugin stores the default provider, default capability level, and provider connection settings as Matomo system settings (not shown on the generic plugin settings page). API keys are only returned to the administration UI as masked state, not as secret values. + +On a managed environment, the default provider is forced and locked via the `[AIProviders] defaultProvider` config setting. The AI Providers settings page and its admin menu entry are hidden entirely. + +### Custom provider and local LLM servers + +The generic custom provider talks to any **OpenAI-compatible** Chat Completions API. This includes hosted OpenAI-compatible services as well as local LLM servers such as Ollama, LM Studio, llama.cpp (`llama-server`), vLLM and LocalAI, which all expose the same `/v1/chat/completions` wire format. + +Two things matter when configuring it: + +- **API base URL.** Enter the URL up to and including the OpenAI-compatible API root. Most often, that is the `/v1` path. Matomo appends `/chat/completions` itself, so do not include it. Examples: + - Ollama: `http://localhost:11434/v1` + - LM Studio: `http://localhost:1234/v1` + - vLLM / llama.cpp: `http://localhost:8000/v1` + + +- **API key.** Optional for the custom provider. Many local servers run without authentication, so the key may be left blank. + +- **Model.** The "test connection" action probes `GET {base}/models` and populates the model picker from the result. Pick the model to use. The custom provider has **no built-in default model**. + +### Managed credentials + +Provider connection settings can also be supplied through namespaced DI values, the `[AIProviders]` config section, or environment variables instead of the administration UI. Per field, managed values win over the database value: + +```php +AIProviders.openaiApiKey +``` + +```ini +[AIProviders] +openaiApiKey = "..." ; or env MATOMO_AIPROVIDERS_OPENAI_API_KEY +custom-providerApiKey = "..." ; or env MATOMO_AIPROVIDERS_CUSTOM_PROVIDER_API_KEY +custom-providerEndpointUrl = "..." ; or env MATOMO_AIPROVIDERS_CUSTOM_PROVIDER_ENDPOINT_URL +``` + +`EndpointUrl` only applies to the providers that have an endpoint field at all — the custom provider's base URL and AWS Bedrock's region. The fixed hosted providers (OpenAI, Anthropic, Google) always talk to their own API, so a value supplied for them is ignored. + +Credentials supplied this way never appear in the UI as secret values and cannot be edited or removed there. + +A managed API key is bound to the endpoint it belongs to. For a provider whose endpoint field is a free-form URL (the custom provider), supply `EndpointUrl` together with `ApiKey`: such a key is only ever sent to the endpoint supplied alongside it, and an endpoint stored on the instance is ignored. Without a managed endpoint the provider has no destination and reports as not connected, so do not set `ApiKey` for a provider whose endpoint the instance should choose itself. + +Saving or testing a different endpoint is then rejected with an error naming the config key to set instead. Only the endpoint is bound this way, not the key: a connection test may still submit an API key of its own, which is then the one sent to the managed endpoint. Providers that expand the field into a host they control, such as AWS Bedrock with its region, are unaffected by the *key* binding. + +Supplying `EndpointUrl` on its own — without an API key — locks the endpoint field the same way, for the custom provider and for Bedrock's region alike: the supplied value is the one used, and saving or testing a different one is rejected rather than silently discarded, so a connection test can never report a pairing that a save would refuse to store. An endpoint the instance had configured before stays in the database untouched and takes effect again once the supplied value is removed. + +In a multi-tenant setup the `config.ini.php` is scoped to each tenant, so the `[AIProviders]` section is the natural place to give each tenant its own provider credentials and forced default. Environment variables are process-wide and shared across tenants, so prefer the config file when the value must differ per tenant. + +### Restricted providers and the provider selection allowlist + +A managed environment can demote providers to *restricted* (non-selectable) in the `AIProviders.filterAIProviders` event via `AIProvidersList::setSelectable()`. Restricted providers are hidden from every admin surface and can never become the default, but stay registered for completions. + +Plugins listed in the allowlist may target a specific provider and model per request even though the default provider is forced. + +```ini +[AIProviders] +defaultProvider = "openai" +providerSelectionAllowlist[] = "ExamplePlugin" +``` + + +## Usage from other plugins + +To use a provider from another plugin, call the AIProvider service like so: + +```php +use Piwik\Container\StaticContainer; +use Piwik\Plugins\AIProviders\AIRequest; +use Piwik\Plugins\AIProviders\AIProviderService; + +$service = StaticContainer::get(AIProviderService::class); +$response = $service->complete(new AIRequest('why is the sky blue, answer in 7 words', 'YourPlugin')); +$text = $response->getText(); +``` + +When a managed environment forces a provider from configuration, the service also ignores caller-provided provider and model overrides unless the calling plugin is on the `providerSelectionAllowlist` (see above), in which case its requested provider and model are honoured. + +`AIProviderResponse` returns the generated text plus request metadata. `toArray()` returns: + +```php +[ + 'providerId' => 'openai', // provider identifier used for the request + 'providerName' => 'OpenAI', // human-readable provider name + 'model' => 'gpt-4.1-mini', // model used + 'text' => 'Generated response text.', + 'inputTokens' => 42, // input/prompt tokens reported by the provider, or null + 'outputTokens' => 12, // output/completion tokens reported by the provider, or null + 'reasoningLevel' => 'none', // reasoning level used + 'webSearchEnabled' => false, // whether provider-side web search was used + 'executionTimeMs' => 1234, // total request time in milliseconds, including retries, or null + 'stopReason' => 'stop', // provider stop reason, if available, or null +] +``` + +### JSON mode + +For structured output, call `withJsonResponse()` and read the decoded object with `getJsonData()`: + +```php +$response = $service->complete( + (new AIRequest($prompt, 'Goals'))->withJsonResponse() +); +$data = $response->getJsonData(); // array, or null if the model did not return valid JSON +``` + +Each provider asks for JSON the best way it can: `response_format` (OpenAI-compatible), `responseMimeType` (Google). And AIProviders always adds a system instruction to return a single JSON object, so it works for providers without a native option (for example Anthropic) too. The model can still occasionally return invalid JSON, so always handle a `null` from `getJsonData()`. + +## Conversations (multi-turn, tool calling) + +`complete()` is prompt-in/text-out. When your plugin maintains a back-and-forth conversation and dispatches tool calls itself (as AskMatomo does), use `AIConversationRequest` with `AIProviderService::converse()` instead. The service resolves the provider exactly like `complete()` (forced provider, allowlisted caller selection, then the configured default) and returns one assistant turn per call. + +```php +use Piwik\Container\StaticContainer; +use Piwik\Plugins\AIProviders\AIConversationRequest; +use Piwik\Plugins\AIProviders\AIConversationResponse; +use Piwik\Plugins\AIProviders\AIProviderService; + +$service = StaticContainer::get(AIProviderService::class); + +$messages = [ + ['role' => 'user', 'content' => [['type' => 'text', 'text' => 'How many visits yesterday?']]], +]; + +$response = $service->converse( + (new AIConversationRequest($messages, 'YourPlugin')) + ->withSystemPrompt($systemPrompt) + ->withTools($toolCatalog) // optional; MCP-aligned shape, see below + ->withMaxTokens(2048) +); + +if ($response->getStopReason() === AIConversationResponse::STOP_TOOL_USE) { + // Run the requested tool_use blocks, append this turn and the tool + // results to $messages, and call converse() again. +} +``` + +Messages, tools, and the assistant's content all use one **provider-agnostic canonical shape** documented in [`CanonicalMessage.php`](CanonicalMessage.php). Each provider translates that shape to and from its own wire format inside `converse()`. Running tools and appending their results to the history for the next call is the caller's responsibility. The service performs a single round-trip per call. + +`AIConversationResponse` exposes the assistant content blocks (`getContent()`), the stop reason mapped onto the `STOP_*` constants (`getStopReason()`), a text convenience (`getText()`), token usage, and the raw decoded provider response. Treat an unknown stop reason like `STOP_END_TURN`. Unlike `complete()`, an empty text turn is valid here: a turn may consist solely of `tool_use` blocks. + +Not every provider supports conversations. Gate conversational features on availability. + +```php +if (!$service->canConverse()) { + // Hide or disable the feature. +} +``` diff --git a/app/plugins/AIProviders/templates/index.twig b/app/plugins/AIProviders/templates/index.twig new file mode 100644 index 000000000..535ef3c36 --- /dev/null +++ b/app/plugins/AIProviders/templates/index.twig @@ -0,0 +1,7 @@ +{% extends 'admin.twig' %} + +{% set title %}{{ 'AIProviders_MenuTitle'|translate }}{% endset %} + +{% block content %} +
+{% endblock %} diff --git a/app/plugins/AIProviders/vue/dist/AIProviders.css b/app/plugins/AIProviders/vue/dist/AIProviders.css new file mode 100644 index 000000000..5bc74c8fc --- /dev/null +++ b/app/plugins/AIProviders/vue/dist/AIProviders.css @@ -0,0 +1 @@ +.ai-providers-card{display:flex;flex-direction:column;background:var(--theme-color-background-contrast);border:1px solid var(--ai-providers-border);border-radius:6px;cursor:pointer;transition:border-color .12s ease}.ai-providers-card:hover:not(.is-not-usable):not(.is-selected){border-color:var(--ai-providers-border-strong)}.ai-providers-card.is-selected{border-color:var(--ai-providers-accent);box-shadow:0 0 0 1px var(--ai-providers-accent) inset;background:var(--theme-color-background-tinyContrast)}.ai-providers-card.is-not-usable{cursor:default}.ai-providers-card:active,.ai-providers-card:focus,.ai-providers-card:focus-visible{outline:none}.ai-providers-card .matomo-form-field{border:0;margin:0}.ai-providers-card .matomo-form-field>.col{padding-left:0!important;padding-right:0!important}.ai-providers-card .input-field{margin:0}.ai-providers-card .input-field.col>label,.ai-providers-card .input-field>label,.ai-providers-card .matomo-field-select>.select-wrapper+label,.ai-providers-card .matomo-field-select>label{left:0!important}.ai-providers-card .input-field>input{padding-left:0;margin-left:0;margin-bottom:0;width:100%;box-sizing:border-box}.ai-providers-card .matomo-form-field.ai-providers-endpoint-field,.ai-providers-card .matomo-form-field.ai-providers-fips-field{margin-bottom:16px}.ai-providers-card .matomo-form-field.ai-providers-fips-field{margin-top:-16px}.ai-providers-card .matomo-form-field.ai-providers-fips-field .checkbox label{display:inline-flex;align-items:center}.ai-providers-card .matomo-form-field.ai-providers-fips-field .checkbox [type=checkbox]+span{height:auto;line-height:1.5}.ai-providers-card-inner{display:flex;flex-direction:column;flex:1;gap:16px;padding:16px}.ai-providers-card-default{flex:none;padding:3px 8px;background-color:var(--ai-providers-accent);color:var(--theme-color-brand-contrast);border-radius:3px;font-size:10px;font-weight:700;line-height:1.5;letter-spacing:.04em;text-transform:uppercase}.ai-providers-card-header{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px 12px;min-height:24px;margin-bottom:8px}.ai-providers-card-name{flex:1 1 auto;min-width:0;color:var(--ai-providers-heading);font-weight:600;font-size:15px}.ai-providers-card-description{color:var(--ai-providers-text-muted);font-size:13px;line-height:1.5;margin:0}.ai-providers-refresh-models{display:inline-flex;align-items:center;gap:6px;margin-top:4px;padding:0;font-size:12px}.ai-providers-refresh-models[disabled]{pointer-events:none;cursor:not-allowed;color:var(--theme-color-text-on-disabled)}.ai-providers-refresh-models .icon-reload{font-size:12px}.ai-providers-card-model-help{font-size:12px}.ai-providers-card-model-help,.ai-providers-card-status{color:var(--ai-providers-text-muted);line-height:1.5;margin:0}.ai-providers-card-status{display:flex;align-items:center;gap:8px;font-size:13px}.ai-providers-card-status.is-connected,.ai-providers-card-status.is-connected .ai-providers-status-icon{color:var(--ai-providers-accent)}.ai-providers-status-icon{font-size:14px;line-height:1;color:var(--ai-providers-border-strong);flex:none}.ai-providers-card-actions{display:flex;flex-direction:column;align-items:stretch;justify-content:flex-start;gap:12px;margin-top:auto;padding-top:16px}.ai-providers-card-actions .btn,.ai-providers-card-actions .btn-flat{white-space:nowrap}.ai-providers-card-actions .btn-flat[disabled]{pointer-events:none;cursor:not-allowed;color:var(--theme-color-text-on-disabled)}.ai-providers-page{--ai-providers-border:var(--theme-color-border-light);--ai-providers-border-strong:var(--theme-color-border);--ai-providers-accent:var(--theme-color-brand);--ai-providers-text-muted:var(--theme-color-text-light);--ai-providers-heading:var(--theme-color-headline-alternative)}.ai-providers-page h2,.ai-providers-page h3,.ai-providers-page h4{color:var(--ai-providers-heading);margin:0;padding:0}.ai-providers-page-header{margin-bottom:24px}.ai-providers-page-title{font-size:22px;line-height:1.3}.ai-providers-page-subtitle{color:var(--ai-providers-text-muted);font-size:14px;line-height:1.5;margin:4px 0 0}.ai-providers-defaults-title{font-size:18px;line-height:1.4;margin-bottom:24px!important}.ai-providers-subsection-title{font-size:15px;font-weight:600;line-height:1.4}.ai-providers-section+.ai-providers-section{margin-top:24px;padding-top:24px;border-top:1px solid var(--ai-providers-border)}.ai-providers-section-help{color:var(--ai-providers-text-muted);margin:4px 0 16px}.ai-providers-capability-cards,.ai-providers-cards{display:grid;gap:16px}.ai-providers-section{container-type:inline-size}.ai-providers-cards{grid-template-columns:repeat(auto-fit,minmax(280px,1fr));margin-top:16px}.ai-providers-capability-cards{grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}@container (min-width: 620px){.ai-providers-cards{grid-template-columns:repeat(2,1fr)}}@container (min-width: 960px){.ai-providers-cards{grid-template-columns:repeat(3,1fr)}}.ai-providers-default-warning{margin-top:16px}.ai-providers-capability-card{display:flex;flex-direction:column;padding:16px;background:var(--theme-color-background-contrast);border:1px solid var(--ai-providers-border);border-radius:6px;cursor:pointer;transition:border-color .12s ease,box-shadow .12s ease,background-color .12s ease}.ai-providers-capability-card:hover{border-color:var(--ai-providers-border-strong)}.ai-providers-capability-card.is-selected{border-color:var(--ai-providers-accent);box-shadow:0 0 0 1px var(--ai-providers-accent) inset;background:var(--theme-color-background-tinyContrast)}.ai-providers-capability-header{margin-bottom:8px}.ai-providers-capability-label{color:var(--ai-providers-heading);font-weight:600;font-size:15px}.ai-providers-capability-description{color:var(--ai-providers-text-muted);font-size:13px;line-height:1.5}.ai-providers-content{position:relative}.ai-providers-unsaved-changes{position:absolute;top:16px;right:16px;padding:5px 14px;background:rgba(245,124,0,.2);border-radius:3px;color:#f57c00;font-size:12px;font-weight:600;letter-spacing:.01em;line-height:1.5;visibility:hidden;opacity:0;transform:translateY(-4px);transition:opacity .15s ease,transform .15s ease}.ai-providers-unsaved-changes.is-visible{visibility:visible;opacity:1;transform:translateY(0)}[data-theme-mode=dark] .ai-providers-unsaved-changes{background:rgba(245,124,0,.18);color:#f5a557}.ai-providers-footer{display:flex;flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:12px;margin-top:24px}.ai-providers-footer .btn-outline[disabled]{border-color:transparent} \ No newline at end of file diff --git a/app/plugins/AIProviders/vue/dist/AIProviders.umd.js b/app/plugins/AIProviders/vue/dist/AIProviders.umd.js new file mode 100644 index 000000000..58d6956fa --- /dev/null +++ b/app/plugins/AIProviders/vue/dist/AIProviders.umd.js @@ -0,0 +1,811 @@ +(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["AIProviders"] = factory(require("CoreHome"), require("vue"), require("CorePluginsAdmin")); + else + root["AIProviders"] = 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/AIProviders/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__; + +/***/ }), + +/***/ "2a82": +/***/ (function(module, __webpack_exports__, __webpack_require__) { + +"use strict"; +/* harmony import */ var _node_modules_vue_cli_service_node_modules_mini_css_extract_plugin_dist_loader_js_ref_11_oneOf_1_0_node_modules_vue_cli_service_node_modules_css_loader_dist_cjs_js_ref_11_oneOf_1_1_node_modules_vue_cli_service_node_modules_vue_loader_v16_dist_stylePostLoader_js_node_modules_postcss_loader_src_index_js_ref_11_oneOf_1_2_node_modules_less_loader_dist_cjs_js_ref_11_oneOf_1_3_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_index_js_ref_1_1_ManageAIProviders_vue_vue_type_style_index_0_id_464239d2_lang_less__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("4d9d"); +/* harmony import */ var _node_modules_vue_cli_service_node_modules_mini_css_extract_plugin_dist_loader_js_ref_11_oneOf_1_0_node_modules_vue_cli_service_node_modules_css_loader_dist_cjs_js_ref_11_oneOf_1_1_node_modules_vue_cli_service_node_modules_vue_loader_v16_dist_stylePostLoader_js_node_modules_postcss_loader_src_index_js_ref_11_oneOf_1_2_node_modules_less_loader_dist_cjs_js_ref_11_oneOf_1_3_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_index_js_ref_1_1_ManageAIProviders_vue_vue_type_style_index_0_id_464239d2_lang_less__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_node_modules_vue_cli_service_node_modules_mini_css_extract_plugin_dist_loader_js_ref_11_oneOf_1_0_node_modules_vue_cli_service_node_modules_css_loader_dist_cjs_js_ref_11_oneOf_1_1_node_modules_vue_cli_service_node_modules_vue_loader_v16_dist_stylePostLoader_js_node_modules_postcss_loader_src_index_js_ref_11_oneOf_1_2_node_modules_less_loader_dist_cjs_js_ref_11_oneOf_1_3_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_index_js_ref_1_1_ManageAIProviders_vue_vue_type_style_index_0_id_464239d2_lang_less__WEBPACK_IMPORTED_MODULE_0__); +/* unused harmony reexport * */ + + +/***/ }), + +/***/ "4d9d": +/***/ (function(module, exports, __webpack_require__) { + +// extracted by mini-css-extract-plugin + +/***/ }), + +/***/ "8bbf": +/***/ (function(module, exports) { + +module.exports = __WEBPACK_EXTERNAL_MODULE__8bbf__; + +/***/ }), + +/***/ "9c8b": +/***/ (function(module, __webpack_exports__, __webpack_require__) { + +"use strict"; +/* harmony import */ var _node_modules_vue_cli_service_node_modules_mini_css_extract_plugin_dist_loader_js_ref_11_oneOf_1_0_node_modules_vue_cli_service_node_modules_css_loader_dist_cjs_js_ref_11_oneOf_1_1_node_modules_vue_cli_service_node_modules_vue_loader_v16_dist_stylePostLoader_js_node_modules_postcss_loader_src_index_js_ref_11_oneOf_1_2_node_modules_less_loader_dist_cjs_js_ref_11_oneOf_1_3_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_index_js_ref_1_1_ProviderCard_vue_vue_type_style_index_0_id_30b60d9a_lang_less__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("e225"); +/* harmony import */ var _node_modules_vue_cli_service_node_modules_mini_css_extract_plugin_dist_loader_js_ref_11_oneOf_1_0_node_modules_vue_cli_service_node_modules_css_loader_dist_cjs_js_ref_11_oneOf_1_1_node_modules_vue_cli_service_node_modules_vue_loader_v16_dist_stylePostLoader_js_node_modules_postcss_loader_src_index_js_ref_11_oneOf_1_2_node_modules_less_loader_dist_cjs_js_ref_11_oneOf_1_3_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_index_js_ref_1_1_ProviderCard_vue_vue_type_style_index_0_id_30b60d9a_lang_less__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_node_modules_vue_cli_service_node_modules_mini_css_extract_plugin_dist_loader_js_ref_11_oneOf_1_0_node_modules_vue_cli_service_node_modules_css_loader_dist_cjs_js_ref_11_oneOf_1_1_node_modules_vue_cli_service_node_modules_vue_loader_v16_dist_stylePostLoader_js_node_modules_postcss_loader_src_index_js_ref_11_oneOf_1_2_node_modules_less_loader_dist_cjs_js_ref_11_oneOf_1_3_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_index_js_ref_1_1_ProviderCard_vue_vue_type_style_index_0_id_30b60d9a_lang_less__WEBPACK_IMPORTED_MODULE_0__); +/* unused harmony reexport * */ + + +/***/ }), + +/***/ "a5a2": +/***/ (function(module, exports) { + +module.exports = __WEBPACK_EXTERNAL_MODULE_a5a2__; + +/***/ }), + +/***/ "e225": +/***/ (function(module, exports, __webpack_require__) { + +// extracted by mini-css-extract-plugin + +/***/ }), + +/***/ "fae3": +/***/ (function(module, __webpack_exports__, __webpack_require__) { + +"use strict"; +// ESM COMPAT FLAG +__webpack_require__.r(__webpack_exports__); + +// EXPORTS +__webpack_require__.d(__webpack_exports__, "ManageAIProviders", function() { return /* reexport */ ManageAIProviders; }); + +// 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"); + +// EXTERNAL MODULE: external "CoreHome" +var external_CoreHome_ = __webpack_require__("19dc"); + +// EXTERNAL MODULE: external "CorePluginsAdmin" +var external_CorePluginsAdmin_ = __webpack_require__("a5a2"); + +// 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/AIProviders/vue/src/components/ProviderCard.vue?vue&type=script&setup=true&lang=ts + + +const _hoisted_1 = ["aria-checked", "aria-disabled", "tabindex"]; +const _hoisted_2 = { + class: "ai-providers-card-inner" +}; +const _hoisted_3 = { + class: "ai-providers-card-heading" +}; +const _hoisted_4 = { + class: "ai-providers-card-header" +}; +const _hoisted_5 = { + class: "ai-providers-card-name" +}; +const _hoisted_6 = { + key: 0, + class: "ai-providers-card-default" +}; +const _hoisted_7 = { + class: "ai-providers-card-description" +}; +const _hoisted_8 = { + key: 2, + class: "ai-providers-card-model" +}; +const _hoisted_9 = { + key: 1, + class: "ai-providers-card-model-help" +}; +const _hoisted_10 = ["disabled", "title"]; +const _hoisted_11 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { + "aria-hidden": "true", + class: "icon icon-reload" +}, null, -1); +const _hoisted_12 = { + class: "ai-providers-card-actions" +}; +const _hoisted_13 = ["disabled"]; +const _hoisted_14 = ["disabled"]; + + + +/* harmony default export */ var ProviderCardvue_type_script_setup_true_lang_ts = (/*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + __name: 'ProviderCard', + props: { + provider: null, + configuration: null, + availableModels: null, + selected: { + type: Boolean + }, + usableAsDefault: { + type: Boolean + }, + canEdit: { + type: Boolean + }, + isTesting: { + type: Boolean + }, + isDisconnecting: { + type: Boolean + } + }, + emits: ["select", "update:apiKey", "update:endpointUrl", "update:useFipsEndpoint", "update:model", "test", "disconnect"], + setup(__props, { + emit + }) { + const props = __props; + /* eslint-disable func-call-spacing, no-spaced-func */ + /* eslint-enable func-call-spacing, no-spaced-func */ + const hasPendingKey = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + var _props$configuration$, _props$configuration; + return ((_props$configuration$ = (_props$configuration = props.configuration) === null || _props$configuration === void 0 ? void 0 : _props$configuration.apiKey) !== null && _props$configuration$ !== void 0 ? _props$configuration$ : '') !== ''; + }); + const hasEndpointUrl = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + var _props$configuration$2, _props$configuration2; + return ((_props$configuration$2 = (_props$configuration2 = props.configuration) === null || _props$configuration2 === void 0 ? void 0 : _props$configuration2.endpointUrl) !== null && _props$configuration$2 !== void 0 ? _props$configuration$2 : '') !== ''; + }); + const hasKey = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => hasPendingKey.value || props.provider.configuration.hasApiKey); + // Providers with a default endpoint (e.g. AWS Bedrock) only need the key; + // fully custom servers need the URL and commonly run without authentication. + const canTest = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + if (!props.provider.supportsCustomEndpoint || props.provider.defaultEndpointUrl) { + return hasKey.value; + } + return hasEndpointUrl.value; + }); + const modelOptions = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + const options = {}; + props.availableModels.forEach(model => { + options[model] = model; + }); + return options; + }); + function selectProvider() { + if (props.usableAsDefault) { + emit('select'); + } + } + return (_ctx, _cache) => { + var _props$configuration3, _props$configuration4, _props$configuration5, _props$configuration6; + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", { + "aria-checked": __props.selected, + "aria-disabled": !__props.usableAsDefault, + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])([{ + 'is-selected': __props.selected, + 'is-not-usable': !__props.usableAsDefault + }, "ai-providers-card"]), + role: "radio", + tabindex: __props.usableAsDefault ? 0 : -1, + onClick: _cache[7] || (_cache[7] = $event => selectProvider()), + onKeydown: [_cache[8] || (_cache[8] = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withKeys"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withModifiers"])($event => selectProvider(), ["prevent"]), ["enter"])), _cache[9] || (_cache[9] = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withKeys"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withModifiers"])($event => selectProvider(), ["prevent"]), ["space"]))] + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_2, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_3, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_4, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", _hoisted_5, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(__props.provider.name), 1), __props.selected ? (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"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_DefaultBadge')), 1)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("p", _hoisted_7, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])(__props.provider.description)), 1)]), __props.canEdit ? (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 + }, [__props.provider.supportsCustomEndpoint ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createBlock"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CorePluginsAdmin_["Field"]), { + key: 0, + class: "ai-providers-endpoint-field", + "model-value": (_props$configuration3 = __props.configuration) === null || _props$configuration3 === void 0 ? void 0 : _props$configuration3.endpointUrl, + name: `endpointUrl-${__props.provider.id}`, + title: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])(__props.provider.endpointFieldTitle), + placeholder: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])(__props.provider.endpointFieldPlaceholder), + autocomplete: "off", + "full-width": "", + uicontrol: "text", + "onUpdate:modelValue": _cache[0] || (_cache[0] = $event => emit('update:endpointUrl', `${$event}`)) + }, null, 8, ["model-value", "name", "title", "placeholder"])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), __props.provider.supportsFipsEndpoint ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createBlock"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CorePluginsAdmin_["Field"]), { + key: 1, + class: "ai-providers-fips-field", + "model-value": (_props$configuration4 = __props.configuration) === null || _props$configuration4 === void 0 ? void 0 : _props$configuration4.useFipsEndpoint, + name: `useFipsEndpoint-${__props.provider.id}`, + title: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_BedrockUseFipsEndpoint'), + "full-width": "", + uicontrol: "checkbox", + "onUpdate:modelValue": _cache[1] || (_cache[1] = $event => emit('update:useFipsEndpoint', !!$event)) + }, null, 8, ["model-value", "name", "title"])) : 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"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CorePluginsAdmin_["Field"]), { + "model-value": (_props$configuration5 = __props.configuration) === null || _props$configuration5 === void 0 ? void 0 : _props$configuration5.apiKey, + name: `apiKey-${__props.provider.id}`, + placeholder: __props.provider.configuration.hasApiKey ? Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_ApiKeyAlreadyConfiguredPlaceholder') : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_ApiKeyPlaceholder'), + title: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_ApiKey'), + autocomplete: "new-password", + "full-width": "", + uicontrol: "password", + "onUpdate:modelValue": _cache[2] || (_cache[2] = $event => emit('update:apiKey', `${$event}`)) + }, null, 8, ["model-value", "name", "placeholder", "title"]), [[Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["AutoClearPassword"])]]), __props.provider.supportsCustomEndpoint ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", _hoisted_8, [__props.availableModels.length ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createBlock"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CorePluginsAdmin_["Field"]), { + key: 0, + "model-value": (_props$configuration6 = __props.configuration) === null || _props$configuration6 === void 0 ? void 0 : _props$configuration6.model, + name: `model-${__props.provider.id}`, + title: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_Model'), + options: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(modelOptions), + "full-width": "", + uicontrol: "select", + "onUpdate:modelValue": _cache[3] || (_cache[3] = $event => emit('update:model', `${$event}`)) + }, null, 8, ["model-value", "name", "title", "options"])) : (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("p", _hoisted_9, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_ClickTestConnectionToShowAvailableModels')), 1)), __props.availableModels.length ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("button", { + key: 2, + class: "btn-flat ai-providers-refresh-models", + type: "button", + disabled: __props.isTesting || !Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(canTest), + title: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_RefreshModels'), + onClick: _cache[4] || (_cache[4] = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withModifiers"])($event => emit('test'), ["prevent", "stop"])) + }, [_hoisted_11, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createTextVNode"])(" " + Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_RefreshModels')), 1)], 8, _hoisted_10)) : 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", { + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])([{ + 'is-connected': __props.provider.configuration.isUsable + }, "ai-providers-card-status"]) + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { + "aria-hidden": "true", + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(["icon ai-providers-status-icon", __props.provider.configuration.isUsable ? 'icon-ok' : 'icon-minus']) + }, null, 2), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createTextVNode"])(" " + Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(__props.provider.configuration.isUsable ? Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_StatusConnected') : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_StatusNotConnected')), 1)], 2), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_12, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("button", { + class: "btn btn-outline btn-small", + type: "button", + disabled: __props.isTesting || !Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(canTest), + onClick: _cache[5] || (_cache[5] = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withModifiers"])($event => emit('test'), ["prevent", "stop"])) + }, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(__props.isTesting ? Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_TestingConnection') : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_TestConnection')), 9, _hoisted_13), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("button", { + class: "btn-flat", + type: "button", + disabled: __props.isDisconnecting || !__props.provider.configuration.hasApiKey, + onClick: _cache[6] || (_cache[6] = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withModifiers"])($event => emit('disconnect'), ["prevent", "stop"])) + }, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(__props.isDisconnecting ? Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_Disconnecting') : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_Disconnect')), 9, _hoisted_14)])], 64)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)])], 42, _hoisted_1); + }; + } +})); +// CONCATENATED MODULE: ./plugins/AIProviders/vue/src/components/ProviderCard.vue?vue&type=script&setup=true&lang=ts + +// EXTERNAL MODULE: ./plugins/AIProviders/vue/src/components/ProviderCard.vue?vue&type=style&index=0&id=30b60d9a&lang=less +var ProviderCardvue_type_style_index_0_id_30b60d9a_lang_less = __webpack_require__("9c8b"); + +// CONCATENATED MODULE: ./plugins/AIProviders/vue/src/components/ProviderCard.vue + + + + + +/* harmony default export */ var ProviderCard = (ProviderCardvue_type_script_setup_true_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/AIProviders/vue/src/ManageAIProviders.vue?vue&type=script&setup=true&lang=ts + + +const ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_1 = { + class: "ai-providers-page" +}; +const ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_2 = { + class: "ai-providers-page-header" +}; +const ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_3 = { + class: "ai-providers-page-title" +}; +const ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_4 = { + class: "ai-providers-page-subtitle" +}; +const ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_5 = { + class: "ai-providers" +}; +const ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_6 = { + class: "ai-providers-defaults-title" +}; +const ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_7 = { + class: "ai-providers-section" +}; +const ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_8 = { + class: "ai-providers-subsection-title" +}; +const ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_9 = { + class: "ai-providers-section-help" +}; +const ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_10 = ["aria-label"]; +const ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_11 = { + key: 1, + class: "ai-providers-section" +}; +const ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_12 = { + class: "ai-providers-subsection-title" +}; +const ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_13 = { + class: "ai-providers-section-help" +}; +const ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_14 = ["aria-label"]; +const _hoisted_15 = { + class: "ai-providers-capability-header" +}; +const _hoisted_16 = ["value"]; +const _hoisted_17 = { + class: "ai-providers-capability-label" +}; +const _hoisted_18 = { + key: 0, + class: "ai-providers-capability-description" +}; +const _hoisted_19 = { + key: 2, + class: "ai-providers-footer" +}; +const _hoisted_20 = ["disabled"]; + + + + +/* harmony default export */ var ManageAIProvidersvue_type_script_setup_true_lang_ts = (/*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + __name: 'ManageAIProviders', + setup(__props) { + const settings = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])(null); + const isLoading = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])(false); + const isSaving = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])(false); + const defaultProviderId = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])(''); + const defaultCapabilityLevel = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])(''); + const providerConfigurations = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])({}); + // Models discovered per provider from the last "test connection"/refresh, used + // to populate the model picker. + const availableModels = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])({}); + const testingProviders = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])({}); + const disconnectingProviders = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])({}); + const providers = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + var _settings$value; + return ((_settings$value = settings.value) === null || _settings$value === void 0 ? void 0 : _settings$value.providers) || []; + }); + const hasUsableProvider = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => providers.value.some(provider => provider.configuration.isUsable)); + const canEditCapabilityLevel = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + var _settings$value2; + return !!((_settings$value2 = settings.value) !== null && _settings$value2 !== void 0 && _settings$value2.canEditCapabilityLevel); + }); + const canEditProviderConfiguration = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + var _settings$value3; + return !!((_settings$value3 = settings.value) !== null && _settings$value3 !== void 0 && _settings$value3.canEditProviderConfiguration); + }); + // Snapshot of the saved state, used to detect unsaved changes. + const savedSnapshot = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])(''); + const capabilityLevelOptions = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + var _settings$value4; + const capabilityLevels = ((_settings$value4 = settings.value) === null || _settings$value4 === void 0 ? void 0 : _settings$value4.capabilityLevels) || {}; + return Object.entries(capabilityLevels).map(([id, keys]) => ({ + id, + label: Object(external_CoreHome_["translate"])(keys.label), + description: keys.description ? Object(external_CoreHome_["translate"])(keys.description) : '' + })); + }); + // Serializes everything the user can edit, so it can be compared to the saved snapshot. + function serializeEditableState() { + return JSON.stringify({ + defaultProviderId: defaultProviderId.value, + defaultCapabilityLevel: defaultCapabilityLevel.value, + providerConfigurations: providerConfigurations.value + }); + } + const hasUnsavedChanges = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => !!settings.value && serializeEditableState() !== savedSnapshot.value); + /** + * Applies the given settings to the component state. + * @param nextSettings + */ + function applySettings(nextSettings) { + settings.value = nextSettings; + defaultProviderId.value = nextSettings.defaultProviderId; + defaultCapabilityLevel.value = nextSettings.defaultCapabilityLevel; + const nextProviderConfigurations = {}; + nextSettings.providers.forEach(provider => { + const savedModel = provider.configuration.model || ''; + nextProviderConfigurations[provider.id] = { + apiKey: '', + endpointUrl: provider.configuration.endpointUrl || '', + model: savedModel, + useFipsEndpoint: provider.configuration.useFipsEndpoint || false + }; + }); + providerConfigurations.value = nextProviderConfigurations; + availableModels.value = {}; + // Capture the freshly applied state as the baseline for unsaved-change detection. + savedSnapshot.value = serializeEditableState(); + } + function markProviderUsable(providerId) { + if (!settings.value) { + return; + } + const provider = settings.value.providers.find(p => p.id === providerId); + if (provider) { + provider.configuration = Object.assign(Object.assign({}, provider.configuration), {}, { + hasApiKey: true, + isUsable: true + }); + } + if (!defaultProviderId.value) { + defaultProviderId.value = providerId; + } + } + function getCleanErrorMessage(error) { + let message = ''; + if (error && typeof error === 'object' && 'message' in error) { + message = `${error.message}`; + } else { + message = `${error}`; + } + return message.replace(/\s*#\d+\s+[\s\S]*$/, '').replace(/\s+/g, ' ').trim(); + } + // Notification IDs may only contain word characters (alphanumerics + underscore), + // see core/Notification/Manager.php::checkId(). Provider IDs can contain other + // characters (e.g. hyphens), so sanitize before using them in a notification ID. + function notificationId(id) { + return id.replace(/[^\w]/g, '_'); + } + function showErrorNotification(error, id) { + const cleaned = getCleanErrorMessage(error); + const isUseful = cleaned && cleaned !== 'Something went wrong'; + const message = isUseful ? Object(external_CoreHome_["translate"])('AIProviders_RequestFailed', cleaned) : Object(external_CoreHome_["translate"])('AIProviders_UnexpectedError'); + return external_CoreHome_["NotificationsStore"].show({ + message, + type: 'transient', + id, + context: 'error' + }); + } + function updateModel(providerId, model) { + providerConfigurations.value[providerId] = Object.assign(Object.assign({}, providerConfigurations.value[providerId]), {}, { + model + }); + } + async function fetchAvailableModels(providerId, showNotifications) { + testingProviders.value[providerId] = true; + try { + const response = await external_CoreHome_["AjaxHelper"].post({ + method: 'AIProviders.testConnection' + }, { + providerId, + providerConfiguration: JSON.stringify(providerConfigurations.value[providerId] || {}) + }, { + withTokenInUrl: true, + createErrorNotification: false + }); + markProviderUsable(providerId); + if (response.models && response.models.length) { + var _providerConfiguratio; + availableModels.value[providerId] = response.models; + // Default to the first discovered model when none is selected yet. + if (!((_providerConfiguratio = providerConfigurations.value[providerId]) !== null && _providerConfiguratio !== void 0 && _providerConfiguratio.model)) { + updateModel(providerId, response.models[0]); + } + } + if (showNotifications) { + external_CoreHome_["NotificationsStore"].show({ + message: Object(external_CoreHome_["translate"])('AIProviders_TestConnectionSuccess', response.providerName), + type: 'transient', + id: notificationId(`aiProvidersTest-${providerId}`), + context: 'success' + }); + } + } catch (error) { + if (showNotifications) { + showErrorNotification(error, notificationId(`aiProvidersTestError-${providerId}`)); + } + } finally { + testingProviders.value[providerId] = false; + } + } + async function loadSettings() { + isLoading.value = true; + try { + const response = await external_CoreHome_["AjaxHelper"].fetch({ + method: 'AIProviders.getSettings' + }, { + createErrorNotification: false + }); + applySettings(response); + response.providers.filter(provider => provider.supportsCustomEndpoint && provider.configuration.isUsable).forEach(provider => { + fetchAvailableModels(provider.id, false); + }); + } catch (error) { + showErrorNotification(error, 'aiProvidersLoadError'); + } finally { + isLoading.value = false; + } + } + function updateApiKey(providerId, apiKey) { + providerConfigurations.value[providerId] = Object.assign(Object.assign({}, providerConfigurations.value[providerId]), {}, { + apiKey + }); + } + function updateEndpointUrl(providerId, endpointUrl) { + providerConfigurations.value[providerId] = Object.assign(Object.assign({}, providerConfigurations.value[providerId]), {}, { + endpointUrl + }); + availableModels.value[providerId] = []; + } + function updateUseFipsEndpoint(providerId, useFipsEndpoint) { + providerConfigurations.value[providerId] = Object.assign(Object.assign({}, providerConfigurations.value[providerId]), {}, { + useFipsEndpoint + }); + availableModels.value[providerId] = []; + } + async function disconnectProvider(providerId) { + disconnectingProviders.value[providerId] = true; + try { + const response = await external_CoreHome_["AjaxHelper"].post({ + method: 'AIProviders.disconnectProvider' + }, { + providerId + }, { + withTokenInUrl: true, + createErrorNotification: false + }); + applySettings(response); + external_CoreHome_["NotificationsStore"].show({ + message: Object(external_CoreHome_["translate"])('AIProviders_DisconnectSuccess'), + type: 'transient', + id: notificationId(`aiProvidersDisconnect-${providerId}`), + context: 'success' + }); + } catch (error) { + showErrorNotification(error, notificationId(`aiProvidersDisconnectError-${providerId}`)); + } finally { + disconnectingProviders.value[providerId] = false; + } + } + async function testConnection(providerId) { + await fetchAvailableModels(providerId, true); + } + function cancelChanges() { + if (settings.value) { + applySettings(settings.value); + } + } + /** + * Saves the current settings to the server. + */ + async function saveSettings() { + isSaving.value = true; + try { + const response = await external_CoreHome_["AjaxHelper"].post({ + method: 'AIProviders.saveSettings' + }, { + defaultProviderId: defaultProviderId.value, + defaultCapabilityLevel: defaultCapabilityLevel.value, + providerConfigurations: JSON.stringify(providerConfigurations.value) + }, { + withTokenInUrl: true, + createErrorNotification: false + }); + applySettings(response); + const notificationInstanceId = external_CoreHome_["NotificationsStore"].show({ + message: Object(external_CoreHome_["translate"])('AIProviders_SettingsSaveSuccess'), + type: 'transient', + id: 'aiProvidersSettings', + context: 'success' + }); + external_CoreHome_["NotificationsStore"].scrollToNotification(notificationInstanceId); + } catch (error) { + const notificationInstanceId = showErrorNotification(error, 'aiProvidersSettingsError'); + external_CoreHome_["NotificationsStore"].scrollToNotification(notificationInstanceId); + } finally { + isSaving.value = false; + } + } + Object(external_commonjs_vue_commonjs2_vue_root_Vue_["onMounted"])(loadSettings); + return (_ctx, _cache) => { + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_1, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("header", ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_2, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("h2", ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_3, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["EnrichedHeadline"]), null, { + default: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withCtx"])(() => [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createTextVNode"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_MenuTitle')), 1)]), + _: 1 + })]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("p", ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_4, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_ConfigurationIntro')), 1)]), isLoading.value ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createBlock"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["ActivityIndicator"]), { + key: 0, + loading: isLoading.value + }, null, 8, ["loading"])) : settings.value ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createBlock"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["ContentBlock"]), { + key: 1, + class: "ai-providers-content" + }, { + default: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withCtx"])(() => [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(["ai-providers-unsaved-changes", { + 'is-visible': Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(hasUnsavedChanges) + }]) + }, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_UnsavedChanges')), 3), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])((Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_5, [!Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(canEditProviderConfiguration) ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createBlock"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["Alert"]), { + key: 0, + severity: "info" + }, { + default: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withCtx"])(() => [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createTextVNode"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_ManagedConfigurationHelp')), 1)]), + _: 1 + })) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("h3", ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_6, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_DefaultsTitle')), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("section", ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_7, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("h4", ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_8, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_DefaultProvider')), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("p", ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_9, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_DefaultProviderHelp')), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", { + "aria-label": Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_DefaultProvider'), + class: "ai-providers-cards", + role: "radiogroup" + }, [(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"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(providers), provider => { + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createBlock"])(ProviderCard, { + key: provider.id, + "available-models": availableModels.value[provider.id] || [], + "can-edit": Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(canEditProviderConfiguration), + configuration: providerConfigurations.value[provider.id], + "is-disconnecting": !!disconnectingProviders.value[provider.id], + "is-testing": !!testingProviders.value[provider.id], + provider: provider, + selected: defaultProviderId.value === provider.id, + "usable-as-default": provider.configuration.isUsable, + onDisconnect: $event => disconnectProvider(provider.id), + onSelect: $event => provider.configuration.isUsable ? defaultProviderId.value = provider.id : null, + onTest: $event => testConnection(provider.id), + "onUpdate:apiKey": $event => updateApiKey(provider.id, $event), + "onUpdate:endpointUrl": $event => updateEndpointUrl(provider.id, $event), + "onUpdate:model": $event => updateModel(provider.id, $event), + "onUpdate:useFipsEndpoint": $event => updateUseFipsEndpoint(provider.id, $event) + }, null, 8, ["available-models", "can-edit", "configuration", "is-disconnecting", "is-testing", "provider", "selected", "usable-as-default", "onDisconnect", "onSelect", "onTest", "onUpdate:apiKey", "onUpdate:endpointUrl", "onUpdate:model", "onUpdate:useFipsEndpoint"]); + }), 128))], 8, ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_10), !Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(hasUsableProvider) ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createBlock"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["Alert"]), { + key: 0, + class: "ai-providers-default-warning", + severity: "warning" + }, { + default: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withCtx"])(() => [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createTextVNode"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_NoDefaultProviderWarning')), 1)]), + _: 1 + })) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(canEditCapabilityLevel) ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("section", ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_11, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("h4", ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_12, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_DefaultCapabilityLevel')), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("p", ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_13, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_DefaultCapabilityLevelHelp')), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", { + "aria-label": Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('AIProviders_DefaultCapabilityLevel'), + class: "ai-providers-capability-cards", + role: "radiogroup" + }, [(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"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(capabilityLevelOptions), capability => { + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("label", { + key: capability.id, + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])([{ + 'is-selected': defaultCapabilityLevel.value === capability.id + }, "ai-providers-capability-card"]) + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_15, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("input", { + "onUpdate:modelValue": _cache[0] || (_cache[0] = $event => defaultCapabilityLevel.value = $event), + value: capability.id, + name: "defaultCapabilityLevel", + type: "radio" + }, null, 8, _hoisted_16), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vModelRadio"], defaultCapabilityLevel.value]]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", _hoisted_17, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(capability.label), 1)]), capability.description ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", _hoisted_18, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(capability.description), 1)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)], 2); + }), 128))], 8, ManageAIProvidersvue_type_script_setup_true_lang_ts_hoisted_14)])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)])), [[Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CorePluginsAdmin_["Form"])]])]), + _: 1 + })) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), settings.value ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", _hoisted_19, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("button", { + disabled: isSaving.value || !Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(hasUnsavedChanges), + class: "btn btn-outline", + type: "button", + onClick: _cache[1] || (_cache[1] = $event => cancelChanges()) + }, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CoreHome_["translate"])('General_Cancel')), 9, _hoisted_20), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(external_CorePluginsAdmin_["SaveButton"]), { + disabled: !Object(external_commonjs_vue_commonjs2_vue_root_Vue_["unref"])(hasUnsavedChanges), + saving: isSaving.value, + onConfirm: _cache[2] || (_cache[2] = $event => saveSettings()) + }, null, 8, ["disabled", "saving"])])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)]); + }; + } +})); +// CONCATENATED MODULE: ./plugins/AIProviders/vue/src/ManageAIProviders.vue?vue&type=script&setup=true&lang=ts + +// EXTERNAL MODULE: ./plugins/AIProviders/vue/src/ManageAIProviders.vue?vue&type=style&index=0&id=464239d2&lang=less +var ManageAIProvidersvue_type_style_index_0_id_464239d2_lang_less = __webpack_require__("2a82"); + +// CONCATENATED MODULE: ./plugins/AIProviders/vue/src/ManageAIProviders.vue + + + + + +/* harmony default export */ var ManageAIProviders = (ManageAIProvidersvue_type_script_setup_true_lang_ts); +// CONCATENATED MODULE: ./plugins/AIProviders/vue/src/index.ts +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +// CONCATENATED MODULE: ./node_modules/@vue/cli-service/lib/commands/build/entry-lib-no-default.js + + + + +/***/ }) + +/******/ }); +}); +//# sourceMappingURL=AIProviders.umd.js.map \ No newline at end of file diff --git a/app/plugins/AIProviders/vue/dist/AIProviders.umd.min.js b/app/plugins/AIProviders/vue/dist/AIProviders.umd.min.js new file mode 100644 index 000000000..14a34488b --- /dev/null +++ b/app/plugins/AIProviders/vue/dist/AIProviders.umd.min.js @@ -0,0 +1,2 @@ +(function(e,t){"object"===typeof exports&&"object"===typeof module?module.exports=t(require("CoreHome"),require("vue"),require("CorePluginsAdmin")):"function"===typeof define&&define.amd?define(["CoreHome",,"CorePluginsAdmin"],t):"object"===typeof exports?exports["AIProviders"]=t(require("CoreHome"),require("vue"),require("CorePluginsAdmin")):e["AIProviders"]=t(e["CoreHome"],e["Vue"],e["CorePluginsAdmin"])})("undefined"!==typeof self?self:this,(function(e,t,i){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/AIProviders/vue/dist/",i(i.s="fae3")}({"19dc":function(t,i){t.exports=e},"2a82":function(e,t,i){"use strict";i("4d9d")},"4d9d":function(e,t,i){},"8bbf":function(e,i){e.exports=t},"9c8b":function(e,t,i){"use strict";i("e225")},a5a2:function(e,t){e.exports=i},e225:function(e,t,i){},fae3:function(e,t,i){"use strict";if(i.r(t),i.d(t,"ManageAIProviders",(function(){return R})),"undefined"!==typeof window){var o=window.document.currentScript,r=o&&o.src.match(/(.+\/)[^/]+\.js(\?.*)?$/);r&&(i.p=r[1])}var n=i("8bbf"),a=i("19dc"),c=i("a5a2");const l=["aria-checked","aria-disabled","tabindex"],s={class:"ai-providers-card-inner"},d={class:"ai-providers-card-heading"},u={class:"ai-providers-card-header"},p={class:"ai-providers-card-name"},b={key:0,class:"ai-providers-card-default"},v={class:"ai-providers-card-description"},f={key:2,class:"ai-providers-card-model"},j={key:1,class:"ai-providers-card-model-help"},O=["disabled","title"],m=Object(n["createElementVNode"])("span",{"aria-hidden":"true",class:"icon icon-reload"},null,-1),g={class:"ai-providers-card-actions"},y=["disabled"],h=["disabled"];var k=Object(n["defineComponent"])({__name:"ProviderCard",props:{provider:null,configuration:null,availableModels:null,selected:{type:Boolean},usableAsDefault:{type:Boolean},canEdit:{type:Boolean},isTesting:{type:Boolean},isDisconnecting:{type:Boolean}},emits:["select","update:apiKey","update:endpointUrl","update:useFipsEndpoint","update:model","test","disconnect"],setup(e,{emit:t}){const i=e,o=Object(n["computed"])(()=>{var e,t;return""!==(null!==(e=null===(t=i.configuration)||void 0===t?void 0:t.apiKey)&&void 0!==e?e:"")}),r=Object(n["computed"])(()=>{var e,t;return""!==(null!==(e=null===(t=i.configuration)||void 0===t?void 0:t.endpointUrl)&&void 0!==e?e:"")}),k=Object(n["computed"])(()=>o.value||i.provider.configuration.hasApiKey),E=Object(n["computed"])(()=>!i.provider.supportsCustomEndpoint||i.provider.defaultEndpointUrl?k.value:r.value),P=Object(n["computed"])(()=>{const e={};return i.availableModels.forEach(t=>{e[t]=t}),e});function C(){i.usableAsDefault&&t("select")}return(i,o)=>{var r,k,A,N;return Object(n["openBlock"])(),Object(n["createElementBlock"])("div",{"aria-checked":e.selected,"aria-disabled":!e.usableAsDefault,class:Object(n["normalizeClass"])([{"is-selected":e.selected,"is-not-usable":!e.usableAsDefault},"ai-providers-card"]),role:"radio",tabindex:e.usableAsDefault?0:-1,onClick:o[7]||(o[7]=e=>C()),onKeydown:[o[8]||(o[8]=Object(n["withKeys"])(Object(n["withModifiers"])(e=>C(),["prevent"]),["enter"])),o[9]||(o[9]=Object(n["withKeys"])(Object(n["withModifiers"])(e=>C(),["prevent"]),["space"]))]},[Object(n["createElementVNode"])("div",s,[Object(n["createElementVNode"])("div",d,[Object(n["createElementVNode"])("div",u,[Object(n["createElementVNode"])("span",p,Object(n["toDisplayString"])(e.provider.name),1),e.selected?(Object(n["openBlock"])(),Object(n["createElementBlock"])("span",b,Object(n["toDisplayString"])(Object(n["unref"])(a["translate"])("AIProviders_DefaultBadge")),1)):Object(n["createCommentVNode"])("",!0)]),Object(n["createElementVNode"])("p",v,Object(n["toDisplayString"])(Object(n["unref"])(a["translate"])(e.provider.description)),1)]),e.canEdit?(Object(n["openBlock"])(),Object(n["createElementBlock"])(n["Fragment"],{key:0},[e.provider.supportsCustomEndpoint?(Object(n["openBlock"])(),Object(n["createBlock"])(Object(n["unref"])(c["Field"]),{key:0,class:"ai-providers-endpoint-field","model-value":null===(r=e.configuration)||void 0===r?void 0:r.endpointUrl,name:"endpointUrl-"+e.provider.id,title:Object(n["unref"])(a["translate"])(e.provider.endpointFieldTitle),placeholder:Object(n["unref"])(a["translate"])(e.provider.endpointFieldPlaceholder),autocomplete:"off","full-width":"",uicontrol:"text","onUpdate:modelValue":o[0]||(o[0]=e=>t("update:endpointUrl",""+e))},null,8,["model-value","name","title","placeholder"])):Object(n["createCommentVNode"])("",!0),e.provider.supportsFipsEndpoint?(Object(n["openBlock"])(),Object(n["createBlock"])(Object(n["unref"])(c["Field"]),{key:1,class:"ai-providers-fips-field","model-value":null===(k=e.configuration)||void 0===k?void 0:k.useFipsEndpoint,name:"useFipsEndpoint-"+e.provider.id,title:Object(n["unref"])(a["translate"])("AIProviders_BedrockUseFipsEndpoint"),"full-width":"",uicontrol:"checkbox","onUpdate:modelValue":o[1]||(o[1]=e=>t("update:useFipsEndpoint",!!e))},null,8,["model-value","name","title"])):Object(n["createCommentVNode"])("",!0),Object(n["withDirectives"])(Object(n["createVNode"])(Object(n["unref"])(c["Field"]),{"model-value":null===(A=e.configuration)||void 0===A?void 0:A.apiKey,name:"apiKey-"+e.provider.id,placeholder:e.provider.configuration.hasApiKey?Object(n["unref"])(a["translate"])("AIProviders_ApiKeyAlreadyConfiguredPlaceholder"):Object(n["unref"])(a["translate"])("AIProviders_ApiKeyPlaceholder"),title:Object(n["unref"])(a["translate"])("AIProviders_ApiKey"),autocomplete:"new-password","full-width":"",uicontrol:"password","onUpdate:modelValue":o[2]||(o[2]=e=>t("update:apiKey",""+e))},null,8,["model-value","name","placeholder","title"]),[[Object(n["unref"])(a["AutoClearPassword"])]]),e.provider.supportsCustomEndpoint?(Object(n["openBlock"])(),Object(n["createElementBlock"])("div",f,[e.availableModels.length?(Object(n["openBlock"])(),Object(n["createBlock"])(Object(n["unref"])(c["Field"]),{key:0,"model-value":null===(N=e.configuration)||void 0===N?void 0:N.model,name:"model-"+e.provider.id,title:Object(n["unref"])(a["translate"])("AIProviders_Model"),options:Object(n["unref"])(P),"full-width":"",uicontrol:"select","onUpdate:modelValue":o[3]||(o[3]=e=>t("update:model",""+e))},null,8,["model-value","name","title","options"])):(Object(n["openBlock"])(),Object(n["createElementBlock"])("p",j,Object(n["toDisplayString"])(Object(n["unref"])(a["translate"])("AIProviders_ClickTestConnectionToShowAvailableModels")),1)),e.availableModels.length?(Object(n["openBlock"])(),Object(n["createElementBlock"])("button",{key:2,class:"btn-flat ai-providers-refresh-models",type:"button",disabled:e.isTesting||!Object(n["unref"])(E),title:Object(n["unref"])(a["translate"])("AIProviders_RefreshModels"),onClick:o[4]||(o[4]=Object(n["withModifiers"])(e=>t("test"),["prevent","stop"]))},[m,Object(n["createTextVNode"])(" "+Object(n["toDisplayString"])(Object(n["unref"])(a["translate"])("AIProviders_RefreshModels")),1)],8,O)):Object(n["createCommentVNode"])("",!0)])):Object(n["createCommentVNode"])("",!0),Object(n["createElementVNode"])("div",{class:Object(n["normalizeClass"])([{"is-connected":e.provider.configuration.isUsable},"ai-providers-card-status"])},[Object(n["createElementVNode"])("span",{"aria-hidden":"true",class:Object(n["normalizeClass"])(["icon ai-providers-status-icon",e.provider.configuration.isUsable?"icon-ok":"icon-minus"])},null,2),Object(n["createTextVNode"])(" "+Object(n["toDisplayString"])(e.provider.configuration.isUsable?Object(n["unref"])(a["translate"])("AIProviders_StatusConnected"):Object(n["unref"])(a["translate"])("AIProviders_StatusNotConnected")),1)],2),Object(n["createElementVNode"])("div",g,[Object(n["createElementVNode"])("button",{class:"btn btn-outline btn-small",type:"button",disabled:e.isTesting||!Object(n["unref"])(E),onClick:o[5]||(o[5]=Object(n["withModifiers"])(e=>t("test"),["prevent","stop"]))},Object(n["toDisplayString"])(e.isTesting?Object(n["unref"])(a["translate"])("AIProviders_TestingConnection"):Object(n["unref"])(a["translate"])("AIProviders_TestConnection")),9,y),Object(n["createElementVNode"])("button",{class:"btn-flat",type:"button",disabled:e.isDisconnecting||!e.provider.configuration.hasApiKey,onClick:o[6]||(o[6]=Object(n["withModifiers"])(e=>t("disconnect"),["prevent","stop"]))},Object(n["toDisplayString"])(e.isDisconnecting?Object(n["unref"])(a["translate"])("AIProviders_Disconnecting"):Object(n["unref"])(a["translate"])("AIProviders_Disconnect")),9,h)])],64)):Object(n["createCommentVNode"])("",!0)])],42,l)}}}),E=(i("9c8b"),k);const P={class:"ai-providers-page"},C={class:"ai-providers-page-header"},A={class:"ai-providers-page-title"},N={class:"ai-providers-page-subtitle"},B={class:"ai-providers"},S={class:"ai-providers-defaults-title"},V={class:"ai-providers-section"},I={class:"ai-providers-subsection-title"},D={class:"ai-providers-section-help"},_=["aria-label"],w={key:1,class:"ai-providers-section"},U={class:"ai-providers-subsection-title"},x={class:"ai-providers-section-help"},T=["aria-label"],M={class:"ai-providers-capability-header"},F=["value"],K={class:"ai-providers-capability-label"},H={key:0,class:"ai-providers-capability-description"},L={key:2,class:"ai-providers-footer"},q=["disabled"];var z=Object(n["defineComponent"])({__name:"ManageAIProviders",setup(e){const t=Object(n["ref"])(null),i=Object(n["ref"])(!1),o=Object(n["ref"])(!1),r=Object(n["ref"])(""),l=Object(n["ref"])(""),s=Object(n["ref"])({}),d=Object(n["ref"])({}),u=Object(n["ref"])({}),p=Object(n["ref"])({}),b=Object(n["computed"])(()=>{var e;return(null===(e=t.value)||void 0===e?void 0:e.providers)||[]}),v=Object(n["computed"])(()=>b.value.some(e=>e.configuration.isUsable)),f=Object(n["computed"])(()=>{var e;return!(null===(e=t.value)||void 0===e||!e.canEditCapabilityLevel)}),j=Object(n["computed"])(()=>{var e;return!(null===(e=t.value)||void 0===e||!e.canEditProviderConfiguration)}),O=Object(n["ref"])(""),m=Object(n["computed"])(()=>{var e;const i=(null===(e=t.value)||void 0===e?void 0:e.capabilityLevels)||{};return Object.entries(i).map(([e,t])=>({id:e,label:Object(a["translate"])(t.label),description:t.description?Object(a["translate"])(t.description):""}))});function g(){return JSON.stringify({defaultProviderId:r.value,defaultCapabilityLevel:l.value,providerConfigurations:s.value})}const y=Object(n["computed"])(()=>!!t.value&&g()!==O.value);function h(e){t.value=e,r.value=e.defaultProviderId,l.value=e.defaultCapabilityLevel;const i={};e.providers.forEach(e=>{const t=e.configuration.model||"";i[e.id]={apiKey:"",endpointUrl:e.configuration.endpointUrl||"",model:t,useFipsEndpoint:e.configuration.useFipsEndpoint||!1}}),s.value=i,d.value={},O.value=g()}function k(e){if(!t.value)return;const i=t.value.providers.find(t=>t.id===e);i&&(i.configuration=Object.assign(Object.assign({},i.configuration),{},{hasApiKey:!0,isUsable:!0})),r.value||(r.value=e)}function z(e){let t="";return t=e&&"object"===typeof e&&"message"in e?""+e.message:""+e,t.replace(/\s*#\d+\s+[\s\S]*$/,"").replace(/\s+/g," ").trim()}function R(e){return e.replace(/[^\w]/g,"_")}function J(e,t){const i=z(e),o=i&&"Something went wrong"!==i,r=o?Object(a["translate"])("AIProviders_RequestFailed",i):Object(a["translate"])("AIProviders_UnexpectedError");return a["NotificationsStore"].show({message:r,type:"transient",id:t,context:"error"})}function $(e,t){s.value[e]=Object.assign(Object.assign({},s.value[e]),{},{model:t})}async function G(e,t){u.value[e]=!0;try{const o=await a["AjaxHelper"].post({method:"AIProviders.testConnection"},{providerId:e,providerConfiguration:JSON.stringify(s.value[e]||{})},{withTokenInUrl:!0,createErrorNotification:!1});var i;if(k(e),o.models&&o.models.length)d.value[e]=o.models,null!==(i=s.value[e])&&void 0!==i&&i.model||$(e,o.models[0]);t&&a["NotificationsStore"].show({message:Object(a["translate"])("AIProviders_TestConnectionSuccess",o.providerName),type:"transient",id:R("aiProvidersTest-"+e),context:"success"})}catch(o){t&&J(o,R("aiProvidersTestError-"+e))}finally{u.value[e]=!1}}async function W(){i.value=!0;try{const e=await a["AjaxHelper"].fetch({method:"AIProviders.getSettings"},{createErrorNotification:!1});h(e),e.providers.filter(e=>e.supportsCustomEndpoint&&e.configuration.isUsable).forEach(e=>{G(e.id,!1)})}catch(e){J(e,"aiProvidersLoadError")}finally{i.value=!1}}function Q(e,t){s.value[e]=Object.assign(Object.assign({},s.value[e]),{},{apiKey:t})}function X(e,t){s.value[e]=Object.assign(Object.assign({},s.value[e]),{},{endpointUrl:t}),d.value[e]=[]}function Y(e,t){s.value[e]=Object.assign(Object.assign({},s.value[e]),{},{useFipsEndpoint:t}),d.value[e]=[]}async function Z(e){p.value[e]=!0;try{const t=await a["AjaxHelper"].post({method:"AIProviders.disconnectProvider"},{providerId:e},{withTokenInUrl:!0,createErrorNotification:!1});h(t),a["NotificationsStore"].show({message:Object(a["translate"])("AIProviders_DisconnectSuccess"),type:"transient",id:R("aiProvidersDisconnect-"+e),context:"success"})}catch(t){J(t,R("aiProvidersDisconnectError-"+e))}finally{p.value[e]=!1}}async function ee(e){await G(e,!0)}function te(){t.value&&h(t.value)}async function ie(){o.value=!0;try{const e=await a["AjaxHelper"].post({method:"AIProviders.saveSettings"},{defaultProviderId:r.value,defaultCapabilityLevel:l.value,providerConfigurations:JSON.stringify(s.value)},{withTokenInUrl:!0,createErrorNotification:!1});h(e);const t=a["NotificationsStore"].show({message:Object(a["translate"])("AIProviders_SettingsSaveSuccess"),type:"transient",id:"aiProvidersSettings",context:"success"});a["NotificationsStore"].scrollToNotification(t)}catch(e){const t=J(e,"aiProvidersSettingsError");a["NotificationsStore"].scrollToNotification(t)}finally{o.value=!1}}return Object(n["onMounted"])(W),(e,O)=>(Object(n["openBlock"])(),Object(n["createElementBlock"])("div",P,[Object(n["createElementVNode"])("header",C,[Object(n["createElementVNode"])("h2",A,[Object(n["createVNode"])(Object(n["unref"])(a["EnrichedHeadline"]),null,{default:Object(n["withCtx"])(()=>[Object(n["createTextVNode"])(Object(n["toDisplayString"])(Object(n["unref"])(a["translate"])("AIProviders_MenuTitle")),1)]),_:1})]),Object(n["createElementVNode"])("p",N,Object(n["toDisplayString"])(Object(n["unref"])(a["translate"])("AIProviders_ConfigurationIntro")),1)]),i.value?(Object(n["openBlock"])(),Object(n["createBlock"])(Object(n["unref"])(a["ActivityIndicator"]),{key:0,loading:i.value},null,8,["loading"])):t.value?(Object(n["openBlock"])(),Object(n["createBlock"])(Object(n["unref"])(a["ContentBlock"]),{key:1,class:"ai-providers-content"},{default:Object(n["withCtx"])(()=>[Object(n["createElementVNode"])("span",{class:Object(n["normalizeClass"])(["ai-providers-unsaved-changes",{"is-visible":Object(n["unref"])(y)}])},Object(n["toDisplayString"])(Object(n["unref"])(a["translate"])("AIProviders_UnsavedChanges")),3),Object(n["withDirectives"])((Object(n["openBlock"])(),Object(n["createElementBlock"])("div",B,[Object(n["unref"])(j)?Object(n["createCommentVNode"])("",!0):(Object(n["openBlock"])(),Object(n["createBlock"])(Object(n["unref"])(a["Alert"]),{key:0,severity:"info"},{default:Object(n["withCtx"])(()=>[Object(n["createTextVNode"])(Object(n["toDisplayString"])(Object(n["unref"])(a["translate"])("AIProviders_ManagedConfigurationHelp")),1)]),_:1})),Object(n["createElementVNode"])("h3",S,Object(n["toDisplayString"])(Object(n["unref"])(a["translate"])("AIProviders_DefaultsTitle")),1),Object(n["createElementVNode"])("section",V,[Object(n["createElementVNode"])("h4",I,Object(n["toDisplayString"])(Object(n["unref"])(a["translate"])("AIProviders_DefaultProvider")),1),Object(n["createElementVNode"])("p",D,Object(n["toDisplayString"])(Object(n["unref"])(a["translate"])("AIProviders_DefaultProviderHelp")),1),Object(n["createElementVNode"])("div",{"aria-label":Object(n["unref"])(a["translate"])("AIProviders_DefaultProvider"),class:"ai-providers-cards",role:"radiogroup"},[(Object(n["openBlock"])(!0),Object(n["createElementBlock"])(n["Fragment"],null,Object(n["renderList"])(Object(n["unref"])(b),e=>(Object(n["openBlock"])(),Object(n["createBlock"])(E,{key:e.id,"available-models":d.value[e.id]||[],"can-edit":Object(n["unref"])(j),configuration:s.value[e.id],"is-disconnecting":!!p.value[e.id],"is-testing":!!u.value[e.id],provider:e,selected:r.value===e.id,"usable-as-default":e.configuration.isUsable,onDisconnect:t=>Z(e.id),onSelect:t=>e.configuration.isUsable?r.value=e.id:null,onTest:t=>ee(e.id),"onUpdate:apiKey":t=>Q(e.id,t),"onUpdate:endpointUrl":t=>X(e.id,t),"onUpdate:model":t=>$(e.id,t),"onUpdate:useFipsEndpoint":t=>Y(e.id,t)},null,8,["available-models","can-edit","configuration","is-disconnecting","is-testing","provider","selected","usable-as-default","onDisconnect","onSelect","onTest","onUpdate:apiKey","onUpdate:endpointUrl","onUpdate:model","onUpdate:useFipsEndpoint"]))),128))],8,_),Object(n["unref"])(v)?Object(n["createCommentVNode"])("",!0):(Object(n["openBlock"])(),Object(n["createBlock"])(Object(n["unref"])(a["Alert"]),{key:0,class:"ai-providers-default-warning",severity:"warning"},{default:Object(n["withCtx"])(()=>[Object(n["createTextVNode"])(Object(n["toDisplayString"])(Object(n["unref"])(a["translate"])("AIProviders_NoDefaultProviderWarning")),1)]),_:1}))]),Object(n["unref"])(f)?(Object(n["openBlock"])(),Object(n["createElementBlock"])("section",w,[Object(n["createElementVNode"])("h4",U,Object(n["toDisplayString"])(Object(n["unref"])(a["translate"])("AIProviders_DefaultCapabilityLevel")),1),Object(n["createElementVNode"])("p",x,Object(n["toDisplayString"])(Object(n["unref"])(a["translate"])("AIProviders_DefaultCapabilityLevelHelp")),1),Object(n["createElementVNode"])("div",{"aria-label":Object(n["unref"])(a["translate"])("AIProviders_DefaultCapabilityLevel"),class:"ai-providers-capability-cards",role:"radiogroup"},[(Object(n["openBlock"])(!0),Object(n["createElementBlock"])(n["Fragment"],null,Object(n["renderList"])(Object(n["unref"])(m),e=>(Object(n["openBlock"])(),Object(n["createElementBlock"])("label",{key:e.id,class:Object(n["normalizeClass"])([{"is-selected":l.value===e.id},"ai-providers-capability-card"])},[Object(n["createElementVNode"])("div",M,[Object(n["withDirectives"])(Object(n["createElementVNode"])("input",{"onUpdate:modelValue":O[0]||(O[0]=e=>l.value=e),value:e.id,name:"defaultCapabilityLevel",type:"radio"},null,8,F),[[n["vModelRadio"],l.value]]),Object(n["createElementVNode"])("span",K,Object(n["toDisplayString"])(e.label),1)]),e.description?(Object(n["openBlock"])(),Object(n["createElementBlock"])("div",H,Object(n["toDisplayString"])(e.description),1)):Object(n["createCommentVNode"])("",!0)],2))),128))],8,T)])):Object(n["createCommentVNode"])("",!0)])),[[Object(n["unref"])(c["Form"])]])]),_:1})):Object(n["createCommentVNode"])("",!0),t.value?(Object(n["openBlock"])(),Object(n["createElementBlock"])("div",L,[Object(n["createElementVNode"])("button",{disabled:o.value||!Object(n["unref"])(y),class:"btn btn-outline",type:"button",onClick:O[1]||(O[1]=e=>te())},Object(n["toDisplayString"])(Object(n["unref"])(a["translate"])("General_Cancel")),9,q),Object(n["createVNode"])(Object(n["unref"])(c["SaveButton"]),{disabled:!Object(n["unref"])(y),saving:o.value,onConfirm:O[2]||(O[2]=e=>ie())},null,8,["disabled","saving"])])):Object(n["createCommentVNode"])("",!0)]))}}),R=(i("2a82"),z)}})})); +//# sourceMappingURL=AIProviders.umd.min.js.map \ No newline at end of file diff --git a/app/plugins/AIProviders/vue/dist/umd.metadata.json b/app/plugins/AIProviders/vue/dist/umd.metadata.json new file mode 100644 index 000000000..dce4477a3 --- /dev/null +++ b/app/plugins/AIProviders/vue/dist/umd.metadata.json @@ -0,0 +1,6 @@ +{ + "dependsOn": [ + "CoreHome", + "CorePluginsAdmin" + ] +} \ No newline at end of file diff --git a/app/plugins/AIProviders/vue/src/ManageAIProviders.vue b/app/plugins/AIProviders/vue/src/ManageAIProviders.vue new file mode 100644 index 000000000..758c9457e --- /dev/null +++ b/app/plugins/AIProviders/vue/src/ManageAIProviders.vue @@ -0,0 +1,665 @@ + + + + + + + diff --git a/app/plugins/AIProviders/vue/src/components/ProviderCard.vue b/app/plugins/AIProviders/vue/src/components/ProviderCard.vue new file mode 100644 index 000000000..e34b3f371 --- /dev/null +++ b/app/plugins/AIProviders/vue/src/components/ProviderCard.vue @@ -0,0 +1,422 @@ + + + + + + + diff --git a/app/plugins/AIProviders/vue/src/index.ts b/app/plugins/AIProviders/vue/src/index.ts new file mode 100644 index 000000000..41fbd818a --- /dev/null +++ b/app/plugins/AIProviders/vue/src/index.ts @@ -0,0 +1,8 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +export { default as ManageAIProviders } from './ManageAIProviders.vue'; diff --git a/app/plugins/AIProviders/vue/src/types.ts b/app/plugins/AIProviders/vue/src/types.ts new file mode 100644 index 000000000..e09aceab8 --- /dev/null +++ b/app/plugins/AIProviders/vue/src/types.ts @@ -0,0 +1,58 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +export interface ProviderConfiguration { + apiKey: string; + endpointUrl: string; + model: string; + useFipsEndpoint: boolean; +} + +export interface Provider { + id: string; + name: string; + description: string; + supportsCustomEndpoint: boolean; + supportsFipsEndpoint: boolean; + defaultEndpointUrl: string; + endpointFieldTitle: string; + endpointFieldPlaceholder: string; + defaultModel: string; + configuration: { + hasApiKey: boolean; + endpointUrl: string; + model: string; + useFipsEndpoint: boolean; + isUsable: boolean; + }; +} + +export interface CapabilityLevel { + label: string; + description?: string; +} + +export interface Settings { + defaultProviderId: string; + defaultCapabilityLevel: string; + canEditProviderConfiguration: boolean; + canEditCapabilityLevel: boolean; + capabilityLevels: Record; + providers: Provider[]; +} + +export interface CapabilityLevelOption { + id: string; + label: string; + description: string; +} + +export interface TestConnectionResponse { + providerId: string; + providerName: string; + models: string[]; +} diff --git a/app/plugins/API/API.php b/app/plugins/API/API.php index 97de728d9..6b498b6bd 100644 --- a/app/plugins/API/API.php +++ b/app/plugins/API/API.php @@ -27,6 +27,7 @@ use Piwik\Plugin\SettingsProvider; use Piwik\Plugins\API\DataTable\MergeDataTables; use Piwik\Plugins\CorePluginsAdmin\SettingsMetadata; +use Piwik\Request\AuthenticationToken; use Piwik\Segment; use Piwik\Site; use Piwik\Translation\Translator; @@ -429,9 +430,14 @@ public function getBulkRequest($urls) $request = \Piwik\Request::fromRequest(); $queryParameters = $request->getParameters(); unset($queryParameters['urls']); + $authToken = StaticContainer::get(AuthenticationToken::class); + $rootIsSessionToken = $authToken->isSessionToken(); + $rootTokenAuth = $authToken->getAuthToken(); $result = []; foreach ($urls as $url) { - $params = \Piwik\Request::fromQueryString($url)->getParameters(); + $nestedRequest = \Piwik\Request::fromQueryString($url); + $this->checkNestedRequestAuthMatchesRoot($nestedRequest, $rootIsSessionToken, $rootTokenAuth); + $params = $nestedRequest->getParameters(); $params['format'] = 'json'; $params += $queryParameters; $method = $params['method'] ?? ''; @@ -443,6 +449,30 @@ public function getBulkRequest($urls) } return $result; } + /** + * A bulk sub-request runs inside the authentication context that was established for the outer + * request, so it must not redefine that context. Within a browser session a sub-request may + * change neither the session flag nor the acting user; outside a session it may still not + * change the session flag, but it may supply its own token_auth to authenticate that single + * call. Any attempt to change the established context is treated as a conflicting set of + * authentication parameters and aborts the whole bulk request. + * + * @param \Piwik\Request $nestedRequest The bulk sub-request to validate against the root context. + * @param bool $rootIsSessionToken Whether the outer request authenticated via a session token. + * @param string $rootTokenAuth The token the outer request authenticated with. + */ + private function checkNestedRequestAuthMatchesRoot(\Piwik\Request $nestedRequest, bool $rootIsSessionToken, +#[\SensitiveParameter] +string $rootTokenAuth) : void + { + $params = $nestedRequest->getParameters(); + if (array_key_exists('force_api_session', $params) && $nestedRequest->getBoolParameter('force_api_session', \false) !== $rootIsSessionToken) { + throw new BadRequestException(Piwik::translate('General_ConflictingAuthenticationParametersProvided')); + } + if ($rootIsSessionToken && array_key_exists('token_auth', $params) && $nestedRequest->getStringParameter('token_auth', '') !== $rootTokenAuth) { + throw new BadRequestException(Piwik::translate('General_ConflictingAuthenticationParametersProvided')); + } + } /** * Returns whether a plugin is currently activated. * diff --git a/app/plugins/API/Controller.php b/app/plugins/API/Controller.php index ebc69824f..1ebb1d1e4 100644 --- a/app/plugins/API/Controller.php +++ b/app/plugins/API/Controller.php @@ -25,8 +25,6 @@ class Controller extends \Piwik\Plugin\Controller public function index() { $tokenAuth = StaticContainer::get(AuthenticationToken::class)->getAuthToken() ?: 'anonymous'; - $format = Common::getRequestVar('format', \false); - $serialize = Common::getRequestVar('serialize', \false); // when calling the API through http, we limit the number of returned results if (!isset($_GET['filter_limit'])) { if (isset($_POST['filter_limit'])) { @@ -38,9 +36,8 @@ public function index() $request = new Request(['token_auth' => $tokenAuth]); $response = $request->process(); if (is_array($response)) { - if ($format == 'original' && $serialize != 1) { - Original::sendPlainTextHeader(); - } + // var_export() output is a plain-text PHP structure dump and is always served as such + Original::sendPlainTextHeader(); $response = var_export($response, \true); } return $response; diff --git a/app/plugins/Actions/API.php b/app/plugins/Actions/API.php index 1b106d691..3797f5132 100644 --- a/app/plugins/Actions/API.php +++ b/app/plugins/Actions/API.php @@ -100,7 +100,7 @@ public function get($idSite, string $period, string $date, $segment = \false, $c public function getPageUrls($idSite, string $period, string $date, $segment = \false, bool $expanded = \false, $idSubtable = \false, $depth = \false, bool $flat = \false) { Piwik::checkUserHasViewAccess($idSite); - $dataTable = Archive::createDataTableFromArchive('Actions_actions_url', $idSite, $period, $date, $segment, $expanded, $flat, $idSubtable, $depth); + $dataTable = $this->createActionsTableFromArchive(\Piwik\Plugins\Actions\Archiver::PAGE_URLS_RECORD_NAME, \Piwik\Plugins\Actions\Archiver::PAGE_URLS_FLAT_RECORD_NAME, $idSite, $period, $date, $segment, $expanded, $idSubtable, $depth, $flat); $this->filterActionsDataTable($dataTable, Action::TYPE_PAGE_URL); if ($flat) { $dataTable->filter(function (DataTable $dataTable) { @@ -283,7 +283,7 @@ public function getPageUrl($pageUrl, $idSite, string $period, string $date, $seg public function getPageTitles($idSite, string $period, string $date, $segment = \false, bool $expanded = \false, $idSubtable = \false, bool $flat = \false) { Piwik::checkUserHasViewAccess($idSite); - $dataTable = Archive::createDataTableFromArchive('Actions_actions', $idSite, $period, $date, $segment, $expanded, $flat, $idSubtable); + $dataTable = $this->createActionsTableFromArchive(\Piwik\Plugins\Actions\Archiver::PAGE_TITLES_RECORD_NAME, \Piwik\Plugins\Actions\Archiver::PAGE_TITLES_FLAT_RECORD_NAME, $idSite, $period, $date, $segment, $expanded, $idSubtable, null, $flat); $this->filterActionsDataTable($dataTable, Action::TYPE_PAGE_TITLE); return $dataTable; } @@ -687,6 +687,143 @@ protected function doFilterPageDatatableSearch($callBackParameters, $table, $sea return $this->doFilterPageDatatableSearch($callBackParameters, $table, $searchTree); } } + /** + * Reads the pre-flattened archive record directly for top-level flat requests when flat-first + * archiving is enabled, avoiding loading the hierarchy and collapsing it again at request time. + * Otherwise (and for subtable drill-downs) reads the hierarchical record as before. + * + * @param int|string|int[] $idSite + * @param string|null|false $segment + * @param int|null|false $idSubtable + * @param int|null|false $depth + * @return DataTable|DataTable\Map + */ + private function createActionsTableFromArchive(string $hierarchicalRecord, string $flatRecord, $idSite, string $period, string $date, $segment, bool $expanded, $idSubtable, $depth, bool $flat) + { + $useFlatRecord = $flat && empty($idSubtable) && \Piwik\Plugins\Actions\ArchivingHelper::isFlatArchivingEnabled(); + if (!$useFlatRecord) { + return Archive::createDataTableFromArchive($hierarchicalRecord, $idSite, $period, $date, $segment, $expanded, $flat, $idSubtable, $depth); + } + // Read the flat record as-is: no expansion (which the flat flag would force) is needed, + // and the request-level flat=1 still drives the Actions filter and request-time Flattener. + $flatTable = Archive::createDataTableFromArchive($flatRecord, $idSite, $period, $date, $segment, \false, \false, null, null); + // Match the hierarchical record's column order so exports stay identical. + $this->reorderFlatRowColumnsToHierarchicalOrder($flatTable); + if (!$this->flatResultHasEmptyTable($flatTable)) { + return $flatTable; + } + // Some periods are empty. Read the hierarchical record unexpanded (top-level only, no + // subtable loading, so cheap) to tell a genuinely empty period apart from one archived + // before flat-first was enabled (which has no flat record but does have hierarchical data). + $hierarchicalTop = Archive::createDataTableFromArchive($hierarchicalRecord, $idSite, $period, $date, $segment, \false, \false, null, null); + if (!$this->hasRecoverableHierarchicalData($flatTable, $hierarchicalTop)) { + return $flatTable; + } + // At least one empty period predates flat-first and still holds hierarchical data; rebuild + // those periods from the fully expanded hierarchical record (the request-time Flattener then + // flattens them), keeping the flat rows for every period that has a flat record. + $hierarchicalTable = Archive::createDataTableFromArchive($hierarchicalRecord, $idSite, $period, $date, $segment, \true, \true, null, $depth); + return $this->replaceEmptyFlatTablesWithHierarchical($flatTable, $hierarchicalTable); + } + /** + * Moves the leading metrics to the front in the order the hierarchical record uses (the flat + * record appends nb_uniq_visitors instead of keeping it second); other columns keep their order. + * + * @param DataTable|DataTable\Map $table + */ + private function reorderFlatRowColumnsToHierarchicalOrder($table) : void + { + $leadingColumns = \Piwik\Plugins\Actions\ArchivingHelper::getHierarchyRowColumnOrder(); + $table->filter(function (DataTable $dataTable) use($leadingColumns) { + foreach ($dataTable->getRows() as $row) { + $columns = $row->getColumns(); + $ordered = []; + if (array_key_exists('label', $columns)) { + $ordered['label'] = $columns['label']; + } + foreach ($leadingColumns as $index) { + if (array_key_exists($index, $columns)) { + $ordered[$index] = $columns[$index]; + } + } + foreach ($columns as $index => $value) { + if (!array_key_exists($index, $ordered)) { + $ordered[$index] = $value; + } + } + $row->setColumns($ordered); + } + }); + } + /** + * True if any leaf table is empty, i.e. a period whose flat record is not archived yet. + * + * @param DataTable|DataTable\Map $table + */ + private function flatResultHasEmptyTable($table) : bool + { + if ($table instanceof DataTable\Map) { + foreach ($table->getDataTables() as $child) { + if ($this->flatResultHasEmptyTable($child)) { + return \true; + } + } + return \false; + } + return $table->getRowsCount() === 0; + } + /** + * True if any empty flat leaf has a non-empty hierarchical counterpart, i.e. a period that + * predates flat-first and still holds hierarchical data worth recovering. A genuinely empty + * period is empty in both records, so it does not trigger the expensive expanded read. + * + * @param DataTable|DataTable\Map $flatTable + * @param DataTable|DataTable\Map $hierarchicalTable Read unexpanded (top-level rows only). + */ + private function hasRecoverableHierarchicalData($flatTable, $hierarchicalTable) : bool + { + if ($flatTable instanceof DataTable\Map && $hierarchicalTable instanceof DataTable\Map) { + $hierarchicalChildren = $hierarchicalTable->getDataTables(); + foreach ($flatTable->getDataTables() as $label => $flatChild) { + if (!array_key_exists($label, $hierarchicalChildren)) { + continue; + } + if ($this->hasRecoverableHierarchicalData($flatChild, $hierarchicalChildren[$label])) { + return \true; + } + } + return \false; + } + if ($flatTable instanceof DataTable && $hierarchicalTable instanceof DataTable) { + return $flatTable->getRowsCount() === 0 && $hierarchicalTable->getRowsCount() > 0; + } + return \false; + } + /** + * Replaces each empty leaf table in the flat result with the matching hierarchical table, so a + * result spanning the flat-first enablement date mixes both sources correctly. + * + * @param DataTable|DataTable\Map $flatTable + * @param DataTable|DataTable\Map $hierarchicalTable + * @return DataTable|DataTable\Map + */ + private function replaceEmptyFlatTablesWithHierarchical($flatTable, $hierarchicalTable) + { + if ($flatTable instanceof DataTable\Map && $hierarchicalTable instanceof DataTable\Map) { + $hierarchicalChildren = $hierarchicalTable->getDataTables(); + foreach ($flatTable->getDataTables() as $label => $flatChild) { + if (!array_key_exists($label, $hierarchicalChildren)) { + continue; + } + $flatTable->addTable($this->replaceEmptyFlatTablesWithHierarchical($flatChild, $hierarchicalChildren[$label]), $label); + } + return $flatTable; + } + if ($flatTable instanceof DataTable && $flatTable->getRowsCount() === 0) { + return $hierarchicalTable; + } + return $flatTable; + } /** * Applies the shared post-processing filters used by Actions API reports. * diff --git a/app/plugins/Actions/ArchivingHelper.php b/app/plugins/Actions/ArchivingHelper.php index bbe6750f2..b88fccbef 100644 --- a/app/plugins/Actions/ArchivingHelper.php +++ b/app/plugins/Actions/ArchivingHelper.php @@ -483,6 +483,25 @@ private static function getColumnValuesMerged($columnName, $alreadyValue, $value protected static $defaultActionName = null; protected static $defaultActionNameWhenNotDefined = null; protected static $defaultActionUrlWhenNotDefined = null; + /** + * Whether flat-first archiving is enabled, i.e. page URL/title reports are archived in a flat + * form (and the hierarchy rebuilt from it) rather than hierarchically only. + */ + public static function isFlatArchivingEnabled() : bool + { + return (int) (Config::getInstance()->General['datatable_archiving_maximum_rows_actions_flat'] ?? 0) > 0; + } + /** + * Leading metric columns of a hierarchical action row, in their canonical order. This is the + * order the hierarchical record (and therefore the report output and its exports) uses, so the + * flat record's rows are reordered to match it when served directly. + * + * @return int[] + */ + public static function getHierarchyRowColumnOrder() : array + { + return [PiwikMetrics::INDEX_NB_VISITS, PiwikMetrics::INDEX_NB_UNIQ_VISITORS, PiwikMetrics::INDEX_PAGE_NB_HITS, PiwikMetrics::INDEX_PAGE_SUM_TIME_SPENT]; + } public static function reloadConfig() { // for BC, we read the old style delimiter first (see #1067) diff --git a/app/plugins/Actions/RecordBuilders/ActionReports.php b/app/plugins/Actions/RecordBuilders/ActionReports.php index 7e8566380..938f5c51b 100644 --- a/app/plugins/Actions/RecordBuilders/ActionReports.php +++ b/app/plugins/Actions/RecordBuilders/ActionReports.php @@ -32,6 +32,7 @@ public function __construct() } public function getRecordMetadata(ArchiveProcessor $archiveProcessor) : array { + ArchivingHelper::reloadConfig(); $pageUrlsRecord = Record::make(Record::TYPE_BLOB, Archiver::PAGE_URLS_RECORD_NAME)->setBlobColumnAggregationOps(Metrics::getColumnsAggregationOperation()); $pageTitlesRecord = Record::make(Record::TYPE_BLOB, Archiver::PAGE_TITLES_RECORD_NAME)->setBlobColumnAggregationOps(Metrics::getColumnsAggregationOperation()); if ($this->isFlatArchivingEnabled()) { @@ -188,8 +189,7 @@ private function finalizeBuiltFromFlatHierarchyTable(ArchiveProcessor $archivePr } private function isFlatArchivingEnabled() : bool { - ArchivingHelper::reloadConfig(); - return ArchivingHelper::$maximumRowsInDataTableFlat > 0; + return ArchivingHelper::isFlatArchivingEnabled(); } private function setHierarchyBuiltFromFlatRecord(Record $record, string $flatRecordName, callable $flatToHierarchyPathCallback, callable $legacyHierarchyToFlatReducer) : void { @@ -201,7 +201,7 @@ private function buildDayHierarchicalTableFromFlatTable(DataTable $flatTable, ca } private function getDefaultHierarchyRowColumns() : array { - return [PiwikMetrics::INDEX_NB_VISITS => 0, PiwikMetrics::INDEX_NB_UNIQ_VISITORS => 0, PiwikMetrics::INDEX_PAGE_NB_HITS => 0, PiwikMetrics::INDEX_PAGE_SUM_TIME_SPENT => 0]; + return array_fill_keys(ArchivingHelper::getHierarchyRowColumnOrder(), 0); } private function aggregateLegacyHierarchical(ArchiveProcessor $archiveProcessor) : array { diff --git a/app/plugins/BotTracking/BotDetector.php b/app/plugins/BotTracking/BotDetector.php index d2bce82c0..7bc5639ab 100644 --- a/app/plugins/BotTracking/BotDetector.php +++ b/app/plugins/BotTracking/BotDetector.php @@ -22,7 +22,13 @@ class BotDetector * * @var array */ - private $aiAssistantPatterns = ['ChatGPT-User' => self::BOT_TYPE_AI_CHATBOT, 'MistralAI-User' => self::BOT_TYPE_AI_CHATBOT, 'Gemini-Deep-Research' => self::BOT_TYPE_AI_CHATBOT, 'Claude-User' => self::BOT_TYPE_AI_CHATBOT, 'Perplexity-User' => self::BOT_TYPE_AI_CHATBOT, 'Google-NotebookLM' => self::BOT_TYPE_AI_CHATBOT]; + private $aiAssistantPatterns = ['ChatGPT-User' => self::BOT_TYPE_AI_CHATBOT, 'MistralAI-User' => self::BOT_TYPE_AI_CHATBOT, 'Gemini-Deep-Research' => self::BOT_TYPE_AI_CHATBOT, 'Claude-User' => self::BOT_TYPE_AI_CHATBOT, 'Perplexity-User' => self::BOT_TYPE_AI_CHATBOT, 'Google-GeminiNotebook' => self::BOT_TYPE_AI_CHATBOT, 'Google-NotebookLM' => self::BOT_TYPE_AI_CHATBOT]; + /** + * Normalized bot names for renamed User-Agent patterns. + * + * @var array + */ + private $botNameAliases = ['Google-GeminiNotebook' => 'Google-NotebookLM']; public function __construct(string $userAgent) { $this->detectionResult = $this->detect($userAgent); @@ -40,7 +46,7 @@ private function detect(string $userAgent) : ?array } foreach ($this->aiAssistantPatterns as $pattern => $botType) { if (stripos($userAgent, $pattern) !== \false) { - return ['bot_name' => $pattern, 'bot_type' => $botType]; + return ['bot_name' => $this->botNameAliases[$pattern] ?? $pattern, 'bot_type' => $botType]; } } return null; diff --git a/app/plugins/BotTracking/Widgets/NoRecentRequests.php b/app/plugins/BotTracking/Widgets/NoRecentRequests.php index 012bbfdb9..d04db27cb 100644 --- a/app/plugins/BotTracking/Widgets/NoRecentRequests.php +++ b/app/plugins/BotTracking/Widgets/NoRecentRequests.php @@ -18,9 +18,8 @@ public static function configure(WidgetConfig $config) self::configureMessageWidget($config, 'BotTracking_AIChatbotsOverview', 'noRecentRequestsMessage'); } /** - * Shared config for the "no recent AI bot requests" message. It tops the Overview and Content - * Requests pages as its own widget (see {@see NoRecentRequestsContentRequests}); the - * showNoRecentRequestsMessage middleware hides it once recent requests exist. + * Shared config for the "no recent AI bot requests" message. It tops each AI Chatbots page as + * its own widget; the showNoRecentRequestsMessage middleware hides it once recent requests exist. */ public static function configureMessageWidget(WidgetConfig $config, string $subcategoryId, string $action) : void { diff --git a/app/plugins/BotTracking/Widgets/NoRecentRequestsRealtime.php b/app/plugins/BotTracking/Widgets/NoRecentRequestsRealtime.php new file mode 100644 index 000000000..50ff182ec --- /dev/null +++ b/app/plugins/BotTracking/Widgets/NoRecentRequestsRealtime.php @@ -0,0 +1,24 @@ + 'getStylesheetFiles', 'AssetManager.getJavaScriptFiles' => 'getJsFiles', 'AssetManager.filterMergedJavaScripts' => 'filterMergedJavaScripts', 'Translate.getClientSideTranslationKeys' => 'getClientSideTranslationKeys', 'Metric.addComputedMetrics' => 'addComputedMetrics', 'Request.initAuthenticationObject' => ['function' => 'checkAllowedIpsOnAuthentication', 'before' => \true], 'AssetManager.addStylesheets' => 'addStylesheets', 'Request.dispatchCoreAndPluginUpdatesScreen' => ['function' => 'checkAllowedIpsOnAuthentication', 'before' => \true], 'Tracker.setTrackerCacheGeneral' => 'setTrackerCacheGeneral', 'Segment.filterSegments' => 'filterSegments', 'Template.bodyClass' => 'addBodyClass'); - } - public function addBodyClass(&$out, $type) - { - $featureFlagManager = StaticContainer::get(FeatureFlagManager::class); - // The report header redesign moves widget controls and report actions to a shared - // top-right header. It is gated app-wide by this flag so later tickets can scope - // CSS/JS with `body.report-header-redesign-enabled` across every report surface. - if ($featureFlagManager->isFeatureActive(ReportHeaderRedesign::class)) { - $out .= ' report-header-redesign-enabled'; - } + return array('AssetManager.getStylesheetFiles' => 'getStylesheetFiles', 'AssetManager.getJavaScriptFiles' => 'getJsFiles', 'AssetManager.filterMergedJavaScripts' => 'filterMergedJavaScripts', 'Translate.getClientSideTranslationKeys' => 'getClientSideTranslationKeys', 'Metric.addComputedMetrics' => 'addComputedMetrics', 'Request.initAuthenticationObject' => ['function' => 'checkAllowedIpsOnAuthentication', 'before' => \true], 'AssetManager.addStylesheets' => 'addStylesheets', 'Request.dispatchCoreAndPluginUpdatesScreen' => ['function' => 'checkAllowedIpsOnAuthentication', 'before' => \true], 'Tracker.setTrackerCacheGeneral' => 'setTrackerCacheGeneral', 'Segment.filterSegments' => 'filterSegments'); } public function isTrackerPlugin() { @@ -147,6 +135,8 @@ public function getStylesheetFiles(&$stylesheets) $stylesheets[] = "plugins/CoreHome/vue/src/PasswordStrength/PasswordStrength.less"; $stylesheets[] = "plugins/CoreHome/vue/src/EntityDuplicator/EntityDuplicatorModal.less"; $stylesheets[] = "plugins/CoreHome/vue/src/EntityDuplicator/EntityDuplicatorAction.less"; + $stylesheets[] = "plugins/CoreHome/vue/src/ReportHeader/ReportHeader.less"; + $stylesheets[] = "plugins/CoreHome/vue/src/WidgetControls/WidgetControls.less"; } public function getJsFiles(&$jsFiles) { @@ -216,6 +206,10 @@ public function getClientSideTranslationKeys(&$translationKeys) $translationKeys[] = 'CoreHome_Menu'; $translationKeys[] = 'CoreHome_AddTotalsRowDataTable'; $translationKeys[] = 'CoreHome_RemoveTotalsRowDataTable'; + $translationKeys[] = 'CoreHome_ShowPercentageValuesDataTable'; + $translationKeys[] = 'CoreHome_ShowAbsoluteValuesDataTable'; + $translationKeys[] = 'CoreHome_ShowPercentageValues'; + $translationKeys[] = 'CoreHome_ShowAbsoluteValues'; $translationKeys[] = 'CoreHome_PeriodHasOnlyRawData'; $translationKeys[] = 'CoreHome_PeriodHasOnlyRawDataNoVisitsLog'; $translationKeys[] = 'SitesManager_NotFound'; @@ -408,6 +402,13 @@ public function getClientSideTranslationKeys(&$translationKeys) $translationKeys[] = 'CoreHome_CopyX'; $translationKeys[] = 'CoreHome_CopyXDescription'; $translationKeys[] = 'CoreHome_WebAnalyticsReports'; + $translationKeys[] = 'General_Widget'; + // Widget-control actions rendered by the shared CoreHome.WidgetControls component. Registered + // here (not in Dashboard) so the component works wherever CoreHome is loaded. + $translationKeys[] = 'General_Refresh'; + $translationKeys[] = 'Dashboard_Minimise'; + $translationKeys[] = 'Dashboard_Maximise'; + $translationKeys[] = 'General_Close'; // add admin menu translations if (SettingsPiwik::isMatomoInstalled() && Common::getRequestVar('module', '') != 'CoreUpdater' && Piwik::isUserHasSomeViewAccess()) { /* diff --git a/app/plugins/CoreHome/DataTableRowAction/RowEvolution.php b/app/plugins/CoreHome/DataTableRowAction/RowEvolution.php index ce01aa916..0d70e7887 100644 --- a/app/plugins/CoreHome/DataTableRowAction/RowEvolution.php +++ b/app/plugins/CoreHome/DataTableRowAction/RowEvolution.php @@ -24,6 +24,7 @@ use Piwik\Plugins\CoreVisualizations\Visualizations\Graph\Config as GraphConfig; use Piwik\Plugins\CoreVisualizations\Visualizations\JqplotGraph\Config as JqplotGraphConfig; use Piwik\Plugins\CoreVisualizations\Visualizations\JqplotGraph\Evolution as EvolutionViz; +use Piwik\Plugins\CoreVisualizations\Visualizations\JqplotGraph\Evolution\Config as EvolutionVizConfig; use Piwik\ViewDataTable\Factory; use Piwik\ViewDataTable\Manager as ViewDataTableManager; /** @@ -272,6 +273,13 @@ public function getRowEvolutionGraph($graphType = \false, $metrics = \false) $view->config->external_series_toggle = 'RowEvolutionSeriesToggle'; $view->config->external_series_toggle_show_all = $this->initiallyShowAllMetrics; } + if ($view->config instanceof EvolutionVizConfig) { + // Row evolution applies a label filter, so the forecast's 70-day daily and + // multi-year monthly sub-period fetches would pull subtable blobs for every tick. + // Suppress the precompute path entirely so the popover stays cheap. + $view->config->show_forecast = \false; + $view->config->disable_forecast = \true; + } return $view; } /** diff --git a/app/plugins/CoreHome/FeatureFlags/ReportHeaderRedesign.php b/app/plugins/CoreHome/FeatureFlags/ReportHeaderRedesign.php deleted file mode 100644 index 85fde3686..000000000 --- a/app/plugins/CoreHome/FeatureFlags/ReportHeaderRedesign.php +++ /dev/null @@ -1,19 +0,0 @@ -{{ row.getMetadata('html_label_prefix') | raw }} {% endif -%} {% endif %} - {%- if row.getColumn(column) or (column=='label' and (row.getColumn(column) is same as("0") or row.getColumn(column) is same as(0))) %}{% if column=='label' %}{{- row.getColumn(column)|rawSafeDecoded -}}{% else %}{% if row.getMetadata('html_column_' ~ column ~ '_prefix') %}{{ row.getMetadata('html_column_' ~ column ~ '_prefix') | raw }}{% endif -%}{{- row.getColumn(column)|number(2,0)|rawSafeDecoded -}}{% if row.getMetadata('html_column_' ~ column ~ '_suffix') %}{{ row.getMetadata('html_column_' ~ column ~ '_suffix') | raw }}{% endif -%}{% endif %} + {%- if row.getColumn(column) or (column=='label' and (row.getColumn(column) is same as("0") or row.getColumn(column) is same as(0))) -%} + {%- if column=='label' -%} + {{- row.getColumn(column)|rawSafeDecoded -}} + {%- elseif showPercentageValues -%} + {{- rowPercentage -}} + {%- else -%} + {%- if row.getMetadata('html_column_' ~ column ~ '_prefix') -%} + {{ row.getMetadata('html_column_' ~ column ~ '_prefix') | raw }} + {%- endif -%} + {{- row.getColumn(column)|number(2,0)|rawSafeDecoded -}} + {%- if row.getMetadata('html_column_' ~ column ~ '_suffix') -%} + {{ row.getMetadata('html_column_' ~ column ~ '_suffix') | raw }} + {%- endif -%} + {%- endif -%} {%- else -%}- {%- endif -%} {% if column=='label' %}{%- if row.getMetadata('html_label_suffix') %}{{ row.getMetadata('html_label_suffix') | raw }}{% endif -%}{% endif %} diff --git a/app/plugins/CoreHome/templates/widgetContainer.twig b/app/plugins/CoreHome/templates/widgetContainer.twig index 59932260f..cab64b924 100644 --- a/app/plugins/CoreHome/templates/widgetContainer.twig +++ b/app/plugins/CoreHome/templates/widgetContainer.twig @@ -1,20 +1,7 @@
- -
\ No newline at end of file diff --git a/app/plugins/CoreHome/vue/dist/CoreHome.umd.js b/app/plugins/CoreHome/vue/dist/CoreHome.umd.js index 379d6f784..f25e0eb6f 100644 --- a/app/plugins/CoreHome/vue/dist/CoreHome.umd.js +++ b/app/plugins/CoreHome/vue/dist/CoreHome.umd.js @@ -134,6 +134,7 @@ __webpack_require__.d(__webpack_exports__, "useExternalPluginComponent", functio __webpack_require__.d(__webpack_exports__, "DirectiveUtilities", function() { return /* reexport */ directiveUtilities; }); __webpack_require__.d(__webpack_exports__, "debounce", function() { return /* reexport */ debounce; }); __webpack_require__.d(__webpack_exports__, "clone", function() { return /* reexport */ clone; }); +__webpack_require__.d(__webpack_exports__, "ucfirst", function() { return /* reexport */ ucfirst; }); __webpack_require__.d(__webpack_exports__, "VueEntryContainer", function() { return /* reexport */ VueEntryContainer; }); __webpack_require__.d(__webpack_exports__, "ActivityIndicator", function() { return /* reexport */ ActivityIndicator; }); __webpack_require__.d(__webpack_exports__, "MatomoLoader", function() { return /* reexport */ MatomoLoader; }); @@ -205,6 +206,8 @@ __webpack_require__.d(__webpack_exports__, "ReportingMenuStore", function() { re __webpack_require__.d(__webpack_exports__, "ReportingPagesStore", function() { return /* reexport */ ReportingPages_store; }); __webpack_require__.d(__webpack_exports__, "ReportMetadataStore", function() { return /* reexport */ ReportMetadata_store; }); __webpack_require__.d(__webpack_exports__, "WidgetsStore", function() { return /* reexport */ Widgets_store; }); +__webpack_require__.d(__webpack_exports__, "ReportHeader", function() { return /* reexport */ ReportHeader; }); +__webpack_require__.d(__webpack_exports__, "WidgetControls", function() { return /* reexport */ WidgetControls; }); __webpack_require__.d(__webpack_exports__, "WidgetLoader", function() { return /* reexport */ WidgetLoader; }); __webpack_require__.d(__webpack_exports__, "ClientWidgetRenderer", function() { return /* reexport */ ClientWidgetRenderer; }); __webpack_require__.d(__webpack_exports__, "WidgetContainer", function() { return /* reexport */ WidgetContainer; }); @@ -2612,6 +2615,24 @@ function clone(p) { } return JSON.parse(JSON.stringify(p)); } +// CONCATENATED MODULE: ./plugins/CoreHome/vue/src/ucfirst.ts +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ +/** + * Uppercase the first character of a string, leaving the rest untouched (e.g. "visits" -> + * "Visits"). Uses locale-aware Unicode casing; an empty or missing value yields an empty string. + */ +function ucfirst(text, locale) { + if (!text) { + return ''; + } + const [firstCharacter, ...remainingCharacters] = Array.from(text); + return firstCharacter.toLocaleUpperCase(locale || undefined) + remainingCharacters.join(''); +} // 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/CoreHome/vue/src/VueEntryContainer/VueEntryContainer.vue?vue&type=template&id=6cb9164b const _hoisted_1 = { @@ -4346,7 +4367,7 @@ class Comparisons_store_ComparisonsStore { })); Comparisons_store_defineProperty(this, "state", Object(external_commonjs_vue_commonjs2_vue_root_Vue_["readonly"])(this.privateState)); // for tests - Comparisons_store_defineProperty(this, "colors", {}); + Comparisons_store_defineProperty(this, "colors", Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])({})); Comparisons_store_defineProperty(this, "segmentComparisons", Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => this.parseSegmentComparisons())); Comparisons_store_defineProperty(this, "periodComparisons", Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => this.parsePeriodComparisons())); Comparisons_store_defineProperty(this, "isEnabled", Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => this.checkEnabledForCurrentPage())); @@ -4358,7 +4379,7 @@ class Comparisons_store_ComparisonsStore { }); } $(() => { - this.colors = this.getAllSeriesColors(); + this.colors.value = this.getAllSeriesColors(); }); Object(external_commonjs_vue_commonjs2_vue_root_Vue_["watch"])(() => this.getUrlStateWithoutPopoverKey(), () => Matomo_Matomo.postEvent('piwikComparisonsChanged')); } @@ -4392,10 +4413,10 @@ class Comparisons_store_ComparisonsStore { getSeriesColor(segmentComparison, periodComparison, metricIndex = 0) { const seriesIndex = this.getComparisonSeriesIndex(periodComparison.index, segmentComparison.index) % SERIES_COLOR_COUNT; if (metricIndex === 0) { - return this.colors[`series${seriesIndex}`]; + return this.colors.value[`series${seriesIndex}`]; } const shadeIndex = metricIndex % SERIES_SHADE_COUNT; - return this.colors[`series${seriesIndex}-shade${shadeIndex}`]; + return this.colors.value[`series${seriesIndex}-shade${shadeIndex}`]; } getSeriesColorName(seriesIndex, metricIndex) { let colorName = `series${seriesIndex % SERIES_COLOR_COUNT}`; @@ -4428,7 +4449,7 @@ class Comparisons_store_ComparisonsStore { seriesInfo.push({ index: seriesIndex, params: Object.assign(Object.assign({}, segmentComp.params), periodComp.params), - color: this.colors[`series${seriesIndex}`] + color: this.colors.value[`series${seriesIndex}`] }); seriesIndex += 1; }); @@ -10215,6 +10236,215 @@ class ReportMetadata_store_ReportMetadataStore { } } /* harmony default export */ var ReportMetadata_store = (new ReportMetadata_store_ReportMetadataStore()); +// 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/CoreHome/vue/src/ReportHeader/ReportHeader.vue?vue&type=template&id=4974b272 + +const ReportHeadervue_type_template_id_4974b272_hoisted_1 = { + class: "reportHeader" +}; +const ReportHeadervue_type_template_id_4974b272_hoisted_2 = { + class: "reportHeader__main" +}; +const ReportHeadervue_type_template_id_4974b272_hoisted_3 = ["role", "tabindex", "title"]; +const ReportHeadervue_type_template_id_4974b272_hoisted_4 = { + class: "u-visuallyHidden" +}; +const ReportHeadervue_type_template_id_4974b272_hoisted_5 = { + class: "reportHeader__widgetControls" +}; +const ReportHeadervue_type_template_id_4974b272_hoisted_6 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", { + class: "reportHeader__actions" +}, null, -1); +function ReportHeadervue_type_template_id_4974b272_render(_ctx, _cache, $props, $setup, $data, $options) { + const _component_WidgetControls = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("WidgetControls"); + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", ReportHeadervue_type_template_id_4974b272_hoisted_1, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", ReportHeadervue_type_template_id_4974b272_hoisted_2, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("h3", { + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(["reportHeader__title widgetName", { + 'reportHeader__title--clickable': _ctx.titleClickable + }]), + role: _ctx.titleClickable ? 'button' : undefined, + tabindex: _ctx.titleClickable ? 0 : undefined, + title: _ctx.titleClickable ? _ctx.titleClickHint : undefined, + onClick: _cache[0] || (_cache[0] = (...args) => _ctx.onTitleClick && _ctx.onTitleClick(...args)), + onKeydown: [_cache[1] || (_cache[1] = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withKeys"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withModifiers"])((...args) => _ctx.onTitleClick && _ctx.onTitleClick(...args), ["prevent"]), ["enter"])), _cache[2] || (_cache[2] = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withKeys"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withModifiers"])((...args) => _ctx.onTitleClick && _ctx.onTitleClick(...args), ["prevent"]), ["space"]))] + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.title), 1)], 42, ReportHeadervue_type_template_id_4974b272_hoisted_3), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", ReportHeadervue_type_template_id_4974b272_hoisted_4, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('General_Widget')), 1)]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", ReportHeadervue_type_template_id_4974b272_hoisted_5, [_ctx.hasControls ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createBlock"])(_component_WidgetControls, { + key: 0, + "can-minimise": _ctx.controls.minimise, + "can-maximise": _ctx.controls.maximise, + "can-refresh": _ctx.controls.refresh, + "can-close": _ctx.controls.close, + onMinimise: _cache[3] || (_cache[3] = $event => _ctx.onControl('minimise')), + onMaximise: _cache[4] || (_cache[4] = $event => _ctx.onControl('maximise')), + onRefresh: _cache[5] || (_cache[5] = $event => _ctx.onControl('refresh')), + onClose: _cache[6] || (_cache[6] = $event => _ctx.onControl('close')) + }, null, 8, ["can-minimise", "can-maximise", "can-refresh", "can-close"])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)]), ReportHeadervue_type_template_id_4974b272_hoisted_6]); +} +// CONCATENATED MODULE: ./plugins/CoreHome/vue/src/ReportHeader/ReportHeader.vue?vue&type=template&id=4974b272 + +// 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/CoreHome/vue/src/WidgetControls/WidgetControls.vue?vue&type=template&id=4a7a326c + +const WidgetControlsvue_type_template_id_4a7a326c_hoisted_1 = { + class: "widgetControls" +}; +const WidgetControlsvue_type_template_id_4a7a326c_hoisted_2 = ["title", "aria-label", "onClick"]; +function WidgetControlsvue_type_template_id_4a7a326c_render(_ctx, _cache, $props, $setup, $data, $options) { + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", WidgetControlsvue_type_template_id_4a7a326c_hoisted_1, [(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.visibleControls, control => { + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("button", { + key: control.id, + type: "button", + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(["widgetControls__action", `widgetControls__action--${control.id}`]), + title: control.label, + "aria-label": control.label, + onClick: $event => _ctx.$emit(control.id) + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(["widgetControls__icon", control.icon]) + }, null, 2)], 10, WidgetControlsvue_type_template_id_4a7a326c_hoisted_2); + }), 128))]); +} +// CONCATENATED MODULE: ./plugins/CoreHome/vue/src/WidgetControls/WidgetControls.vue?vue&type=template&id=4a7a326c + +// 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/CoreHome/vue/src/WidgetControls/WidgetControls.vue?vue&type=script&lang=ts + + +/* harmony default export */ var WidgetControlsvue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + props: { + canMinimise: Boolean, + canMaximise: Boolean, + canRefresh: Boolean, + canClose: Boolean + }, + emits: ['minimise', 'maximise', 'refresh', 'close'], + computed: { + visibleControls() { + const controls = [{ + id: 'refresh', + icon: 'icon-reload', + label: translate('General_Refresh'), + visible: this.canRefresh + }, { + id: 'minimise', + icon: 'icon-minimise', + label: translate('Dashboard_Minimise'), + visible: this.canMinimise + }, { + id: 'maximise', + icon: 'icon-fullscreen', + label: translate('Dashboard_Maximise'), + visible: this.canMaximise + }, { + id: 'close', + icon: 'icon-close', + label: translate('General_Close'), + visible: this.canClose + }]; + return controls.filter(control => control.visible); + } + } +})); +// CONCATENATED MODULE: ./plugins/CoreHome/vue/src/WidgetControls/WidgetControls.vue?vue&type=script&lang=ts + +// CONCATENATED MODULE: ./plugins/CoreHome/vue/src/WidgetControls/WidgetControls.vue + + + +WidgetControlsvue_type_script_lang_ts.render = WidgetControlsvue_type_template_id_4a7a326c_render + +/* harmony default export */ var WidgetControls = (WidgetControlsvue_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/CoreHome/vue/src/ReportHeader/ReportHeader.vue?vue&type=script&lang=ts + + + +// Which widget controls each context exposes. Kept here so every surface that renders +// the header stays consistent with the redesign spec. `dashboard` is the normal widget state +// (all controls only make sense on a dashboard); `maximised`/`collapsed` are its state +// variants; `widgetized`/`preview` render no controls. Consumers outside a widget (e.g. +// full-page reports) pass a no-control context. +const CONTROLS_BY_CONTEXT = { + dashboard: { + minimise: true, + maximise: true, + refresh: true, + close: true + }, + maximised: { + minimise: true, + maximise: false, + refresh: true, + close: false + }, + collapsed: { + minimise: false, + maximise: true, + refresh: false, + close: true + }, + widgetized: { + minimise: false, + maximise: false, + refresh: false, + close: false + }, + preview: { + minimise: false, + maximise: false, + refresh: false, + close: false + } +}; +/* harmony default export */ var ReportHeadervue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + props: { + context: { + type: String, + default: 'dashboard' + }, + title: { + type: String, + default: '' + }, + titleClickable: Boolean, + titleClickHint: { + type: String, + default: '' + } + }, + components: { + WidgetControls: WidgetControls + }, + emits: ['minimise', 'maximise', 'refresh', 'close', 'titleClick'], + computed: { + controls() { + return CONTROLS_BY_CONTEXT[this.context] || CONTROLS_BY_CONTEXT.widgetized; + }, + hasControls() { + const c = this.controls; + return c.minimise || c.maximise || c.refresh || c.close; + } + }, + methods: { + translate: translate, + onTitleClick() { + if (this.titleClickable) { + this.$emit('titleClick'); + } + }, + onControl(intent) { + // Re-emit for Vue-native consumers... + this.$emit(intent); + // ...and dispatch a bubbling native event so non-Vue owners (the jQuery dashboard + // widget) can bridge control intents back to their existing handlers. + this.$el.dispatchEvent(new CustomEvent(`widgetcontrol:${intent}`, { + bubbles: true + })); + } + } +})); +// CONCATENATED MODULE: ./plugins/CoreHome/vue/src/ReportHeader/ReportHeader.vue?vue&type=script&lang=ts + +// CONCATENATED MODULE: ./plugins/CoreHome/vue/src/ReportHeader/ReportHeader.vue + + + +ReportHeadervue_type_script_lang_ts.render = ReportHeadervue_type_template_id_4974b272_render + +/* harmony default export */ var ReportHeader = (ReportHeadervue_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/CoreHome/vue/src/WidgetLoader/WidgetLoader.vue?vue&type=template&id=24b8f926 const WidgetLoadervue_type_template_id_24b8f926_hoisted_1 = { @@ -12063,10 +12293,10 @@ const { }); } }); -// 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/CoreHome/vue/src/Sparkline/Sparkline.vue?vue&type=template&id=6902c51a +// 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/CoreHome/vue/src/Sparkline/Sparkline.vue?vue&type=template&id=197ce498 -const Sparklinevue_type_template_id_6902c51a_hoisted_1 = ["src", "width", "height"]; -function Sparklinevue_type_template_id_6902c51a_render(_ctx, _cache, $props, $setup, $data, $options) { +const Sparklinevue_type_template_id_197ce498_hoisted_1 = ["src", "width", "height"]; +function Sparklinevue_type_template_id_197ce498_render(_ctx, _cache, $props, $setup, $data, $options) { return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("img", { class: "sparklineImg", loading: "lazy", @@ -12074,9 +12304,9 @@ function Sparklinevue_type_template_id_6902c51a_render(_ctx, _cache, $props, $se src: _ctx.sparklineUrl, width: _ctx.width, height: _ctx.height - }, null, 8, Sparklinevue_type_template_id_6902c51a_hoisted_1); + }, null, 8, Sparklinevue_type_template_id_197ce498_hoisted_1); } -// CONCATENATED MODULE: ./plugins/CoreHome/vue/src/Sparkline/Sparkline.vue?vue&type=template&id=6902c51a +// CONCATENATED MODULE: ./plugins/CoreHome/vue/src/Sparkline/Sparkline.vue?vue&type=template&id=197ce498 // 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/CoreHome/vue/src/Sparkline/Sparkline.vue?vue&type=script&lang=ts @@ -12118,16 +12348,13 @@ function Sparklinevue_type_template_id_6902c51a_render(_ctx, _cache, $props, $se sparklineColors.lineColor = sparklineColors.lineColor.filter((c, index) => seriesIndices.indexOf(index) !== -1); } const colors = JSON.stringify(sparklineColors); - // The redesign lets sparklines be rendered server-side at a custom size; without it the - // width/height props only control the displayed size and the server uses its defaults. // The width/height props are the displayed size; the PNG is rendered at twice that so it - // stays crisp on hi-DPI screens (matching the legacy 200x50-render / 100x25-display ratio). - const redesignEnabled = document.body.classList.contains('sparklines-redesign-enabled'); - const sizeParams = redesignEnabled ? Object.assign(Object.assign({}, typeof this.width === 'number' ? { + // stays crisp on hi-DPI screens. + const sizeParams = Object.assign(Object.assign({}, typeof this.width === 'number' ? { width: this.width * 2 } : {}), typeof this.height === 'number' ? { height: this.height * 2 - } : {}) : {}; + } : {}); const defaultParams = Object.assign(Object.assign({ forceView: '1', viewDataTable: 'sparkline', @@ -12179,7 +12406,7 @@ function Sparklinevue_type_template_id_6902c51a_render(_ctx, _cache, $props, $se -Sparklinevue_type_script_lang_ts.render = Sparklinevue_type_template_id_6902c51a_render +Sparklinevue_type_script_lang_ts.render = Sparklinevue_type_template_id_197ce498_render /* harmony default export */ var Sparkline = (Sparklinevue_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/CoreHome/vue/src/Progressbar/Progressbar.vue?vue&type=template&id=f800d6ec @@ -12506,46 +12733,46 @@ function Passthroughvue_type_template_id_31c1d52c_render(_ctx, _cache, $props, $ Passthroughvue_type_script_lang_ts.render = Passthroughvue_type_template_id_31c1d52c_render /* harmony default export */ var Passthrough = (Passthroughvue_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/CoreHome/vue/src/DataTable/DataTableActions.vue?vue&type=template&id=5f23fa4e +// 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/CoreHome/vue/src/DataTable/DataTableActions.vue?vue&type=template&id=afa4f368 -const DataTableActionsvue_type_template_id_5f23fa4e_hoisted_1 = { +const DataTableActionsvue_type_template_id_afa4f368_hoisted_1 = { key: 0 }; -const DataTableActionsvue_type_template_id_5f23fa4e_hoisted_2 = ["data-target", "title"]; -const DataTableActionsvue_type_template_id_5f23fa4e_hoisted_3 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { +const DataTableActionsvue_type_template_id_afa4f368_hoisted_2 = ["data-target", "title"]; +const DataTableActionsvue_type_template_id_afa4f368_hoisted_3 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { class: "icon-configure" }, null, -1); -const DataTableActionsvue_type_template_id_5f23fa4e_hoisted_4 = { +const DataTableActionsvue_type_template_id_afa4f368_hoisted_4 = { class: "visually-hidden" }; -const DataTableActionsvue_type_template_id_5f23fa4e_hoisted_5 = ["data-target"]; -const DataTableActionsvue_type_template_id_5f23fa4e_hoisted_6 = ["title"]; -const DataTableActionsvue_type_template_id_5f23fa4e_hoisted_7 = ["title", "src"]; -const DataTableActionsvue_type_template_id_5f23fa4e_hoisted_8 = ["id"]; -const DataTableActionsvue_type_template_id_5f23fa4e_hoisted_9 = ["data-footer-icon-id"]; -const DataTableActionsvue_type_template_id_5f23fa4e_hoisted_10 = ["title"]; -const DataTableActionsvue_type_template_id_5f23fa4e_hoisted_11 = ["title", "src"]; -const DataTableActionsvue_type_template_id_5f23fa4e_hoisted_12 = { +const DataTableActionsvue_type_template_id_afa4f368_hoisted_5 = ["data-target"]; +const DataTableActionsvue_type_template_id_afa4f368_hoisted_6 = ["title"]; +const DataTableActionsvue_type_template_id_afa4f368_hoisted_7 = ["title", "src"]; +const DataTableActionsvue_type_template_id_afa4f368_hoisted_8 = ["id"]; +const DataTableActionsvue_type_template_id_afa4f368_hoisted_9 = ["data-footer-icon-id"]; +const DataTableActionsvue_type_template_id_afa4f368_hoisted_10 = ["title"]; +const DataTableActionsvue_type_template_id_afa4f368_hoisted_11 = ["title", "src"]; +const DataTableActionsvue_type_template_id_afa4f368_hoisted_12 = { key: 2 }; -const DataTableActionsvue_type_template_id_5f23fa4e_hoisted_13 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("li", { +const DataTableActionsvue_type_template_id_afa4f368_hoisted_13 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("li", { class: "divider" }, null, -1); -const DataTableActionsvue_type_template_id_5f23fa4e_hoisted_14 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("li", { +const DataTableActionsvue_type_template_id_afa4f368_hoisted_14 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("li", { class: "divider" }, null, -1); -const DataTableActionsvue_type_template_id_5f23fa4e_hoisted_15 = ["title"]; -const DataTableActionsvue_type_template_id_5f23fa4e_hoisted_16 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { +const DataTableActionsvue_type_template_id_afa4f368_hoisted_15 = ["title"]; +const DataTableActionsvue_type_template_id_afa4f368_hoisted_16 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { class: "icon-export" }, null, -1); -const DataTableActionsvue_type_template_id_5f23fa4e_hoisted_17 = { +const DataTableActionsvue_type_template_id_afa4f368_hoisted_17 = { class: "visually-hidden" }; -const DataTableActionsvue_type_template_id_5f23fa4e_hoisted_18 = ["title"]; -const DataTableActionsvue_type_template_id_5f23fa4e_hoisted_19 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { +const DataTableActionsvue_type_template_id_afa4f368_hoisted_18 = ["title"]; +const DataTableActionsvue_type_template_id_afa4f368_hoisted_19 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { class: "icon-image" }, null, -1); -const _hoisted_20 = [DataTableActionsvue_type_template_id_5f23fa4e_hoisted_19]; +const _hoisted_20 = [DataTableActionsvue_type_template_id_afa4f368_hoisted_19]; const _hoisted_21 = ["title"]; const _hoisted_22 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { class: "icon-annotation" @@ -12580,25 +12807,29 @@ const _hoisted_38 = ["innerHTML"]; const _hoisted_39 = { key: 4 }; -const _hoisted_40 = ["innerHTML"]; +const _hoisted_40 = ["aria-label", "innerHTML"]; const _hoisted_41 = { key: 5 }; const _hoisted_42 = ["innerHTML"]; -const _hoisted_43 = ["title", "data-target"]; -const _hoisted_44 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { +const _hoisted_43 = { + key: 6 +}; +const _hoisted_44 = ["innerHTML"]; +const _hoisted_45 = ["title", "data-target"]; +const _hoisted_46 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { class: "icon-calendar" }, null, -1); -const _hoisted_45 = { +const _hoisted_47 = { class: "periodName" }; -const _hoisted_46 = ["id"]; -const _hoisted_47 = ["data-period"]; -function DataTableActionsvue_type_template_id_5f23fa4e_render(_ctx, _cache, $props, $setup, $data, $options) { +const _hoisted_48 = ["id"]; +const _hoisted_49 = ["data-period"]; +function DataTableActionsvue_type_template_id_afa4f368_render(_ctx, _cache, $props, $setup, $data, $options) { const _component_Passthrough = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("Passthrough"); const _directive_dropdown_button = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveDirective"])("dropdown-button"); const _directive_report_export = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveDirective"])("report-export"); - return _ctx.showFooter && _ctx.showFooterIcons ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", DataTableActionsvue_type_template_id_5f23fa4e_hoisted_1, [_ctx.hasConfigItems && (_ctx.isAnyConfigureIconHighlighted || _ctx.isTableView) ? Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])((Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("a", { + return _ctx.showFooter && _ctx.showFooterIcons ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", DataTableActionsvue_type_template_id_afa4f368_hoisted_1, [_ctx.hasConfigItems && (_ctx.isAnyConfigureIconHighlighted || _ctx.isTableView) ? Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])((Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("a", { key: 0, class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(["dropdown-button dropdownConfigureIcon dataTableAction", { highlighted: _ctx.isAnyConfigureIconHighlighted @@ -12610,7 +12841,7 @@ function DataTableActionsvue_type_template_id_5f23fa4e_render(_ctx, _cache, $pro style: { "margin-right": "3.5px" } - }, [DataTableActionsvue_type_template_id_5f23fa4e_hoisted_3, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", DataTableActionsvue_type_template_id_5f23fa4e_hoisted_4, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CoreHome_ReportConfiguration')), 1)], 10, DataTableActionsvue_type_template_id_5f23fa4e_hoisted_2)), [[_directive_dropdown_button]]) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), _ctx.hasFooterIconsToShow ? Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])((Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("a", { + }, [DataTableActionsvue_type_template_id_afa4f368_hoisted_3, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", DataTableActionsvue_type_template_id_afa4f368_hoisted_4, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('CoreHome_ReportConfiguration')), 1)], 10, DataTableActionsvue_type_template_id_afa4f368_hoisted_2)), [[_directive_dropdown_button]]) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), _ctx.hasFooterIconsToShow ? Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])((Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("a", { key: 1, class: "dropdown-button dataTableAction activateVisualizationSelection", href: "", @@ -12623,13 +12854,13 @@ function DataTableActionsvue_type_template_id_5f23fa4e_render(_ctx, _cache, $pro key: 0, title: _ctx.translate('CoreHome_ChangeVisualization'), class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(_ctx.activeFooterIcon) - }, null, 10, DataTableActionsvue_type_template_id_5f23fa4e_hoisted_6)) : (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("img", { + }, null, 10, DataTableActionsvue_type_template_id_afa4f368_hoisted_6)) : (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("img", { key: 1, title: _ctx.translate('CoreHome_ChangeVisualization'), width: "16", height: "16", src: _ctx.activeFooterIcon - }, null, 8, DataTableActionsvue_type_template_id_5f23fa4e_hoisted_7))], 8, DataTableActionsvue_type_template_id_5f23fa4e_hoisted_5)), [[_directive_dropdown_button]]) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), _ctx.showFooterIcons ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("ul", { + }, null, 8, DataTableActionsvue_type_template_id_afa4f368_hoisted_7))], 8, DataTableActionsvue_type_template_id_afa4f368_hoisted_5)), [[_directive_dropdown_button]]) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), _ctx.showFooterIcons ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("ul", { key: 2, id: `dropdownVisualizations${_ctx.randomIdForDropdown}`, class: "dropdown-content dataTableFooterIcons" @@ -12651,7 +12882,7 @@ function DataTableActionsvue_type_template_id_5f23fa4e_render(_ctx, _cache, $pro style: { "margin-right": "5.5px" } - }, null, 10, DataTableActionsvue_type_template_id_5f23fa4e_hoisted_10)) : (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("img", { + }, null, 10, DataTableActionsvue_type_template_id_afa4f368_hoisted_10)) : (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("img", { key: 1, width: "16", height: "16", @@ -12660,11 +12891,11 @@ function DataTableActionsvue_type_template_id_5f23fa4e_render(_ctx, _cache, $pro style: { "margin-right": "5.5px" } - }, null, 8, DataTableActionsvue_type_template_id_5f23fa4e_hoisted_11)), footerIcon.title ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", DataTableActionsvue_type_template_id_5f23fa4e_hoisted_12, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(footerIcon.title), 1)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)], 10, DataTableActionsvue_type_template_id_5f23fa4e_hoisted_9)]); - }), 128)), DataTableActionsvue_type_template_id_5f23fa4e_hoisted_13]), + }, null, 8, DataTableActionsvue_type_template_id_afa4f368_hoisted_11)), footerIcon.title ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", DataTableActionsvue_type_template_id_afa4f368_hoisted_12, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(footerIcon.title), 1)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)], 10, DataTableActionsvue_type_template_id_afa4f368_hoisted_9)]); + }), 128)), DataTableActionsvue_type_template_id_afa4f368_hoisted_13]), _: 2 }, 1024); - }), 128)), DataTableActionsvue_type_template_id_5f23fa4e_hoisted_14], 8, DataTableActionsvue_type_template_id_5f23fa4e_hoisted_8)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), _ctx.showExport ? Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])((Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("a", { + }), 128)), DataTableActionsvue_type_template_id_afa4f368_hoisted_14], 8, DataTableActionsvue_type_template_id_afa4f368_hoisted_8)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), _ctx.showExport ? Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])((Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("a", { key: 3, class: "dataTableAction activateExportSelection", title: _ctx.translate('General_ExportThisReport'), @@ -12673,7 +12904,7 @@ function DataTableActionsvue_type_template_id_5f23fa4e_render(_ctx, _cache, $pro "margin-right": "3.5px" }, onClick: _cache[2] || (_cache[2] = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withModifiers"])(() => {}, ["prevent"])) - }, [DataTableActionsvue_type_template_id_5f23fa4e_hoisted_16, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", DataTableActionsvue_type_template_id_5f23fa4e_hoisted_17, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('General_ExportThisReport')), 1)], 8, DataTableActionsvue_type_template_id_5f23fa4e_hoisted_15)), [[_directive_report_export, { + }, [DataTableActionsvue_type_template_id_afa4f368_hoisted_16, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", DataTableActionsvue_type_template_id_afa4f368_hoisted_17, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('General_ExportThisReport')), 1)], 8, DataTableActionsvue_type_template_id_afa4f368_hoisted_15)), [[_directive_report_export, { reportTitle: _ctx.reportTitle, requestParams: _ctx.requestParams, apiMethod: _ctx.apiMethodToRequestDataTable, @@ -12690,7 +12921,7 @@ function DataTableActionsvue_type_template_id_5f23fa4e_render(_ctx, _cache, $pro style: { "margin-right": "3.5px" } - }, _hoisted_20, 8, DataTableActionsvue_type_template_id_5f23fa4e_hoisted_18)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), _ctx.showAnnotations ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("a", { + }, _hoisted_20, 8, DataTableActionsvue_type_template_id_afa4f368_hoisted_18)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), _ctx.showAnnotations ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("a", { key: 5, class: "dataTableAction annotationView", href: "", @@ -12753,20 +12984,24 @@ function DataTableActionsvue_type_template_id_5f23fa4e_render(_ctx, _cache, $pro }, null, 8, _hoisted_36)])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), _ctx.showTotalsConfigItem ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("li", _hoisted_37, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", { class: "configItem dataTableShowTotalsRow", innerHTML: _ctx.$sanitize(_ctx.keepTotalsRowText) - }, null, 8, _hoisted_38)])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), _ctx.showExcludeLowPopulation ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("li", _hoisted_39, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", { + }, null, 8, _hoisted_38)])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), _ctx.showPercentageValuesConfigItem ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("li", _hoisted_39, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", { + class: "configItem dataTableShowPercentageValues", + "aria-label": _ctx.percentageValuesLabel, + innerHTML: _ctx.$sanitize(_ctx.percentageValuesText) + }, null, 8, _hoisted_40)])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), _ctx.showExcludeLowPopulation ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("li", _hoisted_41, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", { class: "configItem dataTableExcludeLowPopulation", innerHTML: _ctx.$sanitize(_ctx.excludeLowPopText) - }, null, 8, _hoisted_40)])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), _ctx.showPivotBySubtable ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("li", _hoisted_41, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", { + }, null, 8, _hoisted_42)])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), _ctx.showPivotBySubtable ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("li", _hoisted_43, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", { class: "configItem dataTablePivotBySubtable", innerHTML: _ctx.$sanitize(_ctx.pivotByText) - }, null, 8, _hoisted_42)])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)], 8, _hoisted_30), _ctx.showPeriods ? Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])((Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("a", { + }, null, 8, _hoisted_44)])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)], 8, _hoisted_30), _ctx.showPeriods ? Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])((Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("a", { key: 7, class: "dropdown-button dataTableAction activatePeriodsSelection", href: "", onClick: _cache[7] || (_cache[7] = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withModifiers"])(() => {}, ["prevent"])), title: _ctx.translate('CoreHome_ChangePeriod'), "data-target": `dropdownPeriods${_ctx.randomIdForDropdown}` - }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", null, [_hoisted_44, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", _hoisted_45, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translations[_ctx.clientSideParameters.period] || _ctx.clientSideParameters.period), 1)])], 8, _hoisted_43)), [[_directive_dropdown_button]]) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), _ctx.showPeriods ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("ul", { + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", null, [_hoisted_46, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", _hoisted_47, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translations[_ctx.clientSideParameters.period] || _ctx.clientSideParameters.period), 1)])], 8, _hoisted_45)), [[_directive_dropdown_button]]) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), _ctx.showPeriods ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("ul", { key: 8, id: `dropdownPeriods${_ctx.randomIdForDropdown}`, class: "dropdown-content dataTablePeriods" @@ -12776,10 +13011,10 @@ function DataTableActionsvue_type_template_id_5f23fa4e_render(_ctx, _cache, $pro }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("a", { "data-period": selectablePeriod, class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(`tableIcon ${_ctx.clientSideParameters.period === selectablePeriod ? 'activeIcon' : ''}`) - }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translations[selectablePeriod] || selectablePeriod), 1)], 10, _hoisted_47)]); - }), 128))], 8, _hoisted_46)) : 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"])("span", null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translations[selectablePeriod] || selectablePeriod), 1)], 10, _hoisted_49)]); + }), 128))], 8, _hoisted_48)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true); } -// CONCATENATED MODULE: ./plugins/CoreHome/vue/src/DataTable/DataTableActions.vue?vue&type=template&id=5f23fa4e +// CONCATENATED MODULE: ./plugins/CoreHome/vue/src/DataTable/DataTableActions.vue?vue&type=template&id=afa4f368 // CONCATENATED MODULE: ./plugins/CoreHome/vue/src/DataTable/DataTableActions.utils.ts /*! @@ -12833,6 +13068,7 @@ function getToggledIconText(toggled, textToggled, textUntoggled) { showSearch: Boolean, showFlattenTable: Boolean, reportSupportsFlatten: Boolean, + reportSupportsPercentageValues: Boolean, exportSupportsFlatten: Boolean, footerIcons: { type: Array, @@ -12954,8 +13190,11 @@ function getToggledIconText(toggled, textToggled, textUntoggled) { showTotalsConfigItem() { return !this.isDataTableEmpty && this.showTotalsRow; }, + showPercentageValuesConfigItem() { + return !this.isDataTableEmpty && this.reportSupportsPercentageValues; + }, hasConfigItems() { - return this.showFlattenTable || this.showDimensionsConfigItem || this.showFlatConfigItem || this.showTotalsConfigItem || this.showExcludeLowPopulation || this.showPivotBySubtable; + return this.showFlattenTable || this.showDimensionsConfigItem || this.showFlatConfigItem || this.showTotalsConfigItem || this.showExcludeLowPopulation || this.showPivotBySubtable || this.showPercentageValuesConfigItem; }, flattenItemText() { const params = this.clientSideParameters; @@ -12965,6 +13204,14 @@ function getToggledIconText(toggled, textToggled, textUntoggled) { const params = this.clientSideParameters; return getToggledIconText(isBooleanLikeSet(params.keep_totals_row), 'CoreHome_RemoveTotalsRowDataTable', 'CoreHome_AddTotalsRowDataTable'); }, + percentageValuesText() { + const params = this.clientSideParameters; + return getToggledIconText(isBooleanLikeSet(params.show_percentage_values), 'CoreHome_ShowAbsoluteValuesDataTable', 'CoreHome_ShowPercentageValuesDataTable'); + }, + percentageValuesLabel() { + const params = this.clientSideParameters; + return isBooleanLikeSet(params.show_percentage_values) ? translate('CoreHome_ShowAbsoluteValues') : translate('CoreHome_ShowPercentageValues'); + }, includeAggregateRowsText() { const params = this.clientSideParameters; return getToggledIconText(isBooleanLikeSet(params.include_aggregate_rows), 'CoreHome_DataTableExcludeAggregateRows', 'CoreHome_DataTableIncludeAggregateRows'); @@ -12986,7 +13233,7 @@ function getToggledIconText(toggled, textToggled, textUntoggled) { }, isAnyConfigureIconHighlighted() { const params = this.clientSideParameters; - return isBooleanLikeSet(params.flat) || isBooleanLikeSet(params.keep_totals_row) || isBooleanLikeSet(params.include_aggregate_rows) || isBooleanLikeSet(params.show_dimensions) || isBooleanLikeSet(params.pivotBy) || isBooleanLikeSet(params.enable_filter_excludelowpop); + return isBooleanLikeSet(params.flat) || isBooleanLikeSet(params.keep_totals_row) || isBooleanLikeSet(params.include_aggregate_rows) || isBooleanLikeSet(params.show_dimensions) || isBooleanLikeSet(params.pivotBy) || isBooleanLikeSet(params.enable_filter_excludelowpop) || isBooleanLikeSet(params.show_percentage_values); }, isTableView() { return this.viewDataTable === 'table' || this.viewDataTable === 'tableAllColumns' || this.viewDataTable === 'tableGoals'; @@ -12999,7 +13246,7 @@ function getToggledIconText(toggled, textToggled, textUntoggled) { -DataTableActionsvue_type_script_lang_ts.render = DataTableActionsvue_type_template_id_5f23fa4e_render +DataTableActionsvue_type_script_lang_ts.render = DataTableActionsvue_type_template_id_afa4f368_render /* harmony default export */ var DataTableActions = (DataTableActionsvue_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/CoreHome/vue/src/VersionInfoHeaderMessage/VersionInfoHeaderMessage.vue?vue&type=template&id=23661bee @@ -14196,6 +14443,9 @@ class EntityDuplicatorStore_EntityDuplicatorStore { + + + diff --git a/app/plugins/CoreHome/vue/dist/CoreHome.umd.min.js b/app/plugins/CoreHome/vue/dist/CoreHome.umd.min.js index 7e4c5f61c..abf15ee6d 100644 --- a/app/plugins/CoreHome/vue/dist/CoreHome.umd.min.js +++ b/app/plugins/CoreHome/vue/dist/CoreHome.umd.min.js @@ -4,7 +4,7 @@ * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */window.hasBlockedContent=!1},"8bbf":function(t,o){t.exports=e},fae3:function(e,t,o){"use strict";if(o.r(t),o.d(t,"createVueApp",(function(){return ve})),o.d(t,"importPluginUmd",(function(){return Se})),o.d(t,"useExternalPluginComponent",(function(){return Ce})),o.d(t,"DirectiveUtilities",(function(){return De})),o.d(t,"debounce",(function(){return Pe})),o.d(t,"clone",(function(){return Te})),o.d(t,"VueEntryContainer",(function(){return Ne})),o.d(t,"ActivityIndicator",(function(){return We})),o.d(t,"MatomoLoader",(function(){return Ue})),o.d(t,"translate",(function(){return a})),o.d(t,"translateOrDefault",(function(){return r})),o.d(t,"externalRawLink",(function(){return ue})),o.d(t,"externalLink",(function(){return pe})),o.d(t,"Alert",(function(){return Ke})),o.d(t,"AjaxHelper",(function(){return te})),o.d(t,"setCookie",(function(){return q})),o.d(t,"getCookie",(function(){return W})),o.d(t,"deleteCookie",(function(){return z})),o.d(t,"MatomoUrl",(function(){return U})),o.d(t,"Matomo",(function(){return M})),o.d(t,"Periods",(function(){return c})),o.d(t,"Day",(function(){return f})),o.d(t,"Week",(function(){return O})),o.d(t,"Month",(function(){return j})),o.d(t,"Year",(function(){return S})),o.d(t,"Range",(function(){return k})),o.d(t,"format",(function(){return d})),o.d(t,"getToday",(function(){return u})),o.d(t,"parseDate",(function(){return p})),o.d(t,"todayIsInRange",(function(){return m})),o.d(t,"getWeekNumber",(function(){return h})),o.d(t,"datesAreInTheSamePeriod",(function(){return g})),o.d(t,"NumberFormatter",(function(){return ae})),o.d(t,"formatNumber",(function(){return me})),o.d(t,"formatPercent",(function(){return he})),o.d(t,"formatCurrency",(function(){return ge})),o.d(t,"formatEvolution",(function(){return be})),o.d(t,"calculateAndFormatEvolution",(function(){return fe})),o.d(t,"DropdownMenu",(function(){return Ye})),o.d(t,"FocusAnywhereButHere",(function(){return tt})),o.d(t,"FocusIf",(function(){return it})),o.d(t,"Tooltips",(function(){return ct})),o.d(t,"MatomoDialog",(function(){return mt})),o.d(t,"MatomoModal",(function(){return vt})),o.d(t,"ExpandOnClick",(function(){return Et})),o.d(t,"ExpandOnHover",(function(){return Nt})),o.d(t,"ShowSensitiveData",(function(){return Mt})),o.d(t,"DropdownButton",(function(){return Rt})),o.d(t,"DraggableList",(function(){return _t})),o.d(t,"SelectOnFocus",(function(){return Wt})),o.d(t,"CopyToClipboard",(function(){return Kt})),o.d(t,"SideNav",(function(){return Jt})),o.d(t,"EnrichedHeadline",(function(){return go})),o.d(t,"ContentBlock",(function(){return Do})),o.d(t,"Comparisons",(function(){return Ko})),o.d(t,"ComparisonsStore",(function(){return Wo})),o.d(t,"ComparisonsStoreInstance",(function(){return zo})),o.d(t,"MenuItemsDropdown",(function(){return si})),o.d(t,"DatePicker",(function(){return mi})),o.d(t,"DateRangePicker",(function(){return Ci})),o.d(t,"PeriodDatePicker",(function(){return Mi})),o.d(t,"Notification",(function(){return Wi})),o.d(t,"NotificationGroup",(function(){return tn})),o.d(t,"NotificationsStore",(function(){return Zi})),o.d(t,"ShowHelpLink",(function(){return ln})),o.d(t,"SitesStore",(function(){return un})),o.d(t,"SiteSelector",(function(){return Bn})),o.d(t,"QuickAccess",(function(){return sa})),o.d(t,"SearchInput",(function(){return ma})),o.d(t,"FieldArray",(function(){return ya})),o.d(t,"MultiPairField",(function(){return Ta})),o.d(t,"PeriodSelector",(function(){return rs})),o.d(t,"ReportingMenu",(function(){return Bs})),o.d(t,"ReportingMenuStore",(function(){return oa})),o.d(t,"ReportingPagesStore",(function(){return Gn})),o.d(t,"ReportMetadataStore",(function(){return Ms})),o.d(t,"WidgetsStore",(function(){return Ts})),o.d(t,"WidgetLoader",(function(){return Gs})),o.d(t,"ClientWidgetRenderer",(function(){return Qs})),o.d(t,"WidgetContainer",(function(){return tl})),o.d(t,"WidgetByDimensionContainer",(function(){return ul})),o.d(t,"Widget",(function(){return vl})),o.d(t,"ReportingPage",(function(){return Nl})),o.d(t,"ReportExport",(function(){return lc})),o.d(t,"Sparkline",(function(){return pc})),o.d(t,"Progressbar",(function(){return vc})),o.d(t,"ContentIntro",(function(){return Oc})),o.d(t,"ContentTable",(function(){return xc})),o.d(t,"AjaxForm",(function(){return Mc})),o.d(t,"Passthrough",(function(){return Lc})),o.d(t,"DataTableActions",(function(){return Ad})),o.d(t,"VersionInfoHeaderMessage",(function(){return Xd})),o.d(t,"MobileLeftMenu",(function(){return su})),o.d(t,"scrollToAnchorInUrl",(function(){return gu})),o.d(t,"SearchFiltersPersistenceStore",(function(){return Ws})),o.d(t,"AutoClearPassword",(function(){return vu})),o.d(t,"PasswordStrength",(function(){return wu})),o.d(t,"EntityDuplicatorModal",(function(){return $u})),o.d(t,"EntityDuplicatorAction",(function(){return zu})),o.d(t,"EntityDuplicatorStore",(function(){return Qu})),o.d(t,"BaseDuplicatorAdapter",(function(){return Ku})),"undefined"!==typeof window){var i=window.document.currentScript,n=i&&i.src.match(/(.+\/)[^/]+\.js(\?.*)?$/);n&&(o.p=n[1])}o("2342"); + */window.hasBlockedContent=!1},"8bbf":function(t,o){t.exports=e},fae3:function(e,t,o){"use strict";if(o.r(t),o.d(t,"createVueApp",(function(){return ve})),o.d(t,"importPluginUmd",(function(){return Se})),o.d(t,"useExternalPluginComponent",(function(){return Ce})),o.d(t,"DirectiveUtilities",(function(){return De})),o.d(t,"debounce",(function(){return Pe})),o.d(t,"clone",(function(){return Te})),o.d(t,"ucfirst",(function(){return xe})),o.d(t,"VueEntryContainer",(function(){return Me})),o.d(t,"ActivityIndicator",(function(){return ze})),o.d(t,"MatomoLoader",(function(){return qe})),o.d(t,"translate",(function(){return a})),o.d(t,"translateOrDefault",(function(){return r})),o.d(t,"externalRawLink",(function(){return ue})),o.d(t,"externalLink",(function(){return me})),o.d(t,"Alert",(function(){return Ye})),o.d(t,"AjaxHelper",(function(){return te})),o.d(t,"setCookie",(function(){return q})),o.d(t,"getCookie",(function(){return W})),o.d(t,"deleteCookie",(function(){return z})),o.d(t,"MatomoUrl",(function(){return U})),o.d(t,"Matomo",(function(){return I})),o.d(t,"Periods",(function(){return c})),o.d(t,"Day",(function(){return f})),o.d(t,"Week",(function(){return O})),o.d(t,"Month",(function(){return j})),o.d(t,"Year",(function(){return S})),o.d(t,"Range",(function(){return k})),o.d(t,"format",(function(){return d})),o.d(t,"getToday",(function(){return u})),o.d(t,"parseDate",(function(){return m})),o.d(t,"todayIsInRange",(function(){return p})),o.d(t,"getWeekNumber",(function(){return h})),o.d(t,"datesAreInTheSamePeriod",(function(){return g})),o.d(t,"NumberFormatter",(function(){return ae})),o.d(t,"formatNumber",(function(){return pe})),o.d(t,"formatPercent",(function(){return he})),o.d(t,"formatCurrency",(function(){return ge})),o.d(t,"formatEvolution",(function(){return be})),o.d(t,"calculateAndFormatEvolution",(function(){return fe})),o.d(t,"DropdownMenu",(function(){return Qe})),o.d(t,"FocusAnywhereButHere",(function(){return ot})),o.d(t,"FocusIf",(function(){return nt})),o.d(t,"Tooltips",(function(){return dt})),o.d(t,"MatomoDialog",(function(){return ht})),o.d(t,"MatomoModal",(function(){return Ot})),o.d(t,"ExpandOnClick",(function(){return Pt})),o.d(t,"ExpandOnHover",(function(){return Mt})),o.d(t,"ShowSensitiveData",(function(){return Ft})),o.d(t,"DropdownButton",(function(){return Lt})),o.d(t,"DraggableList",(function(){return Ht})),o.d(t,"SelectOnFocus",(function(){return zt})),o.d(t,"CopyToClipboard",(function(){return Yt})),o.d(t,"SideNav",(function(){return Xt})),o.d(t,"EnrichedHeadline",(function(){return bo})),o.d(t,"ContentBlock",(function(){return Eo})),o.d(t,"Comparisons",(function(){return Yo})),o.d(t,"ComparisonsStore",(function(){return zo})),o.d(t,"ComparisonsStoreInstance",(function(){return Go})),o.d(t,"MenuItemsDropdown",(function(){return li})),o.d(t,"DatePicker",(function(){return hi})),o.d(t,"DateRangePicker",(function(){return ki})),o.d(t,"PeriodDatePicker",(function(){return Fi})),o.d(t,"Notification",(function(){return zi})),o.d(t,"NotificationGroup",(function(){return on})),o.d(t,"NotificationsStore",(function(){return en})),o.d(t,"ShowHelpLink",(function(){return cn})),o.d(t,"SitesStore",(function(){return mn})),o.d(t,"SiteSelector",(function(){return Nn})),o.d(t,"QuickAccess",(function(){return la})),o.d(t,"SearchInput",(function(){return ha})),o.d(t,"FieldArray",(function(){return ja})),o.d(t,"MultiPairField",(function(){return xa})),o.d(t,"PeriodSelector",(function(){return ss})),o.d(t,"ReportingMenu",(function(){return Ns})),o.d(t,"ReportingMenuStore",(function(){return ia})),o.d(t,"ReportingPagesStore",(function(){return Kn})),o.d(t,"ReportMetadataStore",(function(){return Fs})),o.d(t,"WidgetsStore",(function(){return xs})),o.d(t,"ReportHeader",(function(){return Js})),o.d(t,"WidgetControls",(function(){return Ks})),o.d(t,"WidgetLoader",(function(){return cl})),o.d(t,"ClientWidgetRenderer",(function(){return ml})),o.d(t,"WidgetContainer",(function(){return fl})),o.d(t,"WidgetByDimensionContainer",(function(){return El})),o.d(t,"Widget",(function(){return Ml})),o.d(t,"ReportingPage",(function(){return Yl})),o.d(t,"ReportExport",(function(){return Cc})),o.d(t,"Sparkline",(function(){return Pc})),o.d(t,"Progressbar",(function(){return Mc})),o.d(t,"ContentIntro",(function(){return Ic})),o.d(t,"ContentTable",(function(){return zc})),o.d(t,"AjaxForm",(function(){return Jc})),o.d(t,"Passthrough",(function(){return ed})),o.d(t,"DataTableActions",(function(){return iu})),o.d(t,"VersionInfoHeaderMessage",(function(){return bu})),o.d(t,"MobileLeftMenu",(function(){return ku})),o.d(t,"scrollToAnchorInUrl",(function(){return Nu})),o.d(t,"SearchFiltersPersistenceStore",(function(){return sl})),o.d(t,"AutoClearPassword",(function(){return Fu})),o.d(t,"PasswordStrength",(function(){return _u})),o.d(t,"EntityDuplicatorModal",(function(){return rm})),o.d(t,"EntityDuplicatorAction",(function(){return dm})),o.d(t,"EntityDuplicatorStore",(function(){return hm})),o.d(t,"BaseDuplicatorAdapter",(function(){return mm})),"undefined"!==typeof window){var i=window.document.currentScript,n=i&&i.src.match(/(.+\/)[^/]+\.js(\?.*)?$/);n&&(o.p=n[1])}o("2342"); /*! * Matomo - free/libre analytics platform * @@ -23,43 +23,43 @@ function a(e,...t){if(!e)return"";let o=t;return 1===t.length&&t[0]&&Array.isArr * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */function d(e){return $.datepicker.formatDate("yy-mm-dd",e)}function u(){const e=new Date(Date.now());return e.setTime(e.getTime()+60*e.getTimezoneOffset()*1e3),e.setHours(e.getHours()+(window.piwik.timezoneOffset||0)/3600),e.setHours(0),e.setMinutes(0),e.setSeconds(0),e.setMilliseconds(0),e}function p(e){if(e instanceof Date)return e;const t=decodeURIComponent(e).trim();if(""===t)throw new Error("Invalid date, empty string.");if("today"===t||"now"===t)return u();if("yesterday"===t||"yesterdaySameTime"===t){const e=u();return e.setDate(e.getDate()-1),e}if(t.match(/^last[ -]?week$/i)){const e=u();return e.setDate(e.getDate()-7),e}if(t.match(/^last[ -]?month$/i)){const e=u();return e.setDate(1),e.setMonth(e.getMonth()-1),e}if(t.match(/^last[ -]?year$/i)){const e=u();return e.setFullYear(e.getFullYear()-1),e}return $.datepicker.parseDate("yy-mm-dd",t)}function m(e){return 2===e.length&&(u()>=e[0]&&u()<=e[1])}function h(e){const t=new Date(e.valueOf()),o=(e.getDay()+6)%7;t.setDate(t.getDate()-o+3);const i=t.valueOf();if(t.setMonth(0,1),4!==t.getDay()){const e=(4-t.getDay()+7)%7;t.setMonth(0,1+e)}return 1+Math.ceil((i-t.valueOf())/6048e5)}function g(e,t,o){const i=e.getFullYear(),n=e.getMonth(),a=e.getDate(),r=h(e),s=t.getFullYear(),l=t.getMonth(),c=t.getDate(),d=h(t);switch(o){case"day":return i===s&&n===l&&a===c;case"week":return i===s&&r===d;case"month":return i===s&&n===l;case"year":return i===s;default:return!1}}function b(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} + */function d(e){return $.datepicker.formatDate("yy-mm-dd",e)}function u(){const e=new Date(Date.now());return e.setTime(e.getTime()+60*e.getTimezoneOffset()*1e3),e.setHours(e.getHours()+(window.piwik.timezoneOffset||0)/3600),e.setHours(0),e.setMinutes(0),e.setSeconds(0),e.setMilliseconds(0),e}function m(e){if(e instanceof Date)return e;const t=decodeURIComponent(e).trim();if(""===t)throw new Error("Invalid date, empty string.");if("today"===t||"now"===t)return u();if("yesterday"===t||"yesterdaySameTime"===t){const e=u();return e.setDate(e.getDate()-1),e}if(t.match(/^last[ -]?week$/i)){const e=u();return e.setDate(e.getDate()-7),e}if(t.match(/^last[ -]?month$/i)){const e=u();return e.setDate(1),e.setMonth(e.getMonth()-1),e}if(t.match(/^last[ -]?year$/i)){const e=u();return e.setFullYear(e.getFullYear()-1),e}return $.datepicker.parseDate("yy-mm-dd",t)}function p(e){return 2===e.length&&(u()>=e[0]&&u()<=e[1])}function h(e){const t=new Date(e.valueOf()),o=(e.getDay()+6)%7;t.setDate(t.getDate()-o+3);const i=t.valueOf();if(t.setMonth(0,1),4!==t.getDay()){const e=(4-t.getDay()+7)%7;t.setMonth(0,1+e)}return 1+Math.ceil((i-t.valueOf())/6048e5)}function g(e,t,o){const i=e.getFullYear(),n=e.getMonth(),a=e.getDate(),r=h(e),s=t.getFullYear(),l=t.getMonth(),c=t.getDate(),d=h(t);switch(o){case"day":return i===s&&n===l&&a===c;case"week":return i===s&&r===d;case"month":return i===s&&n===l;case"year":return i===s;default:return!1}}function b(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */class f{constructor(e){b(this,"dateInPeriod",void 0),this.dateInPeriod=e}static parse(e){return new f(p(e))}static getDisplayText(){return a("Intl_PeriodDay")}getPrettyString(){return d(this.dateInPeriod)}getDateRange(){return[new Date(this.dateInPeriod.getTime()),new Date(this.dateInPeriod.getTime())]}containsToday(){return m(this.getDateRange())}}function v(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} + */class f{constructor(e){b(this,"dateInPeriod",void 0),this.dateInPeriod=e}static parse(e){return new f(m(e))}static getDisplayText(){return a("Intl_PeriodDay")}getPrettyString(){return d(this.dateInPeriod)}getDateRange(){return[new Date(this.dateInPeriod.getTime()),new Date(this.dateInPeriod.getTime())]}containsToday(){return p(this.getDateRange())}}function v(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */c.addCustomPeriod("day",f);class O{constructor(e){v(this,"dateInPeriod",void 0),this.dateInPeriod=e}static parse(e){return new O(p(e))}static getDisplayText(){return a("Intl_PeriodWeek")}getPrettyString(){const e=this.getDateRange(),t=d(e[0]),o=d(e[1]);return a("General_DateRangeFromTo",[t,o])}getDateRange(){const e=(this.dateInPeriod.getDay()+6)%7,t=new Date(this.dateInPeriod.getTime());t.setDate(this.dateInPeriod.getDate()-e);const o=new Date(t.getTime());return o.setDate(t.getDate()+6),[t,o]}containsToday(){return m(this.getDateRange())}}function y(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} + */c.addCustomPeriod("day",f);class O{constructor(e){v(this,"dateInPeriod",void 0),this.dateInPeriod=e}static parse(e){return new O(m(e))}static getDisplayText(){return a("Intl_PeriodWeek")}getPrettyString(){const e=this.getDateRange(),t=d(e[0]),o=d(e[1]);return a("General_DateRangeFromTo",[t,o])}getDateRange(){const e=(this.dateInPeriod.getDay()+6)%7,t=new Date(this.dateInPeriod.getTime());t.setDate(this.dateInPeriod.getDate()-e);const o=new Date(t.getTime());return o.setDate(t.getDate()+6),[t,o]}containsToday(){return p(this.getDateRange())}}function y(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */c.addCustomPeriod("week",O);class j{constructor(e){y(this,"dateInPeriod",void 0),this.dateInPeriod=e}static parse(e){return new j(p(e))}static getDisplayText(){return a("Intl_PeriodMonth")}getPrettyString(){const e=a("Intl_Month_Long_StandAlone_"+(this.dateInPeriod.getMonth()+1));return`${e} ${this.dateInPeriod.getFullYear()}`}getDateRange(){const e=new Date(this.dateInPeriod.getTime());e.setDate(1);const t=new Date(this.dateInPeriod.getTime());return t.setDate(1),t.setMonth(t.getMonth()+1),t.setDate(0),[e,t]}containsToday(){return m(this.getDateRange())}}function w(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} + */c.addCustomPeriod("week",O);class j{constructor(e){y(this,"dateInPeriod",void 0),this.dateInPeriod=e}static parse(e){return new j(m(e))}static getDisplayText(){return a("Intl_PeriodMonth")}getPrettyString(){const e=a("Intl_Month_Long_StandAlone_"+(this.dateInPeriod.getMonth()+1));return`${e} ${this.dateInPeriod.getFullYear()}`}getDateRange(){const e=new Date(this.dateInPeriod.getTime());e.setDate(1);const t=new Date(this.dateInPeriod.getTime());return t.setDate(1),t.setMonth(t.getMonth()+1),t.setDate(0),[e,t]}containsToday(){return p(this.getDateRange())}}function w(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */c.addCustomPeriod("month",j);class S{constructor(e){w(this,"dateInPeriod",void 0),this.dateInPeriod=e}static parse(e){return new S(p(e))}static getDisplayText(){return a("Intl_PeriodYear")}getPrettyString(){return this.dateInPeriod.getFullYear().toString()}getDateRange(){const e=new Date(this.dateInPeriod.getTime());e.setMonth(0),e.setDate(1);const t=new Date(this.dateInPeriod.getTime());return t.setMonth(12),t.setDate(0),[e,t]}containsToday(){return m(this.getDateRange())}}function C(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} + */c.addCustomPeriod("month",j);class S{constructor(e){w(this,"dateInPeriod",void 0),this.dateInPeriod=e}static parse(e){return new S(m(e))}static getDisplayText(){return a("Intl_PeriodYear")}getPrettyString(){return this.dateInPeriod.getFullYear().toString()}getDateRange(){const e=new Date(this.dateInPeriod.getTime());e.setMonth(0),e.setDate(1);const t=new Date(this.dateInPeriod.getTime());return t.setMonth(12),t.setDate(0),[e,t]}containsToday(){return p(this.getDateRange())}}function C(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */c.addCustomPeriod("year",S);class k{constructor(e,t,o){C(this,"startDate",void 0),C(this,"endDate",void 0),C(this,"childPeriodType",void 0),this.startDate=e,this.endDate=t,this.childPeriodType=o}static getLastNRange(e,t,o){const i=Math.max(parseInt(t.toString(),10)-1,0);if(Number.isNaN(i))throw new Error("Invalid range strAmount");let n=o?p(o):u(),a=new Date(n.getTime());if("day"===e)a.setDate(a.getDate()-i);else if("week"===e)a.setDate(a.getDate()-7*i);else if("month"===e)a.setDate(1),a.setMonth(a.getMonth()-i);else{if("year"!==e)throw new Error(`Unknown period type '${e}'.`);a.setFullYear(a.getFullYear()-i)}if("day"!==e){const t=c.periods[e].parse(a),o=c.periods[e].parse(n);[a]=t.getDateRange(),[,n]=o.getDateRange()}const r=new Date(1991,7,6);if(a.getTime()-r.getTime()<0)switch(e){case"year":a=new Date(1992,0,1);break;case"month":a=new Date(1991,8,1);break;case"week":a=new Date(1991,8,12);break;case"day":default:a=r;break}return new k(a,n,e)}static getLastNRangeChild(e,t,o){const i=t?p(t):u();let n=new Date(i.getTime()),a=new Date(i.getTime());if("day"===e)n.setDate(n.getDate()-o),a.setDate(a.getDate()-o);else if("week"===e)n.setDate(n.getDate()-7*o),a.setDate(a.getDate()-7*o);else if("month"===e)n.setDate(1),n.setMonth(n.getMonth()-o),a.setDate(1),a.setMonth(a.getMonth()-o);else{if("year"!==e)throw new Error(`Unknown period type '${e}'.`);n.setFullYear(n.getFullYear()-o),a.setFullYear(a.getFullYear()-o)}if("day"!==e){const t=c.periods[e].parse(n),o=c.periods[e].parse(a);[n]=t.getDateRange(),[,a]=o.getDateRange()}const r=new Date(1991,7,6);if(n.getTime()-r.getTime()<0)switch(e){case"year":n=new Date(1992,0,1);break;case"month":n=new Date(1991,8,1);break;case"week":n=new Date(1991,8,12);break;case"day":default:n=r;break}return new k(n,a,e)}static parse(e,t="day"){if(/^previous/.test(e)){const o=k.getLastNRange(t,"2").startDate;return k.getLastNRange(t,e.substring(8),o)}if(/^last/.test(e))return k.getLastNRange(t,e.substring(4));const o=decodeURIComponent(e).split(",");return new k(p(o[0]),p(o[1]),t)}static getDisplayText(){return a("General_DateRangeInPeriodList")}getPrettyString(){const e=d(this.startDate),t=d(this.endDate);return a("General_DateRangeFromTo",[e,t])}getDateRange(){return[this.startDate,this.endDate]}containsToday(){return m(this.getDateRange())}getDayCount(){return Math.ceil((this.endDate.getTime()-this.startDate.getTime())/864e5)+1}}c.addCustomPeriod("range",k);var D=o("8bbf"); + */c.addCustomPeriod("year",S);class k{constructor(e,t,o){C(this,"startDate",void 0),C(this,"endDate",void 0),C(this,"childPeriodType",void 0),this.startDate=e,this.endDate=t,this.childPeriodType=o}static getLastNRange(e,t,o){const i=Math.max(parseInt(t.toString(),10)-1,0);if(Number.isNaN(i))throw new Error("Invalid range strAmount");let n=o?m(o):u(),a=new Date(n.getTime());if("day"===e)a.setDate(a.getDate()-i);else if("week"===e)a.setDate(a.getDate()-7*i);else if("month"===e)a.setDate(1),a.setMonth(a.getMonth()-i);else{if("year"!==e)throw new Error(`Unknown period type '${e}'.`);a.setFullYear(a.getFullYear()-i)}if("day"!==e){const t=c.periods[e].parse(a),o=c.periods[e].parse(n);[a]=t.getDateRange(),[,n]=o.getDateRange()}const r=new Date(1991,7,6);if(a.getTime()-r.getTime()<0)switch(e){case"year":a=new Date(1992,0,1);break;case"month":a=new Date(1991,8,1);break;case"week":a=new Date(1991,8,12);break;case"day":default:a=r;break}return new k(a,n,e)}static getLastNRangeChild(e,t,o){const i=t?m(t):u();let n=new Date(i.getTime()),a=new Date(i.getTime());if("day"===e)n.setDate(n.getDate()-o),a.setDate(a.getDate()-o);else if("week"===e)n.setDate(n.getDate()-7*o),a.setDate(a.getDate()-7*o);else if("month"===e)n.setDate(1),n.setMonth(n.getMonth()-o),a.setDate(1),a.setMonth(a.getMonth()-o);else{if("year"!==e)throw new Error(`Unknown period type '${e}'.`);n.setFullYear(n.getFullYear()-o),a.setFullYear(a.getFullYear()-o)}if("day"!==e){const t=c.periods[e].parse(n),o=c.periods[e].parse(a);[n]=t.getDateRange(),[,a]=o.getDateRange()}const r=new Date(1991,7,6);if(n.getTime()-r.getTime()<0)switch(e){case"year":n=new Date(1992,0,1);break;case"month":n=new Date(1991,8,1);break;case"week":n=new Date(1991,8,12);break;case"day":default:n=r;break}return new k(n,a,e)}static parse(e,t="day"){if(/^previous/.test(e)){const o=k.getLastNRange(t,"2").startDate;return k.getLastNRange(t,e.substring(8),o)}if(/^last/.test(e))return k.getLastNRange(t,e.substring(4));const o=decodeURIComponent(e).split(",");return new k(m(o[0]),m(o[1]),t)}static getDisplayText(){return a("General_DateRangeInPeriodList")}getPrettyString(){const e=d(this.startDate),t=d(this.endDate);return a("General_DateRangeFromTo",[e,t])}getDateRange(){return[this.startDate,this.endDate]}containsToday(){return p(this.getDateRange())}getDayCount(){return Math.ceil((this.endDate.getTime()-this.startDate.getTime())/864e5)+1}}c.addCustomPeriod("range",k);var D=o("8bbf"); /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */const{piwik:E,broadcast:P,piwikHelper:T}=window;function x(e){if("string"!==typeof e)return;const t=e.trim();return t&&/^[A-Za-z0-9_]+$/.test(t)?t:void 0}function V(){const{CoreHome:e}=window;return null===e||void 0===e?void 0:e.ReportingMenuStore}function B(){const{CoreHome:e}=window;return null===e||void 0===e?void 0:e.ComparisonStoreInstance}function N(e){var t;if("string"!==typeof e)return;const o=e.trim(),i=B();if(i){const t=i.getSegmentComparisons();if(!o&&t.length)return t[0].title;const n=t.find(t=>t.params.segment===e);if(n)return n.title}if(!o)return a("SegmentEditor_DefaultAllVisits");const n=document.querySelector(".segmentEditorPanel .segmentationTitle"),r=null===n||void 0===n||null===(t=n.textContent)||void 0===t?void 0:t.trim();return r||a("SegmentEditor_CustomSegment")}E.helper=T,E.broadcast=P,E.updateTitle=async function(e,t,o,i,n){let r="",s="",l="";""!==t&&""!==e&&(l=c.parse(t,e).getPrettyString());const d=a("CoreHome_WebAnalyticsReports")+" - Matomo",u=V();if(u&&o&&i){var p,m,h,g;let e=u.findSubcategory(o,i);e.category||(await u.fetchMenuItems(),e=u.findSubcategory(o,i)),r=null!==(p=null===(m=e)||void 0===m||null===(m=m.category)||void 0===m?void 0:m.name)&&void 0!==p?p:"",s=null!==(h=null===(g=e)||void 0===g||null===(g=g.subcategory)||void 0===g?void 0:g.name)&&void 0!==h?h:"",r===s&&(s=""),r=T.htmlEntities(r),s=T.htmlEntities(s);const t=r?`${r} ${s?"> "+s:""}`:"",a=N(n),c=a?T.htmlEntities(a):"";document.title=[E.siteName,l,t,c,d].filter(Boolean).join(" - ")}},E.hasUserCapability=function(e){return Array.isArray(E.userCapabilities)&&-1!==E.userCapabilities.indexOf(e)},E.on=function(e,t){function o(e){t(...e.detail)}t.wrapper=o,window.addEventListener(e,o)},E.off=function(e,t){t.wrapper&&window.removeEventListener(e,t.wrapper)},E.postEvent=function(e,...t){const o=new CustomEvent(e,{detail:t});window.dispatchEvent(o)},E.getLoginModule=function(){const e=x(E.loginModule);if(e)return e;const t=x(window.loginModule);return t||"Login"};const I=E;var M=I; + */const{piwik:E,broadcast:P,piwikHelper:T}=window;function x(e){if("string"!==typeof e)return;const t=e.trim();return t&&/^[A-Za-z0-9_]+$/.test(t)?t:void 0}function V(){const{CoreHome:e}=window;return null===e||void 0===e?void 0:e.ReportingMenuStore}function B(){const{CoreHome:e}=window;return null===e||void 0===e?void 0:e.ComparisonStoreInstance}function N(e){var t;if("string"!==typeof e)return;const o=e.trim(),i=B();if(i){const t=i.getSegmentComparisons();if(!o&&t.length)return t[0].title;const n=t.find(t=>t.params.segment===e);if(n)return n.title}if(!o)return a("SegmentEditor_DefaultAllVisits");const n=document.querySelector(".segmentEditorPanel .segmentationTitle"),r=null===n||void 0===n||null===(t=n.textContent)||void 0===t?void 0:t.trim();return r||a("SegmentEditor_CustomSegment")}E.helper=T,E.broadcast=P,E.updateTitle=async function(e,t,o,i,n){let r="",s="",l="";""!==t&&""!==e&&(l=c.parse(t,e).getPrettyString());const d=a("CoreHome_WebAnalyticsReports")+" - Matomo",u=V();if(u&&o&&i){var m,p,h,g;let e=u.findSubcategory(o,i);e.category||(await u.fetchMenuItems(),e=u.findSubcategory(o,i)),r=null!==(m=null===(p=e)||void 0===p||null===(p=p.category)||void 0===p?void 0:p.name)&&void 0!==m?m:"",s=null!==(h=null===(g=e)||void 0===g||null===(g=g.subcategory)||void 0===g?void 0:g.name)&&void 0!==h?h:"",r===s&&(s=""),r=T.htmlEntities(r),s=T.htmlEntities(s);const t=r?`${r} ${s?"> "+s:""}`:"",a=N(n),c=a?T.htmlEntities(a):"";document.title=[E.siteName,l,t,c,d].filter(Boolean).join(" - ")}},E.hasUserCapability=function(e){return Array.isArray(E.userCapabilities)&&-1!==E.userCapabilities.indexOf(e)},E.on=function(e,t){function o(e){t(...e.detail)}t.wrapper=o,window.addEventListener(e,o)},E.off=function(e,t){t.wrapper&&window.removeEventListener(e,t.wrapper)},E.postEvent=function(e,...t){const o=new CustomEvent(e,{detail:t});window.dispatchEvent(o)},E.getLoginModule=function(){const e=x(E.loginModule);if(e)return e;const t=x(window.loginModule);return t||"Login"};const M=E;var I=M; /*! * Matomo - free/libre analytics platform * @@ -71,19 +71,19 @@ function a(e,...t){if(!e)return"";let o=t;return 1===t.length&&t[0]&&Array.isArr * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */const{piwik:R,broadcast:L}=window;function A(e,t){try{return c.parse(e,t),!0}catch(o){return!1}}class _{constructor(){F(this,"url",Object(D["ref"])(null)),F(this,"urlQuery",Object(D["computed"])(()=>this.url.value?this.url.value.search.replace(/^\?/,""):"")),F(this,"hashQuery",Object(D["computed"])(()=>this.url.value?this.url.value.hash.replace(/^[#/?]+/,""):"")),F(this,"urlParsed",Object(D["computed"])(()=>Object(D["readonly"])(this.parse(this.urlQuery.value)))),F(this,"hashParsed",Object(D["computed"])(()=>Object(D["readonly"])(this.parse(this.hashQuery.value)))),F(this,"parsed",Object(D["computed"])(()=>Object(D["readonly"])(Object.assign(Object.assign({},this.urlParsed.value),this.hashParsed.value)))),this.url.value=new URL(window.location.href),window.addEventListener("hashchange",e=>{this.url.value=new URL(e.newURL),this.updatePeriodParamsFromUrl(),this.updatePageTitle()}),this.updatePeriodParamsFromUrl(),this.updatePageTitle()}updateHashToUrl(e){const t="#"+e;window.location.hash===t?window.dispatchEvent(new HashChangeEvent("hashchange",{newURL:window.location.href,oldURL:window.location.href})):window.location.hash=t}updateHash(e){const t=this.getFinalHashParams(e),o=this.stringify(t);this.updateHashToUrl("?"+o)}updateUrl(e,t={}){const o="string"!==typeof e?this.stringify(e):e,i=Object.keys(t).length?this.getFinalHashParams(t,e):{},n=this.stringify(i);let a="?"+o;n.length&&(a=`${a}#?${n}`),window.broadcast.propagateNewPage("",void 0,void 0,void 0,a)}getFinalHashParams(e,t={}){const o="string"!==typeof e?e:this.parse(e),i="string"!==typeof e?t:this.parse(t);return Object.assign({period:i.period||this.parsed.value.period,date:i.date||this.parsed.value.date,segment:i.segment||this.parsed.value.segment},o)}updateLocation(e){M.helper.isReportingPage()?this.updateHash(e):this.updateUrl(e)}getSearchParam(e){const t=window.location.href.split("#"),o=new RegExp(e+"(\\[]|=)");if(t&&t[1]&&o.test(decodeURIComponent(t[1]))){const t=window.broadcast.getValueFromHash(e,window.location.href);if(t||"date"!==e&&"period"!==e&&"idSite"!==e)return t}return window.broadcast.getValueFromUrl(e,window.location.search)}parse(e){return L.getValuesFromUrl("?"+e,!0)}stringify(e){const t=Object.fromEntries(Object.entries(e).filter(([,e])=>""!==e&&null!==e&&void 0!==e));return $.param(t).replace(/%5B%5D/g,"[]").replace(/%2C/g,",").replace(/\+/g,"%20")}getMenuPathSuffix(){const e=this.getSearchParam("category"),t=this.getSearchParam("subcategory");return{category:decodeURIComponent(e),subcategory:decodeURIComponent(t)}}getDateAndPeriodFromUrl(){return{date:this.getSearchParam("date")||"",period:this.getSearchParam("period")||""}}updatePageTitle(){const{period:e,date:t}=this.getDateAndPeriodFromUrl(),{category:o,subcategory:i}=this.getMenuPathSuffix(),n=this.getSearchParam("segment")||"";R.updateTitle(t,e,o,i,n)}updatePeriodParamsFromUrl(){const{period:e,date:t}=this.getDateAndPeriodFromUrl();let o=t;if(!A(e,o))return;if(R.period===e&&R.currentDateString===o)return;R.period=e;const i=c.parse(e,o).getDateRange();R.startDateString=d(i[0]),R.endDateString=d(i[1]),"range"===R.period&&(o=`${R.startDateString},${R.endDateString}`),R.currentDateString=o}}const H=new _;var U=H;function q(e,t,o){const i=new Date;o||(o=432e4),i.setTime(i.getTime()+o),document.cookie=`${e}=${t}; expires=${i.toUTCString()}; path=/`}function W(e){const t="; "+document.cookie,o=t.split(`; ${e}=`);if(2==o.length){const e=o.pop().split(";").shift();if("undefined"!==typeof e)return e}return null}function z(e){const t=new Date;t.setTime(t.getTime()+-864e5),document.cookie=`${e}=; expires=${t.toUTCString()}; path=/`}function G(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} + */const{piwik:R,broadcast:L}=window;function A(e,t){try{return c.parse(e,t),!0}catch(o){return!1}}class _{constructor(){F(this,"url",Object(D["ref"])(null)),F(this,"urlQuery",Object(D["computed"])(()=>this.url.value?this.url.value.search.replace(/^\?/,""):"")),F(this,"hashQuery",Object(D["computed"])(()=>this.url.value?this.url.value.hash.replace(/^[#/?]+/,""):"")),F(this,"urlParsed",Object(D["computed"])(()=>Object(D["readonly"])(this.parse(this.urlQuery.value)))),F(this,"hashParsed",Object(D["computed"])(()=>Object(D["readonly"])(this.parse(this.hashQuery.value)))),F(this,"parsed",Object(D["computed"])(()=>Object(D["readonly"])(Object.assign(Object.assign({},this.urlParsed.value),this.hashParsed.value)))),this.url.value=new URL(window.location.href),window.addEventListener("hashchange",e=>{this.url.value=new URL(e.newURL),this.updatePeriodParamsFromUrl(),this.updatePageTitle()}),this.updatePeriodParamsFromUrl(),this.updatePageTitle()}updateHashToUrl(e){const t="#"+e;window.location.hash===t?window.dispatchEvent(new HashChangeEvent("hashchange",{newURL:window.location.href,oldURL:window.location.href})):window.location.hash=t}updateHash(e){const t=this.getFinalHashParams(e),o=this.stringify(t);this.updateHashToUrl("?"+o)}updateUrl(e,t={}){const o="string"!==typeof e?this.stringify(e):e,i=Object.keys(t).length?this.getFinalHashParams(t,e):{},n=this.stringify(i);let a="?"+o;n.length&&(a=`${a}#?${n}`),window.broadcast.propagateNewPage("",void 0,void 0,void 0,a)}getFinalHashParams(e,t={}){const o="string"!==typeof e?e:this.parse(e),i="string"!==typeof e?t:this.parse(t);return Object.assign({period:i.period||this.parsed.value.period,date:i.date||this.parsed.value.date,segment:i.segment||this.parsed.value.segment},o)}updateLocation(e){I.helper.isReportingPage()?this.updateHash(e):this.updateUrl(e)}getSearchParam(e){const t=window.location.href.split("#"),o=new RegExp(e+"(\\[]|=)");if(t&&t[1]&&o.test(decodeURIComponent(t[1]))){const t=window.broadcast.getValueFromHash(e,window.location.href);if(t||"date"!==e&&"period"!==e&&"idSite"!==e)return t}return window.broadcast.getValueFromUrl(e,window.location.search)}parse(e){return L.getValuesFromUrl("?"+e,!0)}stringify(e){const t=Object.fromEntries(Object.entries(e).filter(([,e])=>""!==e&&null!==e&&void 0!==e));return $.param(t).replace(/%5B%5D/g,"[]").replace(/%2C/g,",").replace(/\+/g,"%20")}getMenuPathSuffix(){const e=this.getSearchParam("category"),t=this.getSearchParam("subcategory");return{category:decodeURIComponent(e),subcategory:decodeURIComponent(t)}}getDateAndPeriodFromUrl(){return{date:this.getSearchParam("date")||"",period:this.getSearchParam("period")||""}}updatePageTitle(){const{period:e,date:t}=this.getDateAndPeriodFromUrl(),{category:o,subcategory:i}=this.getMenuPathSuffix(),n=this.getSearchParam("segment")||"";R.updateTitle(t,e,o,i,n)}updatePeriodParamsFromUrl(){const{period:e,date:t}=this.getDateAndPeriodFromUrl();let o=t;if(!A(e,o))return;if(R.period===e&&R.currentDateString===o)return;R.period=e;const i=c.parse(e,o).getDateRange();R.startDateString=d(i[0]),R.endDateString=d(i[1]),"range"===R.period&&(o=`${R.startDateString},${R.endDateString}`),R.currentDateString=o}}const H=new _;var U=H;function q(e,t,o){const i=new Date;o||(o=432e4),i.setTime(i.getTime()+o),document.cookie=`${e}=${t}; expires=${i.toUTCString()}; path=/`}function W(e){const t="; "+document.cookie,o=t.split(`; ${e}=`);if(2==o.length){const e=o.pop().split(";").shift();if("undefined"!==typeof e)return e}return null}function z(e){const t=new Date;t.setTime(t.getTime()+-864e5),document.cookie=`${e}=; expires=${t.toUTCString()}; path=/`}function G(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */R.updatePeriodParamsFromUrl=H.updatePeriodParamsFromUrl.bind(H);const{$:K}=window;function Y(e,t){"abort"!==t&&e&&0!==e.status&&("undefined"!==typeof Piwik_Popover?Piwik_Popover.isOpen()&&e&&500===e.status?K(document.body).html(piwikHelper.escape(e.responseText)):K("#loadingError").show():console.log("Request failed: "+e.responseText))}function Q(e){return Object.prototype.hasOwnProperty.call(e,"segment")&&"undefined"!==typeof e.segment}window.globalAjaxQueue=[],window.globalAjaxQueue.active=0,window.globalAjaxQueue.clean=function(){for(let e=this.length;e>=0;e-=1)this[e]&&4!==this[e].readyState||this.splice(e,1)},window.globalAjaxQueue.push=function(...e){return this.active+=e.length,this.clean(),Array.prototype.push.call(this,...e)},window.globalAjaxQueue.abort=function(){this.forEach(e=>e&&e.abort&&e.abort()),this.splice(0,this.length),this.active=0};class J extends Error{}class X extends Error{constructor(e,t,o){super("Chunked bulk request failed."),G(this,"xhr",void 0),G(this,"status",void 0),G(this,"errorThrown",void 0),this.xhr=e,this.status=t,this.errorThrown=o}}class Z extends Error{constructor(){super("Chunked bulk request was aborted.")}}class ee extends Error{constructor(){super("Chunked bulk request timed out due to session expiration.")}}class te{static fetch(e,t={}){if(Array.isArray(e)&&t.returnResponseObject)throw new Error(this.UNSUPPORTED_BULK_RESPONSE_OBJECT_ERROR);const o=new te;if(t.withTokenInUrl&&o.withTokenInUrl(),t.errorElement&&o.setErrorElement(t.errorElement),t.redirectOnSuccess&&o.redirectOnSuccess(!0!==t.redirectOnSuccess?t.redirectOnSuccess:void 0),o.setFormat(t.format||"json"),Array.isArray(e))o.setBulkRequests(...e);else{Object.keys(e).forEach(e=>{if(/password/i.test(e))throw new Error(`Password parameters are not allowed to be sent as GET parameter. Please send ${e} as POST parameter instead.`)});const i=Q(e);let n={};if(i){let t=null;null!==e.segment&&(t=encodeURIComponent(e.segment)),n={segment:t}}o.addParams(Object.assign(Object.assign({module:"API",format:t.format||"json"},e),n),"get")}t.postParams&&o.addParams(t.postParams,"post"),t.headers&&(o.headers=Object.assign(Object.assign({},o.headers),t.headers));let i=!0;return"undefined"===typeof t.createErrorNotification||t.createErrorNotification||(o.useCallbackInCaseOfError(),o.setErrorCallback(null),i=!1),t.abortController&&(o.abortController=t.abortController),t.returnResponseObject&&(o.resolveWithHelper=!0),!1===t.abortable&&(o.abortable=!1),o.send().then(e=>{const t=e instanceof te?e.requestHandle.responseJSON:e,i="API.getBulkRequest"===o.postParams.method&&Array.isArray(t)?t:[t],n=i.filter(e=>"error"===e.result).map(e=>e.message);if(n.length)throw new J(n.filter(e=>e.length).join("\n"));return e}).catch(e=>{if(i||e instanceof J)throw e;let t="Something went wrong";e instanceof Z&&(t="Request was possibly aborted"),e instanceof ee&&(t="Session timed out");const o="object"===typeof e&&null!==e&&"status"in e?e.status:null;throw 504===o&&(t="Request was possibly aborted"),429===o&&(t="Rate Limit was exceed"),new Error(t)})}static getBulkRequestLimit(){const e=parseInt(""+M.apiBulkRequestLimit,10);return Number.isNaN(e)?-1:e}static splitIntoChunks(e,t){const o=[];for(let i=0;i"error"===e.result).map(e=>e.message).filter(e=>e.length).reduce((e,t)=>(e[t]=(e[t]||0)+1,e),{});if(n&&Object.keys(n).length&&!this.useRegularCallbackInCaseOfError){let e="";Object.keys(n).forEach(t=>{e.length&&(e+="
"),n[t]>1?e+=`${t} (${n[t]}x)`:e+=t});let t=null,o="toast";K(this.errorElement).length&&e.length&&(K(this.errorElement).show(),t=this.errorElement,o=null);const i=!document.querySelector("#login_form");if(e&&i){const i=window["require"]("piwik/UI"),n=new i.Notification;n.show(e,{placeat:t,context:"error",type:o,id:"ajaxHelper"}),n.scrollToNotification()}}else this.callback&&this.callback(e,t,o)}buildRequestUrl(e){const t=this.mixinDefaultGetParams(e);let o=this.getUrl;if("?"!==o[o.length-1]&&(o+="&"),Object.prototype.hasOwnProperty.call(t,"segment")){const e=t.segment;if(delete t.segment,null!==e&&"undefined"!==typeof e){const t=(""+e).replace(/&/g,"%26").replace(/#/g,"%23").replace(/\?/g,"%3F");o=`${o}segment=${t}&`}}if(t.date){const e=t.date.toString(),n=t.period;if(!/^[a-z0-9, -]+$/i.test(e))throw new Error(`Invalid date '${e}'.`);if(n&&c.isRecognizedPeriod(n)){const t=/^(last|previous)\d/i.test(e)||-1!==e.indexOf(",");try{t&&"range"!==n?k.parse(e,n):c.parse(n,e)}catch(i){throw new Error(`Invalid date '${e}' for period '${n}'.`)}}o=`${o}date=${encodeURIComponent(e).replace(/%2C/g,",")}&`,delete t.date}return o+=K.param(t),o}buildChunkedBulkAjaxCall(e){const t=this.buildRequestUrl(Object.assign({},this.getParams)),o=e.map(e=>"string"===typeof e?e:K.param(e));return K.ajax({type:"POST",async:!0,url:t,dataType:this.format||"json",headers:this.headers?this.headers:void 0,data:this.mixinDefaultPostParams(Object.assign(Object.assign({},this.postParams),{},{urls:o})),timeout:null!==this.timeout?this.timeout:void 0})}getBulkRequestUrls(){return"API.getBulkRequest"===this.postParams.method&&Array.isArray(this.postParams.urls)?this.postParams.urls:null}shouldSendBulkRequestInChunks(){const e=this.getBulkRequestUrls();if(!e)return!1;const t=te.getBulkRequestLimit();return t>0&&e.length>t}shouldRejectBulkResponseObjectRequest(){return!!this.getBulkRequestUrls()&&this.resolveWithHelper}sendBulkRequestInChunks(){const e=this.getBulkRequestUrls();if(!e)return Promise.resolve([]);const t=te.getBulkRequestLimit();if(t<=0)return Promise.resolve([]);try{this.buildRequestUrl(Object.assign({},this.getParams))}catch(h){return this.hideLoadingElement(),Promise.reject(h)}const o=this.abortController||new AbortController;this.abortController=o;let i=null,n=!1,a=!1;const r=()=>{!n&&this.abortable&&(window.globalAjaxQueue.active-=1,n=!0)},s=(e,t)=>{!a&&this.completeCallback&&(a=!0,this.completeCallback(e,t))},l={readyState:1,status:0,statusText:"",responseJSON:[],abort:()=>{o.abort()}},c=l;let d=c;this.requestHandle=c,this.abortable&&window.globalAjaxQueue.push(c),o.signal.addEventListener("abort",()=>{i&&i.abort()});const u=te.splitIntoChunks(e,t),p=[],m=e=>e>=u.length?Promise.resolve(p):(i=this.buildChunkedBulkAjaxCall(u[e]),new Promise((e,t)=>{i.then((t,o,i)=>{d=i,l.readyState=i.readyState,l.status=i.status,l.statusText=i.statusText||o,Array.isArray(t)?p.push(...t):p.push(t),e(p)}).fail((e,o,i)=>{l.readyState=e.readyState,l.status=e.status,l.statusText=e.statusText||o,t(new X(e,o,i))})}).then(()=>m(e+1)));return m(0).then(e=>(l.readyState=4,l.responseJSON=e,this.handleApiErrorResponseOrCallback(e,"success",d),r(),s(d,"success"),M.ajaxRequestFinished&&M.ajaxRequestFinished(),e)).catch(e=>{if(!(e instanceof X))throw e;const{xhr:t,status:o,errorThrown:i}=e;if(r(),this.errorCallback&&this.errorCallback.apply(this,[t,o,i]),s(t,o),429===t.status)throw console.log(`Warning: the '${K.param(this.getParams)}' request was rate limited!`),t;if("abort"===t.statusText||0===t.status)throw new Z;const n=!document.querySelector("#login_form"),a="1"===t.getResponseHeader("X-Matomo-Session-Timed-Out");if(a&&n)throw q("matomo_session_timed_out","1",6e4),M.helper.refreshAfter(0),new ee;throw console.log(`Warning: the ${K.param(this.getParams)} request failed!`),t})}static post(e,t={},o={}){return te.fetch(e,Object.assign(Object.assign({},o),{},{postParams:t}))}static oneAtATime(e,t){let o=null;return(i,n)=>(o&&o.abort(),o=new AbortController,te.post(Object.assign(Object.assign({},i),{},{method:e}),n,Object.assign(Object.assign({},t),{},{abortController:o})).finally(()=>{o=null}))}constructor(){G(this,"format","json"),G(this,"timeout",null),G(this,"callback",null),G(this,"useRegularCallbackInCaseOfError",!1),G(this,"errorCallback",void 0),G(this,"withToken",!1),G(this,"completeCallback",void 0),G(this,"getParams",{}),G(this,"getUrl","?"),G(this,"postParams",{}),G(this,"loadingElement",null),G(this,"errorElement","#ajaxError"),G(this,"headers",{"X-Requested-With":"XMLHttpRequest"}),G(this,"requestHandle",null),G(this,"abortController",null),G(this,"abortable",!0),G(this,"defaultParams",["idSite","period","date","segment"]),G(this,"resolveWithHelper",!1),this.errorCallback=Y}addParams(e,t){const o="string"===typeof e?window.broadcast.getValuesFromUrl(e):e,i=["compareSegments","comparePeriods","compareDates"];Object.keys(o).forEach(e=>{let n=o[e];(-1===i.indexOf(e)||n)&&("boolean"===typeof n&&(n=n?1:0),"get"===t.toLowerCase()?this.getParams[e]=n:"post"===t.toLowerCase()&&(this.postParams[e]=n))})}withTokenInUrl(){this.withToken=!0}setUrl(e){this.addParams(broadcast.getValuesFromUrl(e),"GET")}setBulkRequests(...e){const t=e.map(e=>"string"===typeof e?e:K.param(e));this.addParams({module:"API",method:"API.getBulkRequest",urls:t,format:"json"},"post")}setTimeout(e){this.timeout=e}setCallback(e){this.callback=e}useCallbackInCaseOfError(){this.useRegularCallbackInCaseOfError=!0}redirectOnSuccess(e){this.setCallback(()=>{piwikHelper.redirect(e)})}setErrorCallback(e){this.errorCallback=e}setCompleteCallback(e){this.completeCallback=e}setFormat(e){this.format=e}setLoadingElement(e){this.loadingElement=e||"#ajaxLoadingDiv"}setErrorElement(e){e&&(this.errorElement=e)}useGETDefaultParameter(e){if(e&&this.defaultParams)for(let t=0;t{this.requestHandle&&this.requestHandle.abort()});const e=new Promise((e,t)=>{this.requestHandle.then(t=>{this.resolveWithHelper?e(this):e(t)}).fail(e=>{if(429===e.status)return console.log(`Warning: the '${K.param(this.getParams)}' request was rate limited!`),void t(e);if("abort"===e.statusText||0===e.status)return;const o=!document.querySelector("#login_form"),i="1"===e.getResponseHeader("X-Matomo-Session-Timed-Out");if(i&&o)return q("matomo_session_timed_out","1",6e4),void M.helper.refreshAfter(0);console.log(`Warning: the ${K.param(this.getParams)} request failed!`),t(e)})});return e}abort(){this.requestHandle&&"function"===typeof this.requestHandle.abort&&(this.requestHandle.abort(),this.requestHandle=null)}buildAjaxCall(){const e=this,t=this.buildRequestUrl(this.getParams),o={type:"POST",async:!0,url:t,dataType:this.format||"json",complete:this.completeCallback,headers:this.headers?this.headers:void 0,error:function(...t){e.abortable&&(window.globalAjaxQueue.active-=1),e.errorCallback&&e.errorCallback.apply(this,t)},success:(t,o,i)=>{this.handleApiErrorResponseOrCallback(t,o,i),e.abortable&&(window.globalAjaxQueue.active-=1),M.ajaxRequestFinished&&M.ajaxRequestFinished()},data:this.mixinDefaultPostParams(this.postParams),timeout:null!==this.timeout?this.timeout:void 0};return K.ajax(o)}isRequestToApiMethod(){return this.getParams&&"API"===this.getParams.module&&this.getParams.method||this.postParams&&"API"===this.postParams.module&&this.postParams.method}isWidgetizedRequest(){return"Widgetize"===broadcast.getValueFromUrl("module")}getDefaultPostParams(){return this.withToken||this.isRequestToApiMethod()||M.shouldPropagateTokenAuth?{token_auth:M.token_auth,force_api_session:broadcast.isWidgetizeRequestWithoutSession()?0:1}:{}}mixinDefaultPostParams(e){const t=this.getDefaultPostParams(),o=Object.assign(Object.assign({},t),e);return o}mixinDefaultGetParams(e){const t=U.getSearchParam("segment"),o={idSite:M.idSite?M.idSite.toString():broadcast.getValueFromUrl("idSite"),period:M.period||broadcast.getValueFromUrl("period"),segment:t},i=e,n=Q(i)||Q(this.postParams);return i.token_auth&&(i.token_auth=null,delete i.token_auth),Object.keys(o).forEach(e=>{!this.useGETDefaultParameter(e)||"segment"===e&&n||null!==i[e]&&"undefined"!==typeof i[e]&&""!==i[e]||null!==this.postParams[e]&&"undefined"!==typeof this.postParams[e]&&""!==this.postParams[e]||!o[e]||(i[e]=o[e])}),!this.useGETDefaultParameter("date")||i.date||this.postParams.date||(i.date=M.currentDateString),i}getRequestHandle(){return this.requestHandle}}function oe(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} + */R.updatePeriodParamsFromUrl=H.updatePeriodParamsFromUrl.bind(H);const{$:K}=window;function Y(e,t){"abort"!==t&&e&&0!==e.status&&("undefined"!==typeof Piwik_Popover?Piwik_Popover.isOpen()&&e&&500===e.status?K(document.body).html(piwikHelper.escape(e.responseText)):K("#loadingError").show():console.log("Request failed: "+e.responseText))}function Q(e){return Object.prototype.hasOwnProperty.call(e,"segment")&&"undefined"!==typeof e.segment}window.globalAjaxQueue=[],window.globalAjaxQueue.active=0,window.globalAjaxQueue.clean=function(){for(let e=this.length;e>=0;e-=1)this[e]&&4!==this[e].readyState||this.splice(e,1)},window.globalAjaxQueue.push=function(...e){return this.active+=e.length,this.clean(),Array.prototype.push.call(this,...e)},window.globalAjaxQueue.abort=function(){this.forEach(e=>e&&e.abort&&e.abort()),this.splice(0,this.length),this.active=0};class J extends Error{}class X extends Error{constructor(e,t,o){super("Chunked bulk request failed."),G(this,"xhr",void 0),G(this,"status",void 0),G(this,"errorThrown",void 0),this.xhr=e,this.status=t,this.errorThrown=o}}class Z extends Error{constructor(){super("Chunked bulk request was aborted.")}}class ee extends Error{constructor(){super("Chunked bulk request timed out due to session expiration.")}}class te{static fetch(e,t={}){if(Array.isArray(e)&&t.returnResponseObject)throw new Error(this.UNSUPPORTED_BULK_RESPONSE_OBJECT_ERROR);const o=new te;if(t.withTokenInUrl&&o.withTokenInUrl(),t.errorElement&&o.setErrorElement(t.errorElement),t.redirectOnSuccess&&o.redirectOnSuccess(!0!==t.redirectOnSuccess?t.redirectOnSuccess:void 0),o.setFormat(t.format||"json"),Array.isArray(e))o.setBulkRequests(...e);else{Object.keys(e).forEach(e=>{if(/password/i.test(e))throw new Error(`Password parameters are not allowed to be sent as GET parameter. Please send ${e} as POST parameter instead.`)});const i=Q(e);let n={};if(i){let t=null;null!==e.segment&&(t=encodeURIComponent(e.segment)),n={segment:t}}o.addParams(Object.assign(Object.assign({module:"API",format:t.format||"json"},e),n),"get")}t.postParams&&o.addParams(t.postParams,"post"),t.headers&&(o.headers=Object.assign(Object.assign({},o.headers),t.headers));let i=!0;return"undefined"===typeof t.createErrorNotification||t.createErrorNotification||(o.useCallbackInCaseOfError(),o.setErrorCallback(null),i=!1),t.abortController&&(o.abortController=t.abortController),t.returnResponseObject&&(o.resolveWithHelper=!0),!1===t.abortable&&(o.abortable=!1),o.send().then(e=>{const t=e instanceof te?e.requestHandle.responseJSON:e,i="API.getBulkRequest"===o.postParams.method&&Array.isArray(t)?t:[t],n=i.filter(e=>"error"===e.result).map(e=>e.message);if(n.length)throw new J(n.filter(e=>e.length).join("\n"));return e}).catch(e=>{if(i||e instanceof J)throw e;let t="Something went wrong";e instanceof Z&&(t="Request was possibly aborted"),e instanceof ee&&(t="Session timed out");const o="object"===typeof e&&null!==e&&"status"in e?e.status:null;throw 504===o&&(t="Request was possibly aborted"),429===o&&(t="Rate Limit was exceed"),new Error(t)})}static getBulkRequestLimit(){const e=parseInt(""+I.apiBulkRequestLimit,10);return Number.isNaN(e)?-1:e}static splitIntoChunks(e,t){const o=[];for(let i=0;i"error"===e.result).map(e=>e.message).filter(e=>e.length).reduce((e,t)=>(e[t]=(e[t]||0)+1,e),{});if(n&&Object.keys(n).length&&!this.useRegularCallbackInCaseOfError){let e="";Object.keys(n).forEach(t=>{e.length&&(e+="
"),n[t]>1?e+=`${t} (${n[t]}x)`:e+=t});let t=null,o="toast";K(this.errorElement).length&&e.length&&(K(this.errorElement).show(),t=this.errorElement,o=null);const i=!document.querySelector("#login_form");if(e&&i){const i=window["require"]("piwik/UI"),n=new i.Notification;n.show(e,{placeat:t,context:"error",type:o,id:"ajaxHelper"}),n.scrollToNotification()}}else this.callback&&this.callback(e,t,o)}buildRequestUrl(e){const t=this.mixinDefaultGetParams(e);let o=this.getUrl;if("?"!==o[o.length-1]&&(o+="&"),Object.prototype.hasOwnProperty.call(t,"segment")){const e=t.segment;if(delete t.segment,null!==e&&"undefined"!==typeof e){const t=(""+e).replace(/&/g,"%26").replace(/#/g,"%23").replace(/\?/g,"%3F");o=`${o}segment=${t}&`}}if(t.date){const e=t.date.toString(),n=t.period;if(!/^[a-z0-9, -]+$/i.test(e))throw new Error(`Invalid date '${e}'.`);if(n&&c.isRecognizedPeriod(n)){const t=/^(last|previous)\d/i.test(e)||-1!==e.indexOf(",");try{t&&"range"!==n?k.parse(e,n):c.parse(n,e)}catch(i){throw new Error(`Invalid date '${e}' for period '${n}'.`)}}o=`${o}date=${encodeURIComponent(e).replace(/%2C/g,",")}&`,delete t.date}return o+=K.param(t),o}buildChunkedBulkAjaxCall(e){const t=this.buildRequestUrl(Object.assign({},this.getParams)),o=e.map(e=>"string"===typeof e?e:K.param(e));return K.ajax({type:"POST",async:!0,url:t,dataType:this.format||"json",headers:this.headers?this.headers:void 0,data:this.mixinDefaultPostParams(Object.assign(Object.assign({},this.postParams),{},{urls:o})),timeout:null!==this.timeout?this.timeout:void 0})}getBulkRequestUrls(){return"API.getBulkRequest"===this.postParams.method&&Array.isArray(this.postParams.urls)?this.postParams.urls:null}shouldSendBulkRequestInChunks(){const e=this.getBulkRequestUrls();if(!e)return!1;const t=te.getBulkRequestLimit();return t>0&&e.length>t}shouldRejectBulkResponseObjectRequest(){return!!this.getBulkRequestUrls()&&this.resolveWithHelper}sendBulkRequestInChunks(){const e=this.getBulkRequestUrls();if(!e)return Promise.resolve([]);const t=te.getBulkRequestLimit();if(t<=0)return Promise.resolve([]);try{this.buildRequestUrl(Object.assign({},this.getParams))}catch(h){return this.hideLoadingElement(),Promise.reject(h)}const o=this.abortController||new AbortController;this.abortController=o;let i=null,n=!1,a=!1;const r=()=>{!n&&this.abortable&&(window.globalAjaxQueue.active-=1,n=!0)},s=(e,t)=>{!a&&this.completeCallback&&(a=!0,this.completeCallback(e,t))},l={readyState:1,status:0,statusText:"",responseJSON:[],abort:()=>{o.abort()}},c=l;let d=c;this.requestHandle=c,this.abortable&&window.globalAjaxQueue.push(c),o.signal.addEventListener("abort",()=>{i&&i.abort()});const u=te.splitIntoChunks(e,t),m=[],p=e=>e>=u.length?Promise.resolve(m):(i=this.buildChunkedBulkAjaxCall(u[e]),new Promise((e,t)=>{i.then((t,o,i)=>{d=i,l.readyState=i.readyState,l.status=i.status,l.statusText=i.statusText||o,Array.isArray(t)?m.push(...t):m.push(t),e(m)}).fail((e,o,i)=>{l.readyState=e.readyState,l.status=e.status,l.statusText=e.statusText||o,t(new X(e,o,i))})}).then(()=>p(e+1)));return p(0).then(e=>(l.readyState=4,l.responseJSON=e,this.handleApiErrorResponseOrCallback(e,"success",d),r(),s(d,"success"),I.ajaxRequestFinished&&I.ajaxRequestFinished(),e)).catch(e=>{if(!(e instanceof X))throw e;const{xhr:t,status:o,errorThrown:i}=e;if(r(),this.errorCallback&&this.errorCallback.apply(this,[t,o,i]),s(t,o),429===t.status)throw console.log(`Warning: the '${K.param(this.getParams)}' request was rate limited!`),t;if("abort"===t.statusText||0===t.status)throw new Z;const n=!document.querySelector("#login_form"),a="1"===t.getResponseHeader("X-Matomo-Session-Timed-Out");if(a&&n)throw q("matomo_session_timed_out","1",6e4),I.helper.refreshAfter(0),new ee;throw console.log(`Warning: the ${K.param(this.getParams)} request failed!`),t})}static post(e,t={},o={}){return te.fetch(e,Object.assign(Object.assign({},o),{},{postParams:t}))}static oneAtATime(e,t){let o=null;return(i,n)=>(o&&o.abort(),o=new AbortController,te.post(Object.assign(Object.assign({},i),{},{method:e}),n,Object.assign(Object.assign({},t),{},{abortController:o})).finally(()=>{o=null}))}constructor(){G(this,"format","json"),G(this,"timeout",null),G(this,"callback",null),G(this,"useRegularCallbackInCaseOfError",!1),G(this,"errorCallback",void 0),G(this,"withToken",!1),G(this,"completeCallback",void 0),G(this,"getParams",{}),G(this,"getUrl","?"),G(this,"postParams",{}),G(this,"loadingElement",null),G(this,"errorElement","#ajaxError"),G(this,"headers",{"X-Requested-With":"XMLHttpRequest"}),G(this,"requestHandle",null),G(this,"abortController",null),G(this,"abortable",!0),G(this,"defaultParams",["idSite","period","date","segment"]),G(this,"resolveWithHelper",!1),this.errorCallback=Y}addParams(e,t){const o="string"===typeof e?window.broadcast.getValuesFromUrl(e):e,i=["compareSegments","comparePeriods","compareDates"];Object.keys(o).forEach(e=>{let n=o[e];(-1===i.indexOf(e)||n)&&("boolean"===typeof n&&(n=n?1:0),"get"===t.toLowerCase()?this.getParams[e]=n:"post"===t.toLowerCase()&&(this.postParams[e]=n))})}withTokenInUrl(){this.withToken=!0}setUrl(e){this.addParams(broadcast.getValuesFromUrl(e),"GET")}setBulkRequests(...e){const t=e.map(e=>"string"===typeof e?e:K.param(e));this.addParams({module:"API",method:"API.getBulkRequest",urls:t,format:"json"},"post")}setTimeout(e){this.timeout=e}setCallback(e){this.callback=e}useCallbackInCaseOfError(){this.useRegularCallbackInCaseOfError=!0}redirectOnSuccess(e){this.setCallback(()=>{piwikHelper.redirect(e)})}setErrorCallback(e){this.errorCallback=e}setCompleteCallback(e){this.completeCallback=e}setFormat(e){this.format=e}setLoadingElement(e){this.loadingElement=e||"#ajaxLoadingDiv"}setErrorElement(e){e&&(this.errorElement=e)}useGETDefaultParameter(e){if(e&&this.defaultParams)for(let t=0;t{this.requestHandle&&this.requestHandle.abort()});const e=new Promise((e,t)=>{this.requestHandle.then(t=>{this.resolveWithHelper?e(this):e(t)}).fail(e=>{if(429===e.status)return console.log(`Warning: the '${K.param(this.getParams)}' request was rate limited!`),void t(e);if("abort"===e.statusText||0===e.status)return;const o=!document.querySelector("#login_form"),i="1"===e.getResponseHeader("X-Matomo-Session-Timed-Out");if(i&&o)return q("matomo_session_timed_out","1",6e4),void I.helper.refreshAfter(0);console.log(`Warning: the ${K.param(this.getParams)} request failed!`),t(e)})});return e}abort(){this.requestHandle&&"function"===typeof this.requestHandle.abort&&(this.requestHandle.abort(),this.requestHandle=null)}buildAjaxCall(){const e=this,t=this.buildRequestUrl(this.getParams),o={type:"POST",async:!0,url:t,dataType:this.format||"json",complete:this.completeCallback,headers:this.headers?this.headers:void 0,error:function(...t){e.abortable&&(window.globalAjaxQueue.active-=1),e.errorCallback&&e.errorCallback.apply(this,t)},success:(t,o,i)=>{this.handleApiErrorResponseOrCallback(t,o,i),e.abortable&&(window.globalAjaxQueue.active-=1),I.ajaxRequestFinished&&I.ajaxRequestFinished()},data:this.mixinDefaultPostParams(this.postParams),timeout:null!==this.timeout?this.timeout:void 0};return K.ajax(o)}isRequestToApiMethod(){return this.getParams&&"API"===this.getParams.module&&this.getParams.method||this.postParams&&"API"===this.postParams.module&&this.postParams.method}isWidgetizedRequest(){return"Widgetize"===broadcast.getValueFromUrl("module")}getDefaultPostParams(){return this.withToken||this.isRequestToApiMethod()||I.shouldPropagateTokenAuth?{token_auth:I.token_auth,force_api_session:broadcast.isWidgetizeRequestWithoutSession()?0:1}:{}}mixinDefaultPostParams(e){const t=this.getDefaultPostParams(),o=Object.assign(Object.assign({},t),e);return o}mixinDefaultGetParams(e){const t=U.getSearchParam("segment"),o={idSite:I.idSite?I.idSite.toString():broadcast.getValueFromUrl("idSite"),period:I.period||broadcast.getValueFromUrl("period"),segment:t},i=e,n=Q(i)||Q(this.postParams);return i.token_auth&&(i.token_auth=null,delete i.token_auth),Object.keys(o).forEach(e=>{!this.useGETDefaultParameter(e)||"segment"===e&&n||null!==i[e]&&"undefined"!==typeof i[e]&&""!==i[e]||null!==this.postParams[e]&&"undefined"!==typeof this.postParams[e]&&""!==this.postParams[e]||!o[e]||(i[e]=o[e])}),!this.useGETDefaultParameter("date")||i.date||this.postParams.date||(i.date=I.currentDateString),i}getRequestHandle(){return this.requestHandle}}function oe(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */G(te,"UNSUPPORTED_BULK_RESPONSE_OBJECT_ERROR","AjaxHelper returnResponseObject is not supported for bulk requests."),window.ajaxHelper=te;const{$:ie}=window;class ne{constructor(){oe(this,"defaultMinFractionDigits",0),oe(this,"defaultMaxFractionDigits",2)}format(e,t,o,i){if(!ie.isNumeric(e))return String(e);let n=e,a=t||M.numbers.patternNumber;const r=a.split(";");1===r.length&&r.push("-"+r[0]);const s=n<0;if(a=s?r[1]:r[0],n=Math.abs(n),o>=0){const e=10**o;n=Math.round(n*e)/e}const l=n.toString().split(".");let c=l[0],d=l[1]||"";const u=-1!==a.indexOf(",");if(u){const e=a.match(/#+0/),t=(null===e||void 0===e?void 0:e[0].length)||0;let o=(null===e||void 0===e?void 0:e[0].length)||0;const i=a.split(",");i.length>2&&(o=i[1].length);const n=c.split("").reverse();let r=[];r.push(n.splice(0,t).reverse().join(""));while(n.length)r.push(n.splice(0,o).reverse().join(""));r=r.reverse(),c=r.join(",")}if(i>0&&(d=d.replace(/0+$/,""),d.length{let i=e;Object.entries(t).some(([e,t])=>-1!==i.indexOf(e)&&(i=i.replace(e,t),!0)),o+=i}),o}valOrDefault(e,t){return"undefined"===typeof e?t:e}getMaxFractionDigitsForCompactFormat(e){return 1===e?1:0}determineCorrectCompactPattern(e,t){let o=0,i=0,n="";if(Math.round(t)<1e3)return["0",1];for(o=1e3;o<=1e19;o*=10){const r=o+"One",s=o+"Other";if(1===Math.round(t/o)&&""!==(null===e||void 0===e?void 0:e[r])?(i=o,n=r):Math.round(t/o)>=1&&""!==(null===e||void 0===e?void 0:e[s])&&(i=o,n=s),null!==e&&void 0!==e&&e[n]){var a;const i=(null===e||void 0===e||null===(a=e[n].match(/0/g))||void 0===a?void 0:a.length)||1;if(Math.round(t*10**i/(10*o))<10**i)break}}return[(null===e||void 0===e?void 0:e[n])||"0",i]}formatCompact(e,t,o){var i;const n=(null===(i=e.match(/0/g))||void 0===i?void 0:i.length)||0;let a=t;n>1&&(a/=10**(n-1));const r=this.getMaxFractionDigitsForCompactFormat(n),s=10**r,l=Math.round(o/a*s)/s,c=this.formatNumber(l,r,0);return e.replace(/(0+)/,c).replace(/('\.')/,".")}parseFormattedNumber(e){const t=e.indexOf(M.numbers.symbolMinus)>-1||e.startsWith("-"),o=e.split(M.numbers.symbolDecimal);return o.forEach((e,t)=>{o[t]=e.replace(/[^0-9]/g,"")}),(t?-1:1)*parseFloat(o.join("."))}formatNumber(e,t,o){return this.format(e,M.numbers.patternNumber,this.valOrDefault(t,this.defaultMaxFractionDigits),this.valOrDefault(o,this.defaultMinFractionDigits))}formatPercent(e,t,o){return this.format(e,M.numbers.patternPercent,this.valOrDefault(t,this.defaultMaxFractionDigits),this.valOrDefault(o,this.defaultMinFractionDigits))}formatCurrency(e,t,o,i){const n=this.format(e,M.numbers.patternCurrency,this.valOrDefault(o,this.defaultMaxFractionDigits),this.valOrDefault(i,this.defaultMinFractionDigits));return n.replace("¤",t)}formatNumberCompact(e){const t=e,[o,i]=this.determineCorrectCompactPattern(M.numbers.patternsCompactNumber||[],t);return Math.round(t)<1e3||"0"===o?this.formatNumber(t,this.getMaxFractionDigitsForCompactFormat(Math.round(t)),0):this.formatCompact(o,i,t)}formatCurrencyCompact(e,t){const o=e,[i,n]=this.determineCorrectCompactPattern(M.numbers.patternsCompactCurrency||[],o);return Math.round(o)<1e3||"0"===i?this.formatCurrency(o,t,this.getMaxFractionDigitsForCompactFormat(Math.round(o)),0):this.formatCompact(i,n,o).replace("¤",t)}formatEvolution(e,t,o,i){if(i)return this.formatPercent(Math.abs(e),t,o);const n=this.formatPercent(e,t,o);return`${e>0?M.numbers.symbolPlus:""}${n}`}calculateAndFormatEvolution(e,t,o){const i=parseInt(t,10),n=parseInt(e,10)-i;let a;a=0===n||Number.isNaN(n)?0:0===i||Number.isNaN(i)?100:n/i*100;let r=3;return Math.abs(a)>100?r=0:Math.abs(a)>10?r=1:Math.abs(a)>1&&(r=2),this.formatEvolution(a,r,0,o)}}var ae=new ne;window.NumberFormatter=ae; + */G(te,"UNSUPPORTED_BULK_RESPONSE_OBJECT_ERROR","AjaxHelper returnResponseObject is not supported for bulk requests."),window.ajaxHelper=te;const{$:ie}=window;class ne{constructor(){oe(this,"defaultMinFractionDigits",0),oe(this,"defaultMaxFractionDigits",2)}format(e,t,o,i){if(!ie.isNumeric(e))return String(e);let n=e,a=t||I.numbers.patternNumber;const r=a.split(";");1===r.length&&r.push("-"+r[0]);const s=n<0;if(a=s?r[1]:r[0],n=Math.abs(n),o>=0){const e=10**o;n=Math.round(n*e)/e}const l=n.toString().split(".");let c=l[0],d=l[1]||"";const u=-1!==a.indexOf(",");if(u){const e=a.match(/#+0/),t=(null===e||void 0===e?void 0:e[0].length)||0;let o=(null===e||void 0===e?void 0:e[0].length)||0;const i=a.split(",");i.length>2&&(o=i[1].length);const n=c.split("").reverse();let r=[];r.push(n.splice(0,t).reverse().join(""));while(n.length)r.push(n.splice(0,o).reverse().join(""));r=r.reverse(),c=r.join(",")}if(i>0&&(d=d.replace(/0+$/,""),d.length{let i=e;Object.entries(t).some(([e,t])=>-1!==i.indexOf(e)&&(i=i.replace(e,t),!0)),o+=i}),o}valOrDefault(e,t){return"undefined"===typeof e?t:e}getMaxFractionDigitsForCompactFormat(e){return 1===e?1:0}determineCorrectCompactPattern(e,t){let o=0,i=0,n="";if(Math.round(t)<1e3)return["0",1];for(o=1e3;o<=1e19;o*=10){const r=o+"One",s=o+"Other";if(1===Math.round(t/o)&&""!==(null===e||void 0===e?void 0:e[r])?(i=o,n=r):Math.round(t/o)>=1&&""!==(null===e||void 0===e?void 0:e[s])&&(i=o,n=s),null!==e&&void 0!==e&&e[n]){var a;const i=(null===e||void 0===e||null===(a=e[n].match(/0/g))||void 0===a?void 0:a.length)||1;if(Math.round(t*10**i/(10*o))<10**i)break}}return[(null===e||void 0===e?void 0:e[n])||"0",i]}formatCompact(e,t,o){var i;const n=(null===(i=e.match(/0/g))||void 0===i?void 0:i.length)||0;let a=t;n>1&&(a/=10**(n-1));const r=this.getMaxFractionDigitsForCompactFormat(n),s=10**r,l=Math.round(o/a*s)/s,c=this.formatNumber(l,r,0);return e.replace(/(0+)/,c).replace(/('\.')/,".")}parseFormattedNumber(e){const t=e.indexOf(I.numbers.symbolMinus)>-1||e.startsWith("-"),o=e.split(I.numbers.symbolDecimal);return o.forEach((e,t)=>{o[t]=e.replace(/[^0-9]/g,"")}),(t?-1:1)*parseFloat(o.join("."))}formatNumber(e,t,o){return this.format(e,I.numbers.patternNumber,this.valOrDefault(t,this.defaultMaxFractionDigits),this.valOrDefault(o,this.defaultMinFractionDigits))}formatPercent(e,t,o){return this.format(e,I.numbers.patternPercent,this.valOrDefault(t,this.defaultMaxFractionDigits),this.valOrDefault(o,this.defaultMinFractionDigits))}formatCurrency(e,t,o,i){const n=this.format(e,I.numbers.patternCurrency,this.valOrDefault(o,this.defaultMaxFractionDigits),this.valOrDefault(i,this.defaultMinFractionDigits));return n.replace("¤",t)}formatNumberCompact(e){const t=e,[o,i]=this.determineCorrectCompactPattern(I.numbers.patternsCompactNumber||[],t);return Math.round(t)<1e3||"0"===o?this.formatNumber(t,this.getMaxFractionDigitsForCompactFormat(Math.round(t)),0):this.formatCompact(o,i,t)}formatCurrencyCompact(e,t){const o=e,[i,n]=this.determineCorrectCompactPattern(I.numbers.patternsCompactCurrency||[],o);return Math.round(o)<1e3||"0"===i?this.formatCurrency(o,t,this.getMaxFractionDigitsForCompactFormat(Math.round(o)),0):this.formatCompact(i,n,o).replace("¤",t)}formatEvolution(e,t,o,i){if(i)return this.formatPercent(Math.abs(e),t,o);const n=this.formatPercent(e,t,o);return`${e>0?I.numbers.symbolPlus:""}${n}`}calculateAndFormatEvolution(e,t,o){const i=parseInt(t,10),n=parseInt(e,10)-i;let a;a=0===n||Number.isNaN(n)?0:0===i||Number.isNaN(i)?100:n/i*100;let r=3;return Math.abs(a)>100?r=0:Math.abs(a)>10?r=1:Math.abs(a)>1&&(r=2),this.formatEvolution(a,r,0,o)}}var ae=new ne;window.NumberFormatter=ae; /*! * Matomo - free/libre analytics platform * @@ -96,20 +96,20 @@ const{$:re}=window;class se{constructor(){this.setup()}setup(){Object(D["watch"] * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */const{$:le}=window;let ce=!1;function de(){let e=!!parseInt(W("zenMode"),10);const t=le(".top_controls .zenModeToggle");function o(){e?(le("body").addClass("zenMode"),t.addClass("icon-arrowdown").removeClass("icon-arrowup"),t.prop("title",a("CoreHome_ExitZenMode"))):(le("body").removeClass("zenMode"),t.removeClass("icon-arrowdown").addClass("icon-arrowup"),t.prop("title",a("CoreHome_EnterZenMode")))}ce||(M.helper.registerShortcut("z",a("CoreHome_ShortcutZenMode"),t=>{t.altKey||(e=!e,q("zenMode",e?"1":"0"),o())}),ce=!0),t.off("click.matomoZenMode").on("click.matomoZenMode",()=>{window.Mousetrap.trigger("z")}),o()} + */const{$:le}=window;let ce=!1;function de(){let e=!!parseInt(W("zenMode"),10);const t=le(".top_controls .zenModeToggle");function o(){e?(le("body").addClass("zenMode"),t.addClass("icon-arrowdown").removeClass("icon-arrowup"),t.prop("title",a("CoreHome_ExitZenMode"))):(le("body").removeClass("zenMode"),t.removeClass("icon-arrowdown").addClass("icon-arrowup"),t.prop("title",a("CoreHome_EnterZenMode")))}ce||(I.helper.registerShortcut("z",a("CoreHome_ShortcutZenMode"),t=>{t.altKey||(e=!e,q("zenMode",e?"1":"0"),o())}),ce=!0),t.off("click.matomoZenMode").on("click.matomoZenMode",()=>{window.Mousetrap.trigger("z")}),o()} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later */ -function ue(e,...t){const o=t;return window._pk_externalRawLink?window._pk_externalRawLink(e,o):e}function pe(e,...t){if(!e)return"";const o=t.length>0&&t[0]?t[0]:null,i=t.length>1&&t[1]?t[1]:null,n=t.length>2&&t[2]?t[2]:null,a=ue(e,o,i,n);return''} +function ue(e,...t){const o=t;return window._pk_externalRawLink?window._pk_externalRawLink(e,o):e}function me(e,...t){if(!e)return"";const o=t.length>0&&t[0]?t[0]:null,i=t.length>1&&t[1]?t[1]:null,n=t.length>2&&t[2]?t[2]:null,a=ue(e,o,i,n);return''} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */function me(e,t,o){return ae.formatNumber(e,t,o)}function he(e,t,o){return ae.formatPercent(e,t,o)}function ge(e,t,o,i){return ae.formatCurrency(e,t,o,i)}function be(e,t,o,i){return ae.formatEvolution(e,t,o,i)}function fe(e,t,o){return ae.calculateAndFormatEvolution(e,t,o)} + */function pe(e,t,o){return ae.formatNumber(e,t,o)}function he(e,t,o){return ae.formatPercent(e,t,o)}function ge(e,t,o,i){return ae.formatCurrency(e,t,o,i)}function be(e,t,o,i){return ae.formatEvolution(e,t,o,i)}function fe(e,t,o){return ae.calculateAndFormatEvolution(e,t,o)} /*! * Matomo - free/libre analytics platform * @@ -121,13 +121,13 @@ function ue(e,...t){const o=t;return window._pk_externalRawLink?window._pk_exter * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */function ve(...e){const t=Object(D["createApp"])(...e);return t.config.globalProperties.$sanitize=window.vueSanitize,t.config.globalProperties.$sanitizeUrl=window.vueSanitizeUrl,t.config.globalProperties.translate=a,t.config.globalProperties.translateOrDefault=r,t.config.globalProperties.externalLink=pe,t.config.globalProperties.externalRawLink=ue,t.config.globalProperties.formatNumber=me,t.config.globalProperties.formatPercent=he,t.config.globalProperties.formatCurrency=ge,t.config.globalProperties.formatEvolution=be,t.config.globalProperties.calculateAndFormatEvolution=fe,t} + */function ve(...e){const t=Object(D["createApp"])(...e);return t.config.globalProperties.$sanitize=window.vueSanitize,t.config.globalProperties.$sanitizeUrl=window.vueSanitizeUrl,t.config.globalProperties.translate=a,t.config.globalProperties.translateOrDefault=r,t.config.globalProperties.externalLink=me,t.config.globalProperties.externalRawLink=ue,t.config.globalProperties.formatNumber=pe,t.config.globalProperties.formatPercent=he,t.config.globalProperties.formatCurrency=ge,t.config.globalProperties.formatEvolution=be,t.config.globalProperties.calculateAndFormatEvolution=fe,t} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */le(de),M.on("Matomo.topControlsRendered",()=>{de()});const Oe={},ye=120,je=50,we=1e3;function Se(e){if(Oe[e])return Oe[e];if(window[e])return Promise.resolve(window[e]);const t="?module=Proxy&action=getPluginUmdJs&plugin="+e;let o,i;const n=document.createElement("script");let a;n.charset="utf-8",n.timeout=ye,n.src=t;const r=new Error,s=t=>{n.onerror=null,n.onload=null,clearTimeout(a);let s=0;function l(){if(s+=je,o&&i)if(window[e]&&i)try{i(window[e])}finally{o=void 0,i=void 0}else if(s>we)try{const n=t&&("load"===t.type?"missing":t.type),a=t&&t.target&&t.target.src;r.message=`Loading plugin ${e} on demand failed.\n(${n}: ${a})`,r.name="PluginOnDemandLoadError",r.type=n,r.request=a,o(r)}finally{o=void 0,i=void 0}else setTimeout(l,je)}setTimeout(l,je)};return a=setTimeout(()=>{s({type:"timeout",target:n})},ye),n.onerror=s,n.onload=s,document.head.appendChild(n),new Promise((e,t)=>{i=e,o=t})} + */le(de),I.on("Matomo.topControlsRendered",()=>{de()});const Oe={},ye=120,je=50,we=1e3;function Se(e){if(Oe[e])return Oe[e];if(window[e])return Promise.resolve(window[e]);const t="?module=Proxy&action=getPluginUmdJs&plugin="+e;let o,i;const n=document.createElement("script");let a;n.charset="utf-8",n.timeout=ye,n.src=t;const r=new Error,s=t=>{n.onerror=null,n.onload=null,clearTimeout(a);let s=0;function l(){if(s+=je,o&&i)if(window[e]&&i)try{i(window[e])}finally{o=void 0,i=void 0}else if(s>we)try{const n=t&&("load"===t.type?"missing":t.type),a=t&&t.target&&t.target.src;r.message=`Loading plugin ${e} on demand failed.\n(${n}: ${a})`,r.name="PluginOnDemandLoadError",r.type=n,r.request=a,o(r)}finally{o=void 0,i=void 0}else setTimeout(l,je)}setTimeout(l,je)};return a=setTimeout(()=>{s({type:"timeout",target:n})},ye),n.onerror=s,n.onload=s,document.head.appendChild(n),new Promise((e,t)=>{i=e,o=t})} /*! * Matomo - free/libre analytics platform * @@ -145,7 +145,13 @@ function ue(e,...t){const o=t;return window._pk_externalRawLink?window._pk_exter * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */function Te(e){return"undefined"===typeof e?e:JSON.parse(JSON.stringify(e))}const xe={ref:"root"};function Ve(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",xe,[e.componentWrapper?(Object(D["openBlock"])(),Object(D["createBlock"])(Object(D["resolveDynamicComponent"])(e.componentWrapper),{key:0})):Object(D["createCommentVNode"])("",!0)],512)}var Be=Object(D["defineComponent"])({props:{html:String},mounted(){M.helper.compileVueEntryComponents(this.$refs.root)},beforeUnmount(){M.helper.destroyVueComponent(this.$refs.root)},computed:{componentWrapper(){return this.html?Object(D["markRaw"])({template:this.html}):null}}});Be.render=Ve;var Ne=Be;const Ie={class:"loadingPiwik"};function Me(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("MatomoLoader");return Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Ie,[Object(D["createVNode"])(r),Object(D["createElementVNode"])("span",null,Object(D["toDisplayString"])(e.loadingMessage),1)],512)),[[D["vShow"],e.loading]])}const Fe={class:"matomo-loader"},Re=Object(D["createElementVNode"])("span",null,null,-1),Le=Object(D["createElementVNode"])("span",null,null,-1),Ae=Object(D["createElementVNode"])("span",null,null,-1),_e=[Re,Le,Ae];function He(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createElementBlock"])("span",Fe,_e)}var $e=Object(D["defineComponent"])({});$e.render=He;var Ue=$e,qe=Object(D["defineComponent"])({components:{MatomoLoader:Ue},props:{loading:{type:Boolean,required:!0,default:!1},loadingMessage:{type:String,required:!1,default:a("General_LoadingData")}}});qe.render=Me;var We=qe;function ze(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{class:Object(D["normalizeClass"])(["alert",{["alert-"+e.severity]:!0}])},[Object(D["renderSlot"])(e.$slots,"default")],2)}var Ge=Object(D["defineComponent"])({props:{severity:{type:String,required:!0}}});Ge.render=ze;var Ke=Ge,Ye={mounted(e,t){let o={};$(e).addClass("matomo-dropdown-menu");const i=!!$(e).parent().closest(".dropdown-content").length;var n;i&&(o={hover:!0},$(e).addClass("submenu"),$((null===(n=t.value)||void 0===n?void 0:n.activates)||$(e).data("target")).addClass("submenu-dropdown-content"),$(e).parents(".dropdown-content").addClass("submenu-container"));$(e).dropdown(o)},updated(e){Object(D["nextTick"])(()=>{$(e).addClass("matomo-dropdown-menu")})}}; + */function Te(e){return"undefined"===typeof e?e:JSON.parse(JSON.stringify(e))} +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */function xe(e,t){if(!e)return"";const[o,...i]=Array.from(e);return o.toLocaleUpperCase(t||void 0)+i.join("")}const Ve={ref:"root"};function Be(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Ve,[e.componentWrapper?(Object(D["openBlock"])(),Object(D["createBlock"])(Object(D["resolveDynamicComponent"])(e.componentWrapper),{key:0})):Object(D["createCommentVNode"])("",!0)],512)}var Ne=Object(D["defineComponent"])({props:{html:String},mounted(){I.helper.compileVueEntryComponents(this.$refs.root)},beforeUnmount(){I.helper.destroyVueComponent(this.$refs.root)},computed:{componentWrapper(){return this.html?Object(D["markRaw"])({template:this.html}):null}}});Ne.render=Be;var Me=Ne;const Ie={class:"loadingPiwik"};function Fe(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("MatomoLoader");return Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Ie,[Object(D["createVNode"])(r),Object(D["createElementVNode"])("span",null,Object(D["toDisplayString"])(e.loadingMessage),1)],512)),[[D["vShow"],e.loading]])}const Re={class:"matomo-loader"},Le=Object(D["createElementVNode"])("span",null,null,-1),Ae=Object(D["createElementVNode"])("span",null,null,-1),_e=Object(D["createElementVNode"])("span",null,null,-1),He=[Le,Ae,_e];function $e(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createElementBlock"])("span",Re,He)}var Ue=Object(D["defineComponent"])({});Ue.render=$e;var qe=Ue,We=Object(D["defineComponent"])({components:{MatomoLoader:qe},props:{loading:{type:Boolean,required:!0,default:!1},loadingMessage:{type:String,required:!1,default:a("General_LoadingData")}}});We.render=Fe;var ze=We;function Ge(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{class:Object(D["normalizeClass"])(["alert",{["alert-"+e.severity]:!0}])},[Object(D["renderSlot"])(e.$slots,"default")],2)}var Ke=Object(D["defineComponent"])({props:{severity:{type:String,required:!0}}});Ke.render=Ge;var Ye=Ke,Qe={mounted(e,t){let o={};$(e).addClass("matomo-dropdown-menu");const i=!!$(e).parent().closest(".dropdown-content").length;var n;i&&(o={hover:!0},$(e).addClass("submenu"),$((null===(n=t.value)||void 0===n?void 0:n.activates)||$(e).data("target")).addClass("submenu-dropdown-content"),$(e).parents(".dropdown-content").addClass("submenu-container"));$(e).dropdown(o)},updated(e){Object(D["nextTick"])(()=>{$(e).addClass("matomo-dropdown-menu")})}}; /*! * Matomo - free/libre analytics platform * @@ -158,188 +164,188 @@ function ue(e,...t){const o=t;return window._pk_externalRawLink?window._pk_exter * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later */ -function Qe(e,t,o){const i=t.value.isMouseDown&&t.value.hasScrolled;t.value.isMouseDown=!1,t.value.hasScrolled=!1,i||e.contains(o.target)||t.value&&t.value.blur()}function Je(e,t){t.value.hasScrolled=!0}function Xe(e,t){t.value.isMouseDown=!0,t.value.hasScrolled=!1}function Ze(e,t,o){27===o.which&&setTimeout(()=>{t.value.isMouseDown=!1,t.value.hasScrolled=!1,t.value.blur&&t.value.blur()},0)}const et=document.documentElement;var tt={mounted(e,t){t.value.isMouseDown=!1,t.value.hasScrolled=!1,t.value.onEscapeHandler=Ze.bind(null,e,t),t.value.onMouseDown=Xe.bind(null,e,t),t.value.onClickOutsideElement=Qe.bind(null,e,t),t.value.onScroll=Je.bind(null,e,t),et.addEventListener("keyup",t.value.onEscapeHandler),et.addEventListener("mousedown",t.value.onMouseDown),et.addEventListener("mouseup",t.value.onClickOutsideElement),et.addEventListener("scroll",t.value.onScroll)},unmounted(e,t){et.removeEventListener("keyup",t.value.onEscapeHandler),et.removeEventListener("mousedown",t.value.onMouseDown),et.removeEventListener("mouseup",t.value.onClickOutsideElement),et.removeEventListener("scroll",t.value.onScroll)}}; +function Je(e,t,o){const i=t.value.isMouseDown&&t.value.hasScrolled;t.value.isMouseDown=!1,t.value.hasScrolled=!1,i||e.contains(o.target)||t.value&&t.value.blur()}function Xe(e,t){t.value.hasScrolled=!0}function Ze(e,t){t.value.isMouseDown=!0,t.value.hasScrolled=!1}function et(e,t,o){27===o.which&&setTimeout(()=>{t.value.isMouseDown=!1,t.value.hasScrolled=!1,t.value.blur&&t.value.blur()},0)}const tt=document.documentElement;var ot={mounted(e,t){t.value.isMouseDown=!1,t.value.hasScrolled=!1,t.value.onEscapeHandler=et.bind(null,e,t),t.value.onMouseDown=Ze.bind(null,e,t),t.value.onClickOutsideElement=Je.bind(null,e,t),t.value.onScroll=Xe.bind(null,e,t),tt.addEventListener("keyup",t.value.onEscapeHandler),tt.addEventListener("mousedown",t.value.onMouseDown),tt.addEventListener("mouseup",t.value.onClickOutsideElement),tt.addEventListener("scroll",t.value.onScroll)},unmounted(e,t){tt.removeEventListener("keyup",t.value.onEscapeHandler),tt.removeEventListener("mousedown",t.value.onMouseDown),tt.removeEventListener("mouseup",t.value.onClickOutsideElement),tt.removeEventListener("scroll",t.value.onScroll)}}; /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */function ot(e,t){var o,i;null===(o=t.value)||void 0===o||!o.focused||null!==(i=t.oldValue)&&void 0!==i&&i.focused||setTimeout(()=>{e.focus(),t.value.afterFocus&&t.value.afterFocus()},5)}var it={mounted(e,t){ot(e,t)},updated(e,t){ot(e,t)}}; + */function it(e,t){var o,i;null===(o=t.value)||void 0===o||!o.focused||null!==(i=t.oldValue)&&void 0!==i&&i.focused||setTimeout(()=>{e.focus(),t.value.afterFocus&&t.value.afterFocus()},5)}var nt={mounted(e,t){it(e,t)},updated(e,t){it(e,t)}}; /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */const{$:nt}=window,at=new WeakMap;function rt(){const e=nt(this).attr("title")||"";return window.vueSanitize(e.replace(/\n/g,"
"))}function st(e){if(!document.querySelector(".ui-tooltip"))return;let t;try{t=nt(e).tooltip("instance")}catch(o){return}t&&t.tooltips&&Object.keys(t.tooltips).forEach(e=>{var o;const i=null===(o=t)||void 0===o||null===(o=o.tooltips[e])||void 0===o||null===(o=o.element)||void 0===o?void 0:o[0];i&&!i.isConnected&&nt(i).trigger("mouseleave").trigger("focusout")})}function lt(e,t){var o,i,n,a,r,s;if(e.isConnected&&(nt(e).tooltip({track:!0,content:(null===(o=t.value)||void 0===o?void 0:o.content)||rt,show:"undefined"!==typeof(null===(i=t.value)||void 0===i?void 0:i.show)?null===(n=t.value)||void 0===n?void 0:n.show:{delay:(null===(a=t.value)||void 0===a?void 0:a.delay)||700,duration:(null===(r=t.value)||void 0===r?void 0:r.duration)||200},hide:!1,tooltipClass:null===(s=t.value)||void 0===s?void 0:s.tooltipClass}),!at.has(e))){const t=new MutationObserver(t=>{t.some(e=>e.removedNodes.length>0)&&st(e)});t.observe(e,{childList:!0,subtree:!0}),at.set(e,t)}}var ct={mounted(e,t){setTimeout(()=>lt(e,t))},updated(e,t){setTimeout(()=>lt(e,t))},beforeUnmount(e){const t=at.get(e);t&&(t.disconnect(),at.delete(e));try{window.$(e).tooltip("destroy")}catch(o){}}};const dt={ref:"root"};function ut(e,t,o,i,n,a){return Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("div",dt,[Object(D["renderSlot"])(e.$slots,"default")],512)),[[D["vShow"],e.modelValue]])}var pt=Object(D["defineComponent"])({props:{modelValue:{type:Boolean,required:!0},options:{type:Object,required:!1,default:()=>({})}},emits:["yes","no","closeEnd","close","validation","update:modelValue"],activated(){this.$emit("update:modelValue",!1)},watch:{modelValue(e,t){if(e){const e=this.$refs.root.firstElementChild;M.helper.modalConfirm(e,{yes:()=>{this.$emit("yes")},no:()=>{this.$emit("no")},validation:()=>{this.$emit("validation")}},Object.assign({onCloseEnd:()=>{this.$refs.root.appendChild(e),this.$emit("update:modelValue",!1),this.$emit("closeEnd")}},this.options))}else!1===e&&!0===t&&($(".modal.open").modal("close"),this.$emit("close"))}}});pt.render=ut;var mt=pt;const ht=["aria-label"],gt={key:0,class:"modal-footer matomo-modal-footer"};function bt(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createBlock"])(D["Teleport"],{to:"body"},[e.modelValue?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{key:0,class:"modal-overlay matomo-modal-overlay open",onClick:t[0]||(t[0]=(...t)=>e.close&&e.close(...t))})):Object(D["createCommentVNode"])("",!0),Object(D["withDirectives"])(Object(D["createElementVNode"])("div",{ref:"root",class:Object(D["normalizeClass"])(["modal matomo-modal",e.modalClasses]),role:"dialog","aria-modal":"true","aria-label":e.ariaLabel,tabindex:"-1"},[Object(D["createElementVNode"])("div",{class:Object(D["normalizeClass"])(["modal-content matomo-modal-content",e.contentClass])},[Object(D["renderSlot"])(e.$slots,"default")],2),e.$slots.footer?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",gt,[Object(D["renderSlot"])(e.$slots,"footer")])):Object(D["createCommentVNode"])("",!0)],10,ht),[[D["vShow"],e.modelValue]])])}var ft=Object(D["defineComponent"])({name:"MatomoModal",props:{modelValue:{type:Boolean,required:!0},classes:{type:[String,Array,Object],default:""},contentClass:{type:[String,Array,Object],default:""},ariaLabel:{type:String}},emits:["update:modelValue","opened","closed"],data(){return{previousBodyOverflow:"",previousFocus:null}},computed:{modalClasses(){return[{open:this.modelValue},this.classes]}},methods:{close(){this.modelValue&&this.$emit("update:modelValue",!1)},onKeydown(e){"Escape"===e.key&&this.close()},activate(){const e=this.$refs.root;this.previousBodyOverflow=document.body.style.overflow,this.previousFocus=document.activeElement,document.body.style.overflow="hidden",document.addEventListener("keydown",this.onKeydown),this.$nextTick(()=>e.focus()),this.$emit("opened",e)},deactivate(){document.body.style.overflow=this.previousBodyOverflow,this.previousBodyOverflow="",document.removeEventListener("keydown",this.onKeydown),this.previousFocus&&this.previousFocus.focus(),this.previousFocus=null,this.$emit("closed")}},watch:{modelValue(e,t){e&&!t?this.activate():!e&&t&&this.deactivate()}},mounted(){this.modelValue&&this.activate()},unmounted(){this.modelValue&&this.deactivate()}});ft.render=bt;var vt=ft; + */const{$:at}=window,rt=new WeakMap;function st(){const e=at(this).attr("title")||"";return window.vueSanitize(e.replace(/\n/g,"
"))}function lt(e){if(!document.querySelector(".ui-tooltip"))return;let t;try{t=at(e).tooltip("instance")}catch(o){return}t&&t.tooltips&&Object.keys(t.tooltips).forEach(e=>{var o;const i=null===(o=t)||void 0===o||null===(o=o.tooltips[e])||void 0===o||null===(o=o.element)||void 0===o?void 0:o[0];i&&!i.isConnected&&at(i).trigger("mouseleave").trigger("focusout")})}function ct(e,t){var o,i,n,a,r,s;if(e.isConnected&&(at(e).tooltip({track:!0,content:(null===(o=t.value)||void 0===o?void 0:o.content)||st,show:"undefined"!==typeof(null===(i=t.value)||void 0===i?void 0:i.show)?null===(n=t.value)||void 0===n?void 0:n.show:{delay:(null===(a=t.value)||void 0===a?void 0:a.delay)||700,duration:(null===(r=t.value)||void 0===r?void 0:r.duration)||200},hide:!1,tooltipClass:null===(s=t.value)||void 0===s?void 0:s.tooltipClass}),!rt.has(e))){const t=new MutationObserver(t=>{t.some(e=>e.removedNodes.length>0)&<(e)});t.observe(e,{childList:!0,subtree:!0}),rt.set(e,t)}}var dt={mounted(e,t){setTimeout(()=>ct(e,t))},updated(e,t){setTimeout(()=>ct(e,t))},beforeUnmount(e){const t=rt.get(e);t&&(t.disconnect(),rt.delete(e));try{window.$(e).tooltip("destroy")}catch(o){}}};const ut={ref:"root"};function mt(e,t,o,i,n,a){return Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("div",ut,[Object(D["renderSlot"])(e.$slots,"default")],512)),[[D["vShow"],e.modelValue]])}var pt=Object(D["defineComponent"])({props:{modelValue:{type:Boolean,required:!0},options:{type:Object,required:!1,default:()=>({})}},emits:["yes","no","closeEnd","close","validation","update:modelValue"],activated(){this.$emit("update:modelValue",!1)},watch:{modelValue(e,t){if(e){const e=this.$refs.root.firstElementChild;I.helper.modalConfirm(e,{yes:()=>{this.$emit("yes")},no:()=>{this.$emit("no")},validation:()=>{this.$emit("validation")}},Object.assign({onCloseEnd:()=>{this.$refs.root.appendChild(e),this.$emit("update:modelValue",!1),this.$emit("closeEnd")}},this.options))}else!1===e&&!0===t&&($(".modal.open").modal("close"),this.$emit("close"))}}});pt.render=mt;var ht=pt;const gt=["aria-label"],bt={key:0,class:"modal-footer matomo-modal-footer"};function ft(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createBlock"])(D["Teleport"],{to:"body"},[e.modelValue?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{key:0,class:"modal-overlay matomo-modal-overlay open",onClick:t[0]||(t[0]=(...t)=>e.close&&e.close(...t))})):Object(D["createCommentVNode"])("",!0),Object(D["withDirectives"])(Object(D["createElementVNode"])("div",{ref:"root",class:Object(D["normalizeClass"])(["modal matomo-modal",e.modalClasses]),role:"dialog","aria-modal":"true","aria-label":e.ariaLabel,tabindex:"-1"},[Object(D["createElementVNode"])("div",{class:Object(D["normalizeClass"])(["modal-content matomo-modal-content",e.contentClass])},[Object(D["renderSlot"])(e.$slots,"default")],2),e.$slots.footer?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",bt,[Object(D["renderSlot"])(e.$slots,"footer")])):Object(D["createCommentVNode"])("",!0)],10,gt),[[D["vShow"],e.modelValue]])])}var vt=Object(D["defineComponent"])({name:"MatomoModal",props:{modelValue:{type:Boolean,required:!0},classes:{type:[String,Array,Object],default:""},contentClass:{type:[String,Array,Object],default:""},ariaLabel:{type:String}},emits:["update:modelValue","opened","closed"],data(){return{previousBodyOverflow:"",previousFocus:null}},computed:{modalClasses(){return[{open:this.modelValue},this.classes]}},methods:{close(){this.modelValue&&this.$emit("update:modelValue",!1)},onKeydown(e){"Escape"===e.key&&this.close()},activate(){const e=this.$refs.root;this.previousBodyOverflow=document.body.style.overflow,this.previousFocus=document.activeElement,document.body.style.overflow="hidden",document.addEventListener("keydown",this.onKeydown),this.$nextTick(()=>e.focus()),this.$emit("opened",e)},deactivate(){document.body.style.overflow=this.previousBodyOverflow,this.previousBodyOverflow="",document.removeEventListener("keydown",this.onKeydown),this.previousFocus&&this.previousFocus.focus(),this.previousFocus=null,this.$emit("closed")}},watch:{modelValue(e,t){e&&!t?this.activate():!e&&t&&this.deactivate()}},mounted(){this.modelValue&&this.activate()},unmounted(){this.modelValue&&this.deactivate()}});vt.render=ft;var Ot=vt; /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */function Ot(e,t,o){var i;e.classList.add("expanded"),null!==(i=t.value)&&void 0!==i&&i.onExpand&&t.value.onExpand(o);const n=e.querySelector(".dropdown.positionInViewport");n&&M.helper.setMarginLeftToBeInViewport(n)}function yt(e,t,o){var i;e.classList.contains("expanded")&&(e.classList.remove("expanded"),null!==(i=t.value)&&void 0!==i&&i.onClosed&&t.value.onClosed(o))}function jt(e,t,o){e.classList.contains("expanded")?yt(e,t,o):Ot(e,t,o)}function wt(e,t,o){const i=t.value.isMouseDown&&t.value.hasScrolled;t.value.isMouseDown=!1,t.value.hasScrolled=!1,i||e.contains(o.target)||yt(e,t,o)}function St(e){e.value.hasScrolled=!0}function Ct(e){e.value.isMouseDown=!0,e.value.hasScrolled=!1}function kt(e,t,o){"Escape"===o.key&&(t.value.isMouseDown=!1,t.value.hasScrolled=!1,yt(e,t,o))}const Dt=document.documentElement;var Et={mounted(e,t){t.value.isMouseDown=!1,t.value.hasScrolled=!1,t.value.onClickOnExpander=jt.bind(null,e,t),t.value.onEscapeHandler=kt.bind(null,e,t),t.value.onMouseDown=Ct.bind(null,t),t.value.onClickOutsideElement=wt.bind(null,e,t),t.value.onScroll=St.bind(null,t),setTimeout(()=>{const e=De.getRef(t.value.expander,t);e&&e.addEventListener("click",t.value.onClickOnExpander)}),Dt.addEventListener("keyup",t.value.onEscapeHandler),Dt.addEventListener("mousedown",t.value.onMouseDown),Dt.addEventListener("mouseup",t.value.onClickOutsideElement),Dt.addEventListener("scroll",t.value.onScroll)},unmounted(e,t){const o=De.getRef(t.value.expander,t);o&&Dt.removeEventListener("click",t.value.onClickOnExpander),Dt.removeEventListener("keyup",t.value.onEscapeHandler),Dt.removeEventListener("mousedown",t.value.onMouseDown),Dt.removeEventListener("mouseup",t.value.onClickOutsideElement),Dt.removeEventListener("scroll",t.value.onScroll)}}; + */function yt(e,t,o){var i;e.classList.add("expanded"),null!==(i=t.value)&&void 0!==i&&i.onExpand&&t.value.onExpand(o);const n=e.querySelector(".dropdown.positionInViewport");n&&I.helper.setMarginLeftToBeInViewport(n)}function jt(e,t,o){var i;e.classList.contains("expanded")&&(e.classList.remove("expanded"),null!==(i=t.value)&&void 0!==i&&i.onClosed&&t.value.onClosed(o))}function wt(e,t,o){e.classList.contains("expanded")?jt(e,t,o):yt(e,t,o)}function St(e,t,o){const i=t.value.isMouseDown&&t.value.hasScrolled;t.value.isMouseDown=!1,t.value.hasScrolled=!1,i||e.contains(o.target)||jt(e,t,o)}function Ct(e){e.value.hasScrolled=!0}function kt(e){e.value.isMouseDown=!0,e.value.hasScrolled=!1}function Dt(e,t,o){"Escape"===o.key&&(t.value.isMouseDown=!1,t.value.hasScrolled=!1,jt(e,t,o))}const Et=document.documentElement;var Pt={mounted(e,t){t.value.isMouseDown=!1,t.value.hasScrolled=!1,t.value.onClickOnExpander=wt.bind(null,e,t),t.value.onEscapeHandler=Dt.bind(null,e,t),t.value.onMouseDown=kt.bind(null,t),t.value.onClickOutsideElement=St.bind(null,e,t),t.value.onScroll=Ct.bind(null,t),setTimeout(()=>{const e=De.getRef(t.value.expander,t);e&&e.addEventListener("click",t.value.onClickOnExpander)}),Et.addEventListener("keyup",t.value.onEscapeHandler),Et.addEventListener("mousedown",t.value.onMouseDown),Et.addEventListener("mouseup",t.value.onClickOutsideElement),Et.addEventListener("scroll",t.value.onScroll)},unmounted(e,t){const o=De.getRef(t.value.expander,t);o&&Et.removeEventListener("click",t.value.onClickOnExpander),Et.removeEventListener("keyup",t.value.onEscapeHandler),Et.removeEventListener("mousedown",t.value.onMouseDown),Et.removeEventListener("mouseup",t.value.onClickOutsideElement),Et.removeEventListener("scroll",t.value.onScroll)}}; /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */function Pt(e){e.classList.add("expanded");const t=e.querySelector(".dropdown.positionInViewport");t&&M.helper.setMarginLeftToBeInViewport(t)}function Tt(e){e.classList.remove("expanded")}function xt(e,t){e.contains(t.target)||e.classList.remove("expanded")}function Vt(e,t){27===t.which&&e.classList.remove("expanded")}const Bt=document.documentElement;var Nt={mounted(e,t){t.value.onMouseEnter=Pt.bind(null,e),t.value.onMouseLeave=Tt.bind(null,e),t.value.onClickOutsideElement=xt.bind(null,e),t.value.onEscapeHandler=Vt.bind(null,e),setTimeout(()=>{const e=De.getRef(t.value.expander,t);e&&e.addEventListener("mouseenter",t.value.onMouseEnter)}),e.addEventListener("mouseleave",t.value.onMouseLeave),Bt.addEventListener("keyup",t.value.onEscapeHandler),Bt.addEventListener("mouseup",t.value.onClickOutsideElement)},unmounted(e,t){const o=De.getRef(t.value.expander,t);o&&o.removeEventListener("mouseenter",t.value.onMouseEnter),e.removeEventListener("mouseleave",t.value.onMouseLeave),document.removeEventListener("keyup",t.value.onEscapeHandler),document.removeEventListener("mouseup",t.value.onClickOutsideElement)}}; + */function Tt(e){e.classList.add("expanded");const t=e.querySelector(".dropdown.positionInViewport");t&&I.helper.setMarginLeftToBeInViewport(t)}function xt(e){e.classList.remove("expanded")}function Vt(e,t){e.contains(t.target)||e.classList.remove("expanded")}function Bt(e,t){27===t.which&&e.classList.remove("expanded")}const Nt=document.documentElement;var Mt={mounted(e,t){t.value.onMouseEnter=Tt.bind(null,e),t.value.onMouseLeave=xt.bind(null,e),t.value.onClickOutsideElement=Vt.bind(null,e),t.value.onEscapeHandler=Bt.bind(null,e),setTimeout(()=>{const e=De.getRef(t.value.expander,t);e&&e.addEventListener("mouseenter",t.value.onMouseEnter)}),e.addEventListener("mouseleave",t.value.onMouseLeave),Nt.addEventListener("keyup",t.value.onEscapeHandler),Nt.addEventListener("mouseup",t.value.onClickOutsideElement)},unmounted(e,t){const o=De.getRef(t.value.expander,t);o&&o.removeEventListener("mouseenter",t.value.onMouseEnter),e.removeEventListener("mouseleave",t.value.onMouseLeave),document.removeEventListener("keyup",t.value.onEscapeHandler),document.removeEventListener("mouseup",t.value.onClickOutsideElement)}}; /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */const{$:It}=window;var Mt={mounted(e,t){const o=It(e),{sensitiveData:i}=t.value,n=t.value.showCharacters||6,r=t.value.clickElementSelector||o;let s="";function l(){o.html(i),It(r).css({cursor:""}),It(r).tooltip("destroy")}n>0&&(s+=i.slice(0,n)),s+=i.slice(n).replace(/./g,"*"),o.html(s),It(r).tooltip({content:a("CoreHome_ClickToSeeFullInformation"),items:"*",track:!0}),It(r).one("click",l),It(r).css({cursor:"pointer"})}}; + */const{$:It}=window;var Ft={mounted(e,t){const o=It(e),{sensitiveData:i}=t.value,n=t.value.showCharacters||6,r=t.value.clickElementSelector||o;let s="";function l(){o.html(i),It(r).css({cursor:""}),It(r).tooltip("destroy")}n>0&&(s+=i.slice(0,n)),s+=i.slice(n).replace(/./g,"*"),o.html(s),It(r).tooltip({content:a("CoreHome_ClickToSeeFullInformation"),items:"*",track:!0}),It(r).one("click",l),It(r).css({cursor:"pointer"})}}; /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */const{$:Ft}=window;var Rt={mounted(e){const t=Ft(e);!t.attr("data-target")&&t.attr("data-activates")&&t.attr("data-target",t.attr("data-activates"));const o=t.attr("data-target");o&&Ft("#"+o).length&&t.dropdown({inDuration:300,outDuration:225,constrainWidth:!1,belowOrigin:!0})}};const Lt=["data-item-id","draggable","aria-grabbed","onDragstart","onDragover"];var At=Object(D["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 o=e,i=.1,n=Object(D["ref"])([]),a=Object(D["ref"])(null),r=Object(D["ref"])(null),s=Object(D["ref"])(null),l=Object(D["ref"])(!1),c=Object(D["computed"])(()=>!o.disabled&&o.items.length>1);function d(e,t){if("function"===typeof o.itemKey)return o.itemKey(e,t);if(!e||"object"!==typeof e)return t;const i=e[o.itemKey];return"string"===typeof i||"number"===typeof i?i:t}const u=Object(D["computed"])(()=>o.items.map((e,t)=>({id:String(d(e,t)),item:e,sourceIndex:t}))),p=Object(D["computed"])(()=>u.value.map(e=>e.id).join("\0"));function m(){n.value=u.value.slice()}function h(){a.value=null,r.value=null,s.value=null}function g(e=!1){h(),l.value=!1,e&&m()}function b(e,t){if(!o.handle)return!0;if(!(e instanceof Element))return!1;const i=e.closest(o.handle);return!!i&&t.contains(i)}function f(e){return n.value.findIndex(t=>t.id===e)}function v(e){return n.value["number"===typeof e?e:Number(e)]}function O(e,t){const n=t.getBoundingClientRect(),s=a.value?f(a.value):-1,l=r.value?f(r.value):-1,c=-1!==s&&-1!==l&&s{a.value===t&&(s.value=t)},0)):e.preventDefault()}function w(e,t){const o=v(t);o?j(e,o.id):e.preventDefault()}function S(e,t){if(!a.value||!c.value)return;e.preventDefault();const o=e.currentTarget;o&&(r.value=t,y(t,O(e,o)),e.dataTransfer&&(e.dataTransfer.dropEffect="move"))}function C(e,t){const o=v(t);o&&S(e,o.id)}function k(e){if(!a.value)return;e.preventDefault();const o=n.value.map(e=>e.id);o&&o.join("\0")!==p.value&&(l.value=!0,t("reorder",o)),h()}function E(){l.value?l.value=!1:g(!0)}return Object(D["watch"])([u,()=>o.disabled],()=>g(!0),{immediate:!0}),(t,o)=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("ul",{class:Object(D["normalizeClass"])(["draggableList",{isDragging:null!==a.value,isDisabled:e.disabled}])},[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(n.value,(e,o)=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{key:e.id,class:Object(D["normalizeClass"])(["draggableListItem",{isDragged:e.id===s.value}]),"data-item-id":e.id,draggable:Object(D["unref"])(c),"aria-grabbed":e.id===a.value,onDragstart:e=>w(e,o),onDragover:e=>C(e,o),onDrop:k,onDragend:E},[Object(D["renderSlot"])(t.$slots,"default",{item:e.item,index:e.sourceIndex})],42,Lt))),128))],2))}}),_t=At; + */const{$:Rt}=window;var Lt={mounted(e){const t=Rt(e);!t.attr("data-target")&&t.attr("data-activates")&&t.attr("data-target",t.attr("data-activates"));const o=t.attr("data-target");o&&Rt("#"+o).length&&t.dropdown({inDuration:300,outDuration:225,constrainWidth:!1,belowOrigin:!0})}};const At=["data-item-id","draggable","aria-grabbed","onDragstart","onDragover"];var _t=Object(D["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 o=e,i=.1,n=Object(D["ref"])([]),a=Object(D["ref"])(null),r=Object(D["ref"])(null),s=Object(D["ref"])(null),l=Object(D["ref"])(!1),c=Object(D["computed"])(()=>!o.disabled&&o.items.length>1);function d(e,t){if("function"===typeof o.itemKey)return o.itemKey(e,t);if(!e||"object"!==typeof e)return t;const i=e[o.itemKey];return"string"===typeof i||"number"===typeof i?i:t}const u=Object(D["computed"])(()=>o.items.map((e,t)=>({id:String(d(e,t)),item:e,sourceIndex:t}))),m=Object(D["computed"])(()=>u.value.map(e=>e.id).join("\0"));function p(){n.value=u.value.slice()}function h(){a.value=null,r.value=null,s.value=null}function g(e=!1){h(),l.value=!1,e&&p()}function b(e,t){if(!o.handle)return!0;if(!(e instanceof Element))return!1;const i=e.closest(o.handle);return!!i&&t.contains(i)}function f(e){return n.value.findIndex(t=>t.id===e)}function v(e){return n.value["number"===typeof e?e:Number(e)]}function O(e,t){const n=t.getBoundingClientRect(),s=a.value?f(a.value):-1,l=r.value?f(r.value):-1,c=-1!==s&&-1!==l&&s{a.value===t&&(s.value=t)},0)):e.preventDefault()}function w(e,t){const o=v(t);o?j(e,o.id):e.preventDefault()}function S(e,t){if(!a.value||!c.value)return;e.preventDefault();const o=e.currentTarget;o&&(r.value=t,y(t,O(e,o)),e.dataTransfer&&(e.dataTransfer.dropEffect="move"))}function C(e,t){const o=v(t);o&&S(e,o.id)}function k(e){if(!a.value)return;e.preventDefault();const o=n.value.map(e=>e.id);o&&o.join("\0")!==m.value&&(l.value=!0,t("reorder",o)),h()}function E(){l.value?l.value=!1:g(!0)}return Object(D["watch"])([u,()=>o.disabled],()=>g(!0),{immediate:!0}),(t,o)=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("ul",{class:Object(D["normalizeClass"])(["draggableList",{isDragging:null!==a.value,isDisabled:e.disabled}])},[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(n.value,(e,o)=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{key:e.id,class:Object(D["normalizeClass"])(["draggableListItem",{isDragged:e.id===s.value}]),"data-item-id":e.id,draggable:Object(D["unref"])(c),"aria-grabbed":e.id===a.value,onDragstart:e=>w(e,o),onDragover:e=>C(e,o),onDrop:k,onDragend:E},[Object(D["renderSlot"])(t.$slots,"default",{item:e.item,index:e.sourceIndex})],42,At))),128))],2))}}),Ht=_t; /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later */ -const{$:Ht}=window;function $t(e,t){e.value.focusedElement!==t.target&&(e.value.focusedElement=t.target,Ht(t.target).select())}function Ut(e){const t=document.createRange();t.selectNode(e.target);const o=window.getSelection();o&&o.rangeCount>0&&o.removeAllRanges(),o&&o.addRange(t)}function qt(e){delete e.value.focusedElement}var Wt={mounted(e,t){const o=e.tagName.toLowerCase();t.value.elementSupportsSelect="textarea"===o,t.value.elementSupportsSelect?(t.value.onFocusHandler=$t.bind(null,t),t.value.onBlurHandler=qt.bind(null,t),e.addEventListener("focus",t.value.onFocusHandler),e.addEventListener("blur",t.value.onBlurHandler)):(t.value.onClickHandler=Ut,e.addEventListener("click",t.value.onClickHandler))},unmounted(e,t){t.value.elementSupportsSelect?(e.removeEventListener("focus",t.value.onFocusHandler),e.removeEventListener("blur",t.value.onBlurHandler)):e.removeEventListener("click",t.value.onClickHandler)}}; +const{$:$t}=window;function Ut(e,t){e.value.focusedElement!==t.target&&(e.value.focusedElement=t.target,$t(t.target).select())}function qt(e){const t=document.createRange();t.selectNode(e.target);const o=window.getSelection();o&&o.rangeCount>0&&o.removeAllRanges(),o&&o.addRange(t)}function Wt(e){delete e.value.focusedElement}var zt={mounted(e,t){const o=e.tagName.toLowerCase();t.value.elementSupportsSelect="textarea"===o,t.value.elementSupportsSelect?(t.value.onFocusHandler=Ut.bind(null,t),t.value.onBlurHandler=Wt.bind(null,t),e.addEventListener("focus",t.value.onFocusHandler),e.addEventListener("blur",t.value.onBlurHandler)):(t.value.onClickHandler=qt,e.addEventListener("click",t.value.onClickHandler))},unmounted(e,t){t.value.elementSupportsSelect?(e.removeEventListener("focus",t.value.onFocusHandler),e.removeEventListener("blur",t.value.onBlurHandler)):e.removeEventListener("click",t.value.onClickHandler)}}; /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */function zt(e){if(e){const t=document.createElement("textarea");t.value=e.innerText,t.setAttribute("readonly",""),t.style.position="absolute",t.style.left="-9999px",document.body.appendChild(t),t.select(),t.focus(),document.execCommand("copy"),document.body.removeChild(t);const o=e.parentElement;if(o){const e=o.getElementsByTagName("i")[0];e&&(e.classList.remove("copyToClipboardIcon"),e.classList.add("copyToClipboardIconCheck"));const t=o.getElementsByClassName("copyToClipboardCopiedDiv")[0];t&&(t.style.display="inline-block",setTimeout(()=>{t.style.display="none"},2500))}}}function Gt(e,t){if(t.value.transitionOpen){const o=e.parentElement;if(o){const e=o.getElementsByTagName("i")[0];e&&(e.classList.remove("copyToClipboardIconCheck"),e.classList.add("copyToClipboardIcon"))}t.value.transitionOpen=!1}else t.value.transitionOpen=!0}var Kt={mounted(e,t){const o=e.tagName.toLowerCase();if("pre"===o){const o=document.createElement("button");o.setAttribute("type","button"),o.className="copyToClipboardButton";const i=document.createElement("div");i.className="copyToClipboardPositionDiv";const n=document.createElement("i");n.className="copyToClipboardIcon",o.appendChild(n);const r=document.createElement("span");r.className="copyToClipboardSpan",r.innerHTML=a("General_Copy"),o.appendChild(r),i.appendChild(o);const s=document.createElement("div");s.className="copyToClipboardCopiedDiv",s.innerHTML=a("General_CopiedToClipboard"),i.appendChild(s);const l=e.parentElement;l&&(l.classList.add("copyToClipboardWrapper"),l.appendChild(i)),t.value.onClickHandler=zt.bind(null,e),o.addEventListener("click",t.value.onClickHandler),t.value.onTransitionEndHandler=Gt.bind(null,e,t),o.addEventListener("transitionend",t.value.onTransitionEndHandler)}},unmounted(e,t){e.removeEventListener("click",t.value.onClickHandler),e.removeEventListener("transitionend",t.value.onTransitionEndHandler)}}; + */function Gt(e){if(e){const t=document.createElement("textarea");t.value=e.innerText,t.setAttribute("readonly",""),t.style.position="absolute",t.style.left="-9999px",document.body.appendChild(t),t.select(),t.focus(),document.execCommand("copy"),document.body.removeChild(t);const o=e.parentElement;if(o){const e=o.getElementsByTagName("i")[0];e&&(e.classList.remove("copyToClipboardIcon"),e.classList.add("copyToClipboardIconCheck"));const t=o.getElementsByClassName("copyToClipboardCopiedDiv")[0];t&&(t.style.display="inline-block",setTimeout(()=>{t.style.display="none"},2500))}}}function Kt(e,t){if(t.value.transitionOpen){const o=e.parentElement;if(o){const e=o.getElementsByTagName("i")[0];e&&(e.classList.remove("copyToClipboardIconCheck"),e.classList.add("copyToClipboardIcon"))}t.value.transitionOpen=!1}else t.value.transitionOpen=!0}var Yt={mounted(e,t){const o=e.tagName.toLowerCase();if("pre"===o){const o=document.createElement("button");o.setAttribute("type","button"),o.className="copyToClipboardButton";const i=document.createElement("div");i.className="copyToClipboardPositionDiv";const n=document.createElement("i");n.className="copyToClipboardIcon",o.appendChild(n);const r=document.createElement("span");r.className="copyToClipboardSpan",r.innerHTML=a("General_Copy"),o.appendChild(r),i.appendChild(o);const s=document.createElement("div");s.className="copyToClipboardCopiedDiv",s.innerHTML=a("General_CopiedToClipboard"),i.appendChild(s);const l=e.parentElement;l&&(l.classList.add("copyToClipboardWrapper"),l.appendChild(i)),t.value.onClickHandler=Gt.bind(null,e),o.addEventListener("click",t.value.onClickHandler),t.value.onTransitionEndHandler=Kt.bind(null,e,t),o.addEventListener("transitionend",t.value.onTransitionEndHandler)}},unmounted(e,t){e.removeEventListener("click",t.value.onClickHandler),e.removeEventListener("transitionend",t.value.onTransitionEndHandler)}}; /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */function Yt(){const e=document.getElementById("mobile-left-menu");if(e)try{window.$(e).sidenav("open")}catch(t){}}function Qt(){const e=document.getElementById("secondNavBar");if(null===e||void 0===e||!e.classList.contains("mobileLeftMenuOpen"))return;const t=document.getElementById("mobile-left-menu");if(t)try{window.$(t).sidenav("close")}catch(o){}}var Jt={mounted(e,t){if(!t.value.activator)return;const o=document.getElementById("secondNavBar"),i=e=>{o&&o.classList.toggle("mobileLeftMenuOpen",e)};setTimeout(()=>{if(!t.value.initialized){t.value.initialized=!0;const e=De.getRef(t.value.activator,t);if(e){window.$(e).show();const t=e.getAttribute("data-target");window.$("#"+t).sidenav({closeOnClick:!0,onOpenStart:()=>{i(!0)},onCloseStart:()=>{i(!1)}})}}e.classList.contains("collapsible")&&window.$(e).collapsible()})}};const Xt={key:0,class:"title",tabindex:"6"},Zt=["href","title"],eo={class:"iconsBar"},to=["href","title"],oo=Object(D["createElementVNode"])("span",{class:"icon-help"},null,-1),io=[oo],no=["title"],ao=Object(D["createElementVNode"])("span",{class:"icon-info"},null,-1),ro=[ao],so={key:2,class:"ratingIcons"},lo={class:"inlineHelp"},co=["innerHTML"],uo=["innerHTML"],po=["href"];function mo(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{class:"enrichedHeadline",onMouseenter:t[1]||(t[1]=t=>e.showIcons=!0),onMouseleave:t[2]||(t[2]=t=>e.showIcons=!1),ref:"root"},[e.editUrl?Object(D["createCommentVNode"])("",!0):(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Xt,[Object(D["renderSlot"])(e.$slots,"default")])),e.editUrl?(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:1,class:"title",href:e.editUrl,title:e.translate("CoreHome_ClickToEditX",e.htmlEntities(e.actualFeatureName))},[Object(D["renderSlot"])(e.$slots,"default")],8,Zt)):Object(D["createCommentVNode"])("",!0),Object(D["withDirectives"])(Object(D["createElementVNode"])("span",eo,[e.helpUrl&&!e.actualInlineHelp?(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:0,rel:"noreferrer noopener",target:"_blank",class:"helpIcon",href:e.helpUrl,title:e.translate("CoreHome_ExternalHelp")},io,8,to)):Object(D["createCommentVNode"])("",!0),e.actualInlineHelp?(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:1,onClick:t[0]||(t[0]=t=>e.showInlineHelp=!e.showInlineHelp),class:Object(D["normalizeClass"])(["helpIcon",{active:e.showInlineHelp}]),title:e.translate(e.reportGenerated?"General_HelpReport":"General_Help")},ro,10,no)):Object(D["createCommentVNode"])("",!0),e.showRateFeature?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",so,[(Object(D["openBlock"])(),Object(D["createBlock"])(Object(D["resolveDynamicComponent"])(e.rateFeature),{title:e.actualFeatureName},null,8,["title"]))])):Object(D["createCommentVNode"])("",!0)],512),[[D["vShow"],e.showIcons||e.showInlineHelp]]),Object(D["withDirectives"])(Object(D["createElementVNode"])("div",lo,[Object(D["createElementVNode"])("div",{innerHTML:e.$sanitize(e.actualInlineHelp)},null,8,co),""!=e.reportGenerated?(Object(D["openBlock"])(),Object(D["createElementBlock"])("span",{key:0,class:"helpDate",innerHTML:e.$sanitize(e.reportGenerated)},null,8,uo)):Object(D["createCommentVNode"])("",!0),e.helpUrl?(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:1,rel:"noreferrer noopener",target:"_blank",class:"readMore",href:e.helpUrl},Object(D["toDisplayString"])(e.translate("General_MoreDetails")),9,po)):Object(D["createCommentVNode"])("",!0)],512),[[D["vShow"],e.showInlineHelp]])],544)}var ho=Object(D["defineComponent"])({props:{helpUrl:{type:String,default:""},editUrl:{type:String,default:""},reportGenerated:String,featureName:String,inlineHelp:String},data(){return{showIcons:!1,showInlineHelp:!1,actualFeatureName:this.featureName,actualInlineHelp:this.inlineHelp}},watch:{inlineHelp(e){this.actualInlineHelp=e},featureName(e){this.actualFeatureName=e}},mounted(){const e=this.$refs.root;if(!this.actualInlineHelp){var t;let i=e.querySelector(".title .inlineHelp");if(!i&&null!==(t=e.parentElement)&&void 0!==t&&t.nextElementSibling&&(i=e.parentElement.nextElementSibling.querySelector(".reportDocumentation")),i){var o;const e=null===(o=i.getAttribute("data-content"))||void 0===o?void 0:o.trim();e&&e.length&&(this.actualInlineHelp=`

${e}

`,setTimeout(()=>i.remove(),0))}}var i;this.actualFeatureName||(this.actualFeatureName=null===(i=e.querySelector(".title"))||void 0===i?void 0:i.textContent);if(M.period&&M.currentDateString){const t=c.parse(M.period,M.currentDateString);this.reportGenerated&&t.containsToday()&&window.$(e.querySelector(".report-generated")).tooltip({track:!0,content:this.reportGenerated,items:"div",show:!1,hide:!1})}},methods:{htmlEntities(e){return M.helper.htmlEntities(e)}},computed:{showRateFeature(){return"Feedback_SendFeedback"!==r("Feedback_SendFeedback")},rateFeature(){return this.showRateFeature?Ce("Feedback","RateFeature"):""}}});ho.render=mo;var go=ho;const bo={class:"card-content"},fo={key:0,class:"card-title"},vo={key:1,class:"card-title"},Oo={ref:"content"},yo={key:0,class:"card-image hide-on-med-and-down"},jo=["src","alt"];function wo(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("EnrichedHeadline");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{class:Object(D["normalizeClass"])({card:!0,"card-with-image":!!this.imageUrl}),ref:"root"},[Object(D["createElementVNode"])("div",bo,[!e.contentTitle||e.actualFeature||e.helpUrl||e.actualHelpText||e.editUrl?Object(D["createCommentVNode"])("",!0):(Object(D["openBlock"])(),Object(D["createElementBlock"])("h2",fo,Object(D["toDisplayString"])(e.decode(e.contentTitle)),1)),e.contentTitle&&(e.actualFeature||e.helpUrl||e.actualHelpText||e.editUrl)?(Object(D["openBlock"])(),Object(D["createElementBlock"])("h2",vo,[Object(D["createVNode"])(r,{"feature-name":e.actualFeature,"help-url":e.helpUrl,"edit-url":e.editUrl,"inline-help":e.actualHelpText},{default:Object(D["withCtx"])(()=>[Object(D["createTextVNode"])(Object(D["toDisplayString"])(e.decode(e.contentTitle)),1)]),_:1},8,["feature-name","help-url","edit-url","inline-help"])])):Object(D["createCommentVNode"])("",!0),Object(D["createElementVNode"])("div",Oo,[Object(D["renderSlot"])(e.$slots,"default")],512)]),e.imageUrl?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",yo,[Object(D["createElementVNode"])("img",{src:e.imageUrl,alt:e.actualImageAltText},null,8,jo)])):Object(D["createCommentVNode"])("",!0)],2)}let So=null;const{$:Co}=window;var ko=Object(D["defineComponent"])({props:{contentTitle:String,feature:String,helpUrl:String,editUrl:String,helpText:String,anchor:String,imageUrl:String,imageAltText:String},components:{EnrichedHeadline:go},data(){return{actualFeature:this.feature,actualHelpText:this.helpText,actualImageAltText:this.imageAltText?this.imageAltText:this.contentTitle}},watch:{feature(e){this.actualFeature=e},helpText(e){this.actualHelpText=e}},mounted(){const e=this.$refs.root,t=this.$refs.content;if(this.anchor&&e&&e.parentElement){const t=document.createElement("a");t.id=this.anchor,Co(e.parentElement).prepend(t)}setTimeout(()=>{const e=t.querySelector(".contentHelp");e&&(this.actualHelpText=e.innerHTML,e.remove())},0),this.actualFeature&&"true"===this.actualFeature&&(this.actualFeature=this.contentTitle),null===So&&(So=document.querySelector("#content.admin"));let o=null;if(So&&(o=So.offsetTop),o||0===o){const t=e.closest(".widgetLoader"),i=t?t.offsetTop:e.offsetTop;i-o<17&&(e.style.marginTop="0")}},methods:{decode(e){return M.helper.htmlDecode(e)}}});ko.render=wo;var Do=ko;const Eo={key:0,ref:"root",class:"matomo-comparisons"},Po={class:"comparison-type"},To=["title"],xo=["href"],Vo=["title"],Bo={class:"comparison-period-label"},No=["onClick"],Io=["title"],Mo={class:"loadingPiwik",style:{display:"none"}};function Fo(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("MatomoLoader"),s=Object(D["resolveDirective"])("tooltips");return e.isComparing?Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Eo,[Object(D["createElementVNode"])("h3",null,Object(D["toDisplayString"])(e.translate("General_Comparisons")),1),(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.segmentComparisons,(t,o)=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{class:"comparison card",key:t.index},[Object(D["createElementVNode"])("div",Po,Object(D["toDisplayString"])(e.translate("General_Segment")),1),Object(D["createElementVNode"])("div",{class:"title",title:e.getTitleTooltip(t)},[Object(D["createElementVNode"])("a",{target:"_blank",href:e.getUrlToSegment(t.params.segment)},Object(D["toDisplayString"])(t.title),9,xo)],8,To),(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.periodComparisons,o=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{class:"comparison-period",key:o.index,title:e.getComparisonTooltip(t,o)},[Object(D["createElementVNode"])("span",{class:"comparison-dot",style:Object(D["normalizeStyle"])({"background-color":e.getSeriesColor(t,o)})},null,4),Object(D["createElementVNode"])("span",Bo,Object(D["toDisplayString"])(o.title)+" ("+Object(D["toDisplayString"])(e.getComparisonPeriodType(o))+") ",1)],8,Vo))),128)),e.segmentComparisons.length>1?(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:0,class:"remove-button",onClick:t=>e.removeSegmentComparison(o)},[Object(D["createElementVNode"])("span",{class:"icon icon-close",title:e.translate("General_ClickToRemoveComp")},null,8,Io)],8,No)):Object(D["createCommentVNode"])("",!0)]))),128)),Object(D["createElementVNode"])("div",Mo,[Object(D["createVNode"])(r),Object(D["createTextVNode"])(" "+Object(D["toDisplayString"])(e.translate("General_LoadingData")),1)])])),[[s,{duration:200,delay:200,content:e.transformTooltipContent}]]):Object(D["createCommentVNode"])("",!0)}function Ro(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} + */function Qt(){const e=document.getElementById("mobile-left-menu");if(e)try{window.$(e).sidenav("open")}catch(t){}}function Jt(){const e=document.getElementById("secondNavBar");if(null===e||void 0===e||!e.classList.contains("mobileLeftMenuOpen"))return;const t=document.getElementById("mobile-left-menu");if(t)try{window.$(t).sidenav("close")}catch(o){}}var Xt={mounted(e,t){if(!t.value.activator)return;const o=document.getElementById("secondNavBar"),i=e=>{o&&o.classList.toggle("mobileLeftMenuOpen",e)};setTimeout(()=>{if(!t.value.initialized){t.value.initialized=!0;const e=De.getRef(t.value.activator,t);if(e){window.$(e).show();const t=e.getAttribute("data-target");window.$("#"+t).sidenav({closeOnClick:!0,onOpenStart:()=>{i(!0)},onCloseStart:()=>{i(!1)}})}}e.classList.contains("collapsible")&&window.$(e).collapsible()})}};const Zt={key:0,class:"title",tabindex:"6"},eo=["href","title"],to={class:"iconsBar"},oo=["href","title"],io=Object(D["createElementVNode"])("span",{class:"icon-help"},null,-1),no=[io],ao=["title"],ro=Object(D["createElementVNode"])("span",{class:"icon-info"},null,-1),so=[ro],lo={key:2,class:"ratingIcons"},co={class:"inlineHelp"},uo=["innerHTML"],mo=["innerHTML"],po=["href"];function ho(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{class:"enrichedHeadline",onMouseenter:t[1]||(t[1]=t=>e.showIcons=!0),onMouseleave:t[2]||(t[2]=t=>e.showIcons=!1),ref:"root"},[e.editUrl?Object(D["createCommentVNode"])("",!0):(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Zt,[Object(D["renderSlot"])(e.$slots,"default")])),e.editUrl?(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:1,class:"title",href:e.editUrl,title:e.translate("CoreHome_ClickToEditX",e.htmlEntities(e.actualFeatureName))},[Object(D["renderSlot"])(e.$slots,"default")],8,eo)):Object(D["createCommentVNode"])("",!0),Object(D["withDirectives"])(Object(D["createElementVNode"])("span",to,[e.helpUrl&&!e.actualInlineHelp?(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:0,rel:"noreferrer noopener",target:"_blank",class:"helpIcon",href:e.helpUrl,title:e.translate("CoreHome_ExternalHelp")},no,8,oo)):Object(D["createCommentVNode"])("",!0),e.actualInlineHelp?(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:1,onClick:t[0]||(t[0]=t=>e.showInlineHelp=!e.showInlineHelp),class:Object(D["normalizeClass"])(["helpIcon",{active:e.showInlineHelp}]),title:e.translate(e.reportGenerated?"General_HelpReport":"General_Help")},so,10,ao)):Object(D["createCommentVNode"])("",!0),e.showRateFeature?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",lo,[(Object(D["openBlock"])(),Object(D["createBlock"])(Object(D["resolveDynamicComponent"])(e.rateFeature),{title:e.actualFeatureName},null,8,["title"]))])):Object(D["createCommentVNode"])("",!0)],512),[[D["vShow"],e.showIcons||e.showInlineHelp]]),Object(D["withDirectives"])(Object(D["createElementVNode"])("div",co,[Object(D["createElementVNode"])("div",{innerHTML:e.$sanitize(e.actualInlineHelp)},null,8,uo),""!=e.reportGenerated?(Object(D["openBlock"])(),Object(D["createElementBlock"])("span",{key:0,class:"helpDate",innerHTML:e.$sanitize(e.reportGenerated)},null,8,mo)):Object(D["createCommentVNode"])("",!0),e.helpUrl?(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:1,rel:"noreferrer noopener",target:"_blank",class:"readMore",href:e.helpUrl},Object(D["toDisplayString"])(e.translate("General_MoreDetails")),9,po)):Object(D["createCommentVNode"])("",!0)],512),[[D["vShow"],e.showInlineHelp]])],544)}var go=Object(D["defineComponent"])({props:{helpUrl:{type:String,default:""},editUrl:{type:String,default:""},reportGenerated:String,featureName:String,inlineHelp:String},data(){return{showIcons:!1,showInlineHelp:!1,actualFeatureName:this.featureName,actualInlineHelp:this.inlineHelp}},watch:{inlineHelp(e){this.actualInlineHelp=e},featureName(e){this.actualFeatureName=e}},mounted(){const e=this.$refs.root;if(!this.actualInlineHelp){var t;let i=e.querySelector(".title .inlineHelp");if(!i&&null!==(t=e.parentElement)&&void 0!==t&&t.nextElementSibling&&(i=e.parentElement.nextElementSibling.querySelector(".reportDocumentation")),i){var o;const e=null===(o=i.getAttribute("data-content"))||void 0===o?void 0:o.trim();e&&e.length&&(this.actualInlineHelp=`

${e}

`,setTimeout(()=>i.remove(),0))}}var i;this.actualFeatureName||(this.actualFeatureName=null===(i=e.querySelector(".title"))||void 0===i?void 0:i.textContent);if(I.period&&I.currentDateString){const t=c.parse(I.period,I.currentDateString);this.reportGenerated&&t.containsToday()&&window.$(e.querySelector(".report-generated")).tooltip({track:!0,content:this.reportGenerated,items:"div",show:!1,hide:!1})}},methods:{htmlEntities(e){return I.helper.htmlEntities(e)}},computed:{showRateFeature(){return"Feedback_SendFeedback"!==r("Feedback_SendFeedback")},rateFeature(){return this.showRateFeature?Ce("Feedback","RateFeature"):""}}});go.render=ho;var bo=go;const fo={class:"card-content"},vo={key:0,class:"card-title"},Oo={key:1,class:"card-title"},yo={ref:"content"},jo={key:0,class:"card-image hide-on-med-and-down"},wo=["src","alt"];function So(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("EnrichedHeadline");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{class:Object(D["normalizeClass"])({card:!0,"card-with-image":!!this.imageUrl}),ref:"root"},[Object(D["createElementVNode"])("div",fo,[!e.contentTitle||e.actualFeature||e.helpUrl||e.actualHelpText||e.editUrl?Object(D["createCommentVNode"])("",!0):(Object(D["openBlock"])(),Object(D["createElementBlock"])("h2",vo,Object(D["toDisplayString"])(e.decode(e.contentTitle)),1)),e.contentTitle&&(e.actualFeature||e.helpUrl||e.actualHelpText||e.editUrl)?(Object(D["openBlock"])(),Object(D["createElementBlock"])("h2",Oo,[Object(D["createVNode"])(r,{"feature-name":e.actualFeature,"help-url":e.helpUrl,"edit-url":e.editUrl,"inline-help":e.actualHelpText},{default:Object(D["withCtx"])(()=>[Object(D["createTextVNode"])(Object(D["toDisplayString"])(e.decode(e.contentTitle)),1)]),_:1},8,["feature-name","help-url","edit-url","inline-help"])])):Object(D["createCommentVNode"])("",!0),Object(D["createElementVNode"])("div",yo,[Object(D["renderSlot"])(e.$slots,"default")],512)]),e.imageUrl?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",jo,[Object(D["createElementVNode"])("img",{src:e.imageUrl,alt:e.actualImageAltText},null,8,wo)])):Object(D["createCommentVNode"])("",!0)],2)}let Co=null;const{$:ko}=window;var Do=Object(D["defineComponent"])({props:{contentTitle:String,feature:String,helpUrl:String,editUrl:String,helpText:String,anchor:String,imageUrl:String,imageAltText:String},components:{EnrichedHeadline:bo},data(){return{actualFeature:this.feature,actualHelpText:this.helpText,actualImageAltText:this.imageAltText?this.imageAltText:this.contentTitle}},watch:{feature(e){this.actualFeature=e},helpText(e){this.actualHelpText=e}},mounted(){const e=this.$refs.root,t=this.$refs.content;if(this.anchor&&e&&e.parentElement){const t=document.createElement("a");t.id=this.anchor,ko(e.parentElement).prepend(t)}setTimeout(()=>{const e=t.querySelector(".contentHelp");e&&(this.actualHelpText=e.innerHTML,e.remove())},0),this.actualFeature&&"true"===this.actualFeature&&(this.actualFeature=this.contentTitle),null===Co&&(Co=document.querySelector("#content.admin"));let o=null;if(Co&&(o=Co.offsetTop),o||0===o){const t=e.closest(".widgetLoader"),i=t?t.offsetTop:e.offsetTop;i-o<17&&(e.style.marginTop="0")}},methods:{decode(e){return I.helper.htmlDecode(e)}}});Do.render=So;var Eo=Do;const Po={key:0,ref:"root",class:"matomo-comparisons"},To={class:"comparison-type"},xo=["title"],Vo=["href"],Bo=["title"],No={class:"comparison-period-label"},Mo=["onClick"],Io=["title"],Fo={class:"loadingPiwik",style:{display:"none"}};function Ro(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("MatomoLoader"),s=Object(D["resolveDirective"])("tooltips");return e.isComparing?Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Po,[Object(D["createElementVNode"])("h3",null,Object(D["toDisplayString"])(e.translate("General_Comparisons")),1),(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.segmentComparisons,(t,o)=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{class:"comparison card",key:t.index},[Object(D["createElementVNode"])("div",To,Object(D["toDisplayString"])(e.translate("General_Segment")),1),Object(D["createElementVNode"])("div",{class:"title",title:e.getTitleTooltip(t)},[Object(D["createElementVNode"])("a",{target:"_blank",href:e.getUrlToSegment(t.params.segment)},Object(D["toDisplayString"])(t.title),9,Vo)],8,xo),(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.periodComparisons,o=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{class:"comparison-period",key:o.index,title:e.getComparisonTooltip(t,o)},[Object(D["createElementVNode"])("span",{class:"comparison-dot",style:Object(D["normalizeStyle"])({"background-color":e.getSeriesColor(t,o)})},null,4),Object(D["createElementVNode"])("span",No,Object(D["toDisplayString"])(o.title)+" ("+Object(D["toDisplayString"])(e.getComparisonPeriodType(o))+") ",1)],8,Bo))),128)),e.segmentComparisons.length>1?(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:0,class:"remove-button",onClick:t=>e.removeSegmentComparison(o)},[Object(D["createElementVNode"])("span",{class:"icon icon-close",title:e.translate("General_ClickToRemoveComp")},null,8,Io)],8,Mo)):Object(D["createCommentVNode"])("",!0)]))),128)),Object(D["createElementVNode"])("div",Fo,[Object(D["createVNode"])(r),Object(D["createTextVNode"])(" "+Object(D["toDisplayString"])(e.translate("General_LoadingData")),1)])])),[[s,{duration:200,delay:200,content:e.transformTooltipContent}]]):Object(D["createCommentVNode"])("",!0)}function Lo(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */class Lo{get state(){return Object(D["readonly"])(this.segmentState)}constructor(){Ro(this,"segmentState",Object(D["reactive"])({availableSegments:[]})),M.on("piwikSegmentationInited",()=>this.setSegmentState())}setSegmentState(){try{const e=$(".segmentEditorPanel").data("uiControlObject");this.segmentState.availableSegments=e.impl.availableSegments||[]}catch(e){}}}var Ao=new Lo;function _o(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} + */class Ao{get state(){return Object(D["readonly"])(this.segmentState)}constructor(){Lo(this,"segmentState",Object(D["reactive"])({availableSegments:[]})),I.on("piwikSegmentationInited",()=>this.setSegmentState())}setSegmentState(){try{const e=$(".segmentEditorPanel").data("uiControlObject");this.segmentState.availableSegments=e.impl.availableSegments||[]}catch(e){}}}var _o=new Ao;function Ho(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */const Ho=8,$o=3;function Uo(e){return e?Array.isArray(e)?e:[e]:[]}function qo(e){return Array.isArray(e)?e.map(qo):e&&"object"===typeof e?Object.fromEntries(Object.entries(e).sort(([e],[t])=>e.localeCompare(t)).map(([e,t])=>[e,qo(t)])):e}class Wo{constructor(){_o(this,"privateState",Object(D["reactive"])({comparisonsDisabledFor:[]})),_o(this,"state",Object(D["readonly"])(this.privateState)),_o(this,"colors",{}),_o(this,"segmentComparisons",Object(D["computed"])(()=>this.parseSegmentComparisons())),_o(this,"periodComparisons",Object(D["computed"])(()=>this.parsePeriodComparisons())),_o(this,"isEnabled",Object(D["computed"])(()=>this.checkEnabledForCurrentPage())),"complete"===document.readyState||"interactive"===document.readyState?this.loadComparisonsDisabledFor():document.addEventListener("DOMContentLoaded",()=>{this.loadComparisonsDisabledFor()}),$(()=>{this.colors=this.getAllSeriesColors()}),Object(D["watch"])(()=>this.getUrlStateWithoutPopoverKey(),()=>M.postEvent("piwikComparisonsChanged"))}getUrlStateWithoutPopoverKey(){const e=Object.fromEntries(Object.entries(U.parsed.value).filter(([e])=>"popover"!==e));return JSON.stringify(qo(e))}getComparisons(){return this.getSegmentComparisons().concat(this.getPeriodComparisons())}isComparing(){return this.isComparisonEnabled()&&(this.segmentComparisons.value.length>1||this.periodComparisons.value.length>1)}isComparingPeriods(){return this.getPeriodComparisons().length>1}getSegmentComparisons(){return this.isComparisonEnabled()?this.segmentComparisons.value:[]}getPeriodComparisons(){return this.isComparisonEnabled()?this.periodComparisons.value:[]}getSeriesColor(e,t,o=0){const i=this.getComparisonSeriesIndex(t.index,e.index)%Ho;if(0===o)return this.colors["series"+i];const n=o%$o;return this.colors[`series${i}-shade${n}`]}getSeriesColorName(e,t){let o="series"+e%Ho;return t>0&&(o+="-shade"+t%$o),o}isComparisonEnabled(){return this.isEnabled.value}getIndividualComparisonRowIndices(e){const t=this.getSegmentComparisons().length,o=e%t,i=Math.floor(e/t);return{segmentIndex:o,periodIndex:i}}getComparisonSeriesIndex(e,t){const o=this.getSegmentComparisons().length;return e*o+t}getAllComparisonSeries(){const e=[];let t=0;return this.getPeriodComparisons().forEach(o=>{this.getSegmentComparisons().forEach(i=>{e.push({index:t,params:Object.assign(Object.assign({},i.params),o.params),color:this.colors["series"+t]}),t+=1})}),e}removeSegmentComparison(e){if(!this.isComparisonEnabled())throw new Error("Comparison disabled.");const t=[...this.segmentComparisons.value];t.splice(e,1);const o={};0===e&&(o.segment=t[0].params.segment),this.updateQueryParamsFromComparisons(t,this.periodComparisons.value,o)}removeSegmentComparisonByDefinition(e){if(!this.isComparisonEnabled())throw new Error("Comparison disabled.");let t=null;this.getSegmentComparisons().forEach((o,i)=>{o&&o.params&&o.params.segment===e&&(t=i)}),null!==t&&this.removeSegmentComparison(t)}addSegmentComparison(e){if(!this.isComparisonEnabled())throw new Error("Comparison disabled.");const t=this.segmentComparisons.value.concat([{params:e,index:-1,title:""}]);this.updateQueryParamsFromComparisons(t,this.periodComparisons.value)}updateQueryParamsFromComparisons(e,t,o={}){const i={},n={};let a=!1,r=!1;e.forEach(e=>{a?i[e.params.segment]=!0:a=!0}),t.forEach(e=>{r?n[`${e.params.period}|${e.params.date}`]=!0:r=!0});const s=[],l=[];Object.keys(n).forEach(e=>{const t=e.split("|");s.push(t[0]),l.push(t[1])});const c={compareSegments:Object.keys(i),comparePeriods:s,compareDates:l},d=M.helper.isReportingPage()?U.hashParsed.value:U.urlParsed.value;U.updateLocation(Object.assign(Object.assign(Object.assign({},d),c),o))}getAllSeriesColors(){const{ColorManager:e}=M;if(!e)return[];const t=[];for(let o=0;o{this.privateState.comparisonsDisabledFor=e})}parseSegmentComparisons(){const{availableSegments:e}=Ao.state,t=[...Uo(U.parsed.value.compareSegments)];t.unshift(U.parsed.value.segment||"");const o=[];return t.forEach((t,i)=>{let n;e.forEach(e=>{e.definition!==t&&e.definition!==decodeURIComponent(t)&&decodeURIComponent(e.definition)!==t||(n=e)});let r=n?n.name:a("General_Unknown");""===t.trim()&&(r=a("SegmentEditor_DefaultAllVisits")),o.push({params:{segment:t},title:M.helper.htmlDecode(r),index:i})}),o}parsePeriodComparisons(){const e=[...Uo(U.parsed.value.comparePeriods)],t=[...Uo(U.parsed.value.compareDates)];e.unshift(U.parsed.value.period),t.unshift(U.parsed.value.date);const o=[];for(let n=0;nzo.isComparing()&&!window.broadcast.isNoDataPage()),t=Object(D["computed"])(()=>zo.getSegmentComparisons()),o=Object(D["computed"])(()=>zo.getPeriodComparisons()),i=zo.getSeriesColor.bind(zo);function n(){const e=window.$(this).attr("title");return e?window.vueSanitize(e.replace(/\n/g,"
")):e}return{isComparing:e,segmentComparisons:t,periodComparisons:o,getSeriesColor:i,transformTooltipContent:n}},methods:{comparisonHasSegment(e){return"undefined"!==typeof e.params.segment},removeSegmentComparison(e){window.$(this.$refs.root).tooltip("destroy"),zo.removeSegmentComparison(e)},getComparisonPeriodType(e){const{period:t}=e.params;if("range"===t)return a("CoreHome_PeriodRange");const o=a(`Intl_Period${t.substring(0,1).toUpperCase()}${t.substring(1)}`);return o.substring(0,1).toUpperCase()+o.substring(1)},getComparisonTooltip(e,t){if(this.comparisonTooltips&&Object.keys(this.comparisonTooltips).length)return(this.comparisonTooltips[t.index]||{})[e.index]},getTitleTooltip(e){return this.htmlentities(e.title)+"
"+this.htmlentities(decodeURIComponent(e.params.segment))},getUrlToSegment(e){const t=Object.assign({},U.hashParsed.value);return delete t.comparePeriods,delete t.compareDates,delete t.compareSegments,t.segment=e,`${window.location.search}#?${U.stringify(t)}`},onComparisonsChanged(){if(this.comparisonTooltips=null,!zo.isComparing())return;const e=zo.getPeriodComparisons(),t=zo.getSegmentComparisons();te.fetch({method:"API.getProcessedReport",apiModule:"VisitsSummary",apiAction:"get",compare:"1",compareSegments:U.getSearchParam("compareSegments"),comparePeriods:U.getSearchParam("comparePeriods"),compareDates:U.getSearchParam("compareDates"),format_metrics:"1"}).then(o=>{this.comparisonTooltips={},e.forEach(e=>{this.comparisonTooltips[e.index]={},t.forEach(t=>{const i=this.generateComparisonTooltip(o,e,t);this.comparisonTooltips[e.index][t.index]=i})})})},generateComparisonTooltip(e,t,o){if(!e.reportData.comparisons)return"";const i=zo.getComparisonSeriesIndex(t.index,0),n=e.reportData.comparisons[i],r=zo.getComparisonSeriesIndex(t.index,o.index),s=e.reportData.comparisons[r],l=e.reportData.comparisons[o.index];let c='
',d=(s.nb_visits/n.nb_visits*100).toFixed(2);return d+="%",c+=a("General_ComparisonCardTooltip1",[`'${this.htmlentities(s.compareSegmentPretty)}'`,s.comparePeriodPretty,d,s.nb_visits.toString(),n.nb_visits.toString()]),t.index>0&&(c+="

",c+=a("General_ComparisonCardTooltip2",[s.nb_visits_change.toString(),this.htmlentities(l.compareSegmentPretty),l.comparePeriodPretty])),c+="
",c},htmlentities(e){return M.helper.htmlEntities(e)}},mounted(){M.on("piwikComparisonsChanged",()=>{this.onComparisonsChanged()}),this.onComparisonsChanged()}});Go.render=Fo;var Ko=Go;const Yo={ref:"root",class:"menuDropdown"},Qo=["title"],Jo=["innerHTML"],Xo=Object(D["createElementVNode"])("span",{class:"icon-chevron-down reporting-menu-sub-icon"},null,-1),Zo={class:"items"},ei={key:0,class:"search"},ti=["placeholder"],oi=["title"],ii=["title"];function ni(e,t,o,i,n,a){const r=Object(D["resolveDirective"])("focus-if"),s=Object(D["resolveDirective"])("focus-anywhere-but-here");return Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Yo,[Object(D["createElementVNode"])("span",{class:"title",onClick:t[0]||(t[0]=t=>e.showItems=!e.showItems),title:e.tooltip},[Object(D["createElementVNode"])("span",{class:"title-label",innerHTML:e.$sanitize(this.actualMenuTitle)},null,8,Jo),Xo],8,Qo),Object(D["withDirectives"])(Object(D["createElementVNode"])("div",Zo,[e.showSearch&&e.showItems?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",ei,[Object(D["withDirectives"])(Object(D["createElementVNode"])("input",{type:"text","onUpdate:modelValue":t[1]||(t[1]=t=>e.searchTerm=t),onKeydown:t[2]||(t[2]=t=>e.onSearchTermKeydown(t)),placeholder:e.translate("General_Search")},null,40,ti),[[D["vModelText"],e.searchTerm],[r,{focused:e.showItems}]]),Object(D["withDirectives"])(Object(D["createElementVNode"])("div",{class:"search_ico icon-search",title:e.translate("General_Search")},null,8,oi),[[D["vShow"],!e.searchTerm]]),Object(D["withDirectives"])(Object(D["createElementVNode"])("div",{onClick:t[3]||(t[3]=t=>{e.searchTerm="",e.searchItems("")}),class:"reset icon-close",title:e.translate("General_Clear")},null,8,ii),[[D["vShow"],e.searchTerm]])])):Object(D["createCommentVNode"])("",!0),Object(D["createElementVNode"])("div",{onClick:t[4]||(t[4]=t=>e.selectItem(t))},[Object(D["renderSlot"])(e.$slots,"default")])],512),[[D["vShow"],e.showItems]])])),[[s,{blur:e.lostFocus}]])}const{$:ai}=window;var ri=Object(D["defineComponent"])({props:{menuTitle:String,tooltip:String,showSearch:Boolean,menuTitleChangeOnClick:Boolean},directives:{FocusAnywhereButHere:tt,FocusIf:it},emits:["afterSelect"],watch:{menuTitle(){this.actualMenuTitle=this.menuTitle}},data(){return{showItems:!1,searchTerm:"",actualMenuTitle:this.menuTitle}},methods:{lostFocus(){this.showItems=!1},selectItem(e){const t=e.target.classList;!t.contains("item")||t.contains("disabled")||t.contains("separator")||(this.menuTitleChangeOnClick&&(this.actualMenuTitle=(e.target.textContent||"").replace(/[\u0000-\u2666]/g,e=>`&#${e.charCodeAt(0)};`)),this.showItems=!1,ai(this.$slots.default()[0].el).find(".item").removeClass("active"),t.add("active"),this.$emit("afterSelect",e.target))},onSearchTermKeydown(){setTimeout(()=>{this.searchItems(this.searchTerm)})},searchItems(e){const t=e.toLowerCase();ai(this.$refs.root).find(".item").each((e,o)=>{const i=ai(o);-1===i.text().toLowerCase().indexOf(t)?i.hide():i.show()})}}});ri.render=ni;var si=ri;const li={ref:"root"};function ci(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",li,null,512)}const di=1,{$:ui}=window;var pi=Object(D["defineComponent"])({props:{selectedDateStart:Date,selectedDateEnd:Date,persistentHighlightedDateStart:Date,persistentHighlightedDateEnd:Date,highlightedDateStart:Date,highlightedDateEnd:Date,viewDate:[String,Date],stepMonths:Number,disableMonthDropdown:Boolean,disabled:Boolean,options:Object},emits:["cellHover","cellHoverLeave","dateSelect"],setup(e,t){const o=Object(D["ref"])(null);function i(t,o){const i=t.children("a"),{selectedDateStart:n,selectedDateEnd:a}=e,r=o.getTime(),s=!!(e.persistentHighlightedDateStart&&e.persistentHighlightedDateEnd&&o>=e.persistentHighlightedDateStart&&o<=e.persistentHighlightedDateEnd),l=!(!n||!a||r!==n.getTime()&&r!==a.getTime());l?t.addClass("ui-datepicker-current-period"):t.removeClass("ui-datepicker-current-period"),e.highlightedDateStart&&e.highlightedDateEnd&&o>=e.highlightedDateStart&&o<=e.highlightedDateEnd?(t.addClass("ui-state-hover"),i.length&&i.addClass("ui-state-hover")):(t.removeClass("ui-state-hover"),i.removeClass("ui-state-hover")),s?(t.addClass("ui-datepicker-persistent-highlight"),i.length&&i.addClass("ui-datepicker-persistent-highlight")):(t.removeClass("ui-datepicker-persistent-highlight"),i.removeClass("ui-datepicker-persistent-highlight"))}function n(e,t,o){if(e.hasClass("ui-datepicker-other-month"))return a(e,t,o);const i=parseInt(e.children("a,span").text(),10);return new Date(o,t,i)}function a(e,t,o){let i;const a=e.parent(),r=a.children("td");if(a.is(":first-child")){const s=a.children("td:not(.ui-datepicker-other-month)").first();return i=n(s,t,o),i.setDate(r.index(e)-r.index(s)+1),i}const s=a.children("td:not(.ui-datepicker-other-month)").last();return i=n(s,t,o),i.setDate(i.getDate()+r.index(e)-r.index(s)),i}function r(){const e=ui(o.value),t=e.find("td[data-month]"),i=parseInt(t.attr("data-month"),10),n=parseInt(t.attr("data-year"),10);return[i,n]}function s(){const e=ui(o.value),t=e.find(".ui-datepicker-calendar"),a=r(),s=t.find("td"),l=s.first(),c=n(l,a[0],a[1]);s.each((function(){i(ui(this),c),c.setDate(c.getDate()+1)}))}function l(){if(!e.viewDate)return!1;let t;if("string"===typeof e.viewDate)try{t=p(e.viewDate)}catch(a){return!1}else t=e.viewDate;const i=ui(o.value),n=r();return(n[0]!==t.getMonth()||n[1]!==t.getFullYear())&&(i.datepicker("setDate",t),!0)}function c(){const t=ui(o.value),i=t.find(".ui-datepicker-month")[0];i&&(i.disabled=e.disableMonthDropdown||!!e.disabled);const n=t.find(".ui-datepicker-year")[0];n&&(n.disabled=!!e.disabled)}function d(){const t=ui(o.value),i=e.disabled?-1:0;t.find("a, select").attr("tabindex",i),t.attr("aria-disabled",e.disabled?"true":"false"),e.disabled?t.find("a").attr("aria-disabled","true"):t.find("a").removeAttr("aria-disabled")}function u(){const e=ui(o.value);e.find("td[data-event]").off("click"),e.find(".ui-state-active").removeClass("ui-state-active"),e.find(".ui-datepicker-current-day").removeClass("ui-datepicker-current-day"),e.find(".ui-datepicker-prev,.ui-datepicker-next").attr("href",""),e.find(".ui-datepicker-prev .ui-icon").removeClass("ui-icon-circle-triangle-w").addClass("icon-chevron-left"),e.find(".ui-datepicker-next .ui-icon").removeClass("ui-icon-circle-triangle-e").addClass("icon-chevron-right"),d()}function m(){const t=ui(o.value),i=e.stepMonths||di;if(t.datepicker("option","stepMonths")===i)return!1;const n=ui(".ui-datepicker-month",t).val(),a=ui(".ui-datepicker-year",t).val();return t.datepicker("option","stepMonths",i).datepicker("setDate",new Date(a,n)),u(),!0}function h(){if(!ui(this).hasClass("ui-state-hover"))return;const e=ui(this).parent(),t=e.parent();e.is(":first-child")?t.find("a").first().click():t.find("a").last().click()}function g(){c(),d(),s()}return Object(D["watch"])(()=>Object.assign({},e),(e,t)=>{let o=!1;[e=>e.selectedDateStart,e=>e.selectedDateEnd,e=>e.persistentHighlightedDateStart,e=>e.persistentHighlightedDateEnd,e=>e.highlightedDateStart,e=>e.highlightedDateEnd].forEach(i=>{if(o)return;const n=i(e),a=i(t);!n&&a&&(o=!0),n&&!a&&(o=!0),n&&a&&n.getTime()!==a.getTime()&&(o=!0)}),e.viewDate!==t.viewDate&&l()&&(o=!0),e.stepMonths!==t.stepMonths&&m(),e.disableMonthDropdown!==t.disableMonthDropdown&&c(),e.disabled!==t.disabled&&(c(),d()),o&&s()}),Object(D["onMounted"])(()=>{const i=ui(o.value),a=e.options||{},p=Object.assign(Object.assign(Object.assign({},M.getBaseDatePickerOptions()),a),{},{onChangeMonthYear:()=>{setTimeout(()=>{u()})}});i.datepicker(p),i.on("mouseover","tbody td a",e=>{e.originalEvent&&s()}),i.on("mouseenter","tbody td",(function(){const e=r(),o=ui(this),i=n(o,e[0],e[1]);t.emit("cellHover",{date:i,$cell:o})})),i.on("mouseout","tbody td a",()=>{s()}),i.on("mouseleave","table",()=>t.emit("cellHoverLeave")).on("mouseenter","thead",()=>t.emit("cellHoverLeave")),i.on("click","tbody td.ui-datepicker-other-month",h),i.on("click",e=>{e.preventDefault();const t=ui(e.target).closest("a");(t.is(".ui-datepicker-next")||t.is(".ui-datepicker-prev"))&&g()}),i.on("click","td[data-month]",e=>{const o=ui(e.target).closest("td"),i=parseInt(o.attr("data-month"),10),n=parseInt(o.attr("data-year"),10),a=parseInt(o.children("a,span").text(),10);t.emit("dateSelect",{date:new Date(n,i,a)})});const b=m();l(),c(),b||u(),d(),s()}),{root:o}}});pi.render=ci;var mi=pi;const hi={class:"dateRangePicker"},gi={id:"calendarRangeFrom"},bi={class:"dateRangePicker-label"},fi=["disabled"],vi={id:"calendarRangeTo"},Oi={class:"dateRangePicker-label"},yi=["disabled"];function ji(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("DatePicker");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",hi,[Object(D["createElementVNode"])("div",gi,[Object(D["createElementVNode"])("h6",bi,[Object(D["createTextVNode"])(Object(D["toDisplayString"])(e.translate("General_DateRangeFrom"))+" ",1),Object(D["withDirectives"])(Object(D["createElementVNode"])("input",{type:"text",id:"inputCalendarFrom",name:"inputCalendarFrom",class:"browser-default dateRangePicker-field",disabled:e.disabled,"onUpdate:modelValue":t[0]||(t[0]=t=>e.startDateText=t),onKeydown:t[1]||(t[1]=t=>e.onRangeInputChanged("from",t)),onKeyup:t[2]||(t[2]=t=>e.handleEnterPress(t))},null,40,fi),[[D["vModelText"],e.startDateText]])]),Object(D["createVNode"])(r,{id:"calendarFrom","view-date":e.startDate,"selected-date-start":e.fromPickerSelectedDate,"selected-date-end":e.fromPickerSelectedDate,"highlighted-date-start":e.fromPickerHoveredDate,"highlighted-date-end":e.fromPickerHoveredDate,disabled:e.disabled,onDateSelect:t[3]||(t[3]=t=>e.setStartRangeDate(t.date)),onCellHover:t[4]||(t[4]=t=>e.fromPickerHoveredDate=e.getNewHoveredDate(t.date,t.$cell)),onCellHoverLeave:t[5]||(t[5]=t=>e.fromPickerHoveredDate=null)},null,8,["view-date","selected-date-start","selected-date-end","highlighted-date-start","highlighted-date-end","disabled"])]),Object(D["createElementVNode"])("div",vi,[Object(D["createElementVNode"])("h6",Oi,[Object(D["createTextVNode"])(Object(D["toDisplayString"])(e.translate("General_DateRangeTo"))+" ",1),Object(D["withDirectives"])(Object(D["createElementVNode"])("input",{type:"text",id:"inputCalendarTo",name:"inputCalendarTo",class:"browser-default dateRangePicker-field",disabled:e.disabled,"onUpdate:modelValue":t[6]||(t[6]=t=>e.endDateText=t),onKeydown:t[7]||(t[7]=t=>e.onRangeInputChanged("to",t)),onKeyup:t[8]||(t[8]=t=>e.handleEnterPress(t))},null,40,yi),[[D["vModelText"],e.endDateText]])]),Object(D["createVNode"])(r,{id:"calendarTo","view-date":e.endDate,"selected-date-start":e.toPickerSelectedDate,"selected-date-end":e.toPickerSelectedDate,"highlighted-date-start":e.toPickerHoveredDate,"highlighted-date-end":e.toPickerHoveredDate,disabled:e.disabled,onDateSelect:t[9]||(t[9]=t=>e.setEndRangeDate(t.date)),onCellHover:t[10]||(t[10]=t=>e.toPickerHoveredDate=e.getNewHoveredDate(t.date,t.$cell)),onCellHoverLeave:t[11]||(t[11]=t=>e.toPickerHoveredDate=null)},null,8,["view-date","selected-date-start","selected-date-end","highlighted-date-start","highlighted-date-end","disabled"])])])}const wi="YYYY-MM-DD";var Si=Object(D["defineComponent"])({name:"DateRangePicker",props:{startDate:String,endDate:String,disabled:Boolean},components:{DatePicker:mi},data(){let e=null;try{this.startDate&&(e=p(this.startDate))}catch(o){}let t=null;try{this.endDate&&(t=p(this.endDate))}catch(o){}return{fromPickerSelectedDate:e,toPickerSelectedDate:t,fromPickerHoveredDate:null,toPickerHoveredDate:null,startDateText:this.startDate,endDateText:this.endDate,startDateInvalid:!1,endDateInvalid:!1}},emits:["rangeChange","submit"],watch:{startDate(){this.startDateText=this.startDate,this.syncStartRangeDateFromProp(this.startDate)},endDate(){this.endDateText=this.endDate,this.syncEndRangeDateFromProp(this.endDate)}},methods:{setStartRangeDate(e){this.fromPickerSelectedDate=e,this.rangeChanged()},setEndRangeDate(e){this.toPickerSelectedDate=e,this.rangeChanged()},onRangeInputChanged(e,t){setTimeout(()=>{"from"===e?this.setStartRangeDateFromStr(t.target.value):this.setEndRangeDateFromStr(t.target.value)})},getNewHoveredDate(e,t){return t.hasClass("ui-datepicker-unselectable")?null:e},handleEnterPress(e){13===e.keyCode&&this.$emit("submit",{start:this.startDate,end:this.endDate})},syncStartRangeDateFromProp(e){this.startDateInvalid=!0;let t=null;try{e&&e.length===wi.length&&(t=p(e))}catch(o){}t&&(this.fromPickerSelectedDate=t,this.startDateInvalid=!1)},setStartRangeDateFromStr(e){this.syncStartRangeDateFromProp(e),this.startDateInvalid||this.rangeChanged()},syncEndRangeDateFromProp(e){this.endDateInvalid=!0;let t=null;try{e&&e.length===wi.length&&(t=p(e))}catch(o){}t&&(this.toPickerSelectedDate=t,this.endDateInvalid=!1)},setEndRangeDateFromStr(e){this.syncEndRangeDateFromProp(e),this.endDateInvalid||this.rangeChanged()},rangeChanged(){this.$emit("rangeChange",{start:this.fromPickerSelectedDate?d(this.fromPickerSelectedDate):null,end:this.toPickerSelectedDate?d(this.toPickerSelectedDate):null})}}});Si.render=ji;var Ci=Si;function ki(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("DatePicker");return Object(D["openBlock"])(),Object(D["createBlock"])(r,{"selected-date-start":e.selectedDates[0],"selected-date-end":e.selectedDates[1],"persistent-highlighted-date-start":e.committedBetweenHighlightDates[0],"persistent-highlighted-date-end":e.committedBetweenHighlightDates[1],"highlighted-date-start":e.highlightedDates?e.highlightedDates[0]:null,"highlighted-date-end":e.highlightedDates?e.highlightedDates[1]:null,"view-date":e.viewDate,"step-months":"year"===e.period?12:1,"disable-month-dropdown":"year"===e.period,disabled:e.disabled,onCellHover:t[0]||(t[0]=t=>e.onHoverNormalCell(t.date,t.$cell)),onCellHoverLeave:t[1]||(t[1]=t=>e.onHoverLeaveNormalCells()),onDateSelect:t[2]||(t[2]=t=>e.onDateSelected(t.date))},null,8,["selected-date-start","selected-date-end","persistent-highlighted-date-start","persistent-highlighted-date-end","highlighted-date-start","highlighted-date-end","view-date","step-months","disable-month-dropdown","disabled"])} + */const $o=8,Uo=3;function qo(e){return e?Array.isArray(e)?e:[e]:[]}function Wo(e){return Array.isArray(e)?e.map(Wo):e&&"object"===typeof e?Object.fromEntries(Object.entries(e).sort(([e],[t])=>e.localeCompare(t)).map(([e,t])=>[e,Wo(t)])):e}class zo{constructor(){Ho(this,"privateState",Object(D["reactive"])({comparisonsDisabledFor:[]})),Ho(this,"state",Object(D["readonly"])(this.privateState)),Ho(this,"colors",Object(D["ref"])({})),Ho(this,"segmentComparisons",Object(D["computed"])(()=>this.parseSegmentComparisons())),Ho(this,"periodComparisons",Object(D["computed"])(()=>this.parsePeriodComparisons())),Ho(this,"isEnabled",Object(D["computed"])(()=>this.checkEnabledForCurrentPage())),"complete"===document.readyState||"interactive"===document.readyState?this.loadComparisonsDisabledFor():document.addEventListener("DOMContentLoaded",()=>{this.loadComparisonsDisabledFor()}),$(()=>{this.colors.value=this.getAllSeriesColors()}),Object(D["watch"])(()=>this.getUrlStateWithoutPopoverKey(),()=>I.postEvent("piwikComparisonsChanged"))}getUrlStateWithoutPopoverKey(){const e=Object.fromEntries(Object.entries(U.parsed.value).filter(([e])=>"popover"!==e));return JSON.stringify(Wo(e))}getComparisons(){return this.getSegmentComparisons().concat(this.getPeriodComparisons())}isComparing(){return this.isComparisonEnabled()&&(this.segmentComparisons.value.length>1||this.periodComparisons.value.length>1)}isComparingPeriods(){return this.getPeriodComparisons().length>1}getSegmentComparisons(){return this.isComparisonEnabled()?this.segmentComparisons.value:[]}getPeriodComparisons(){return this.isComparisonEnabled()?this.periodComparisons.value:[]}getSeriesColor(e,t,o=0){const i=this.getComparisonSeriesIndex(t.index,e.index)%$o;if(0===o)return this.colors.value["series"+i];const n=o%Uo;return this.colors.value[`series${i}-shade${n}`]}getSeriesColorName(e,t){let o="series"+e%$o;return t>0&&(o+="-shade"+t%Uo),o}isComparisonEnabled(){return this.isEnabled.value}getIndividualComparisonRowIndices(e){const t=this.getSegmentComparisons().length,o=e%t,i=Math.floor(e/t);return{segmentIndex:o,periodIndex:i}}getComparisonSeriesIndex(e,t){const o=this.getSegmentComparisons().length;return e*o+t}getAllComparisonSeries(){const e=[];let t=0;return this.getPeriodComparisons().forEach(o=>{this.getSegmentComparisons().forEach(i=>{e.push({index:t,params:Object.assign(Object.assign({},i.params),o.params),color:this.colors.value["series"+t]}),t+=1})}),e}removeSegmentComparison(e){if(!this.isComparisonEnabled())throw new Error("Comparison disabled.");const t=[...this.segmentComparisons.value];t.splice(e,1);const o={};0===e&&(o.segment=t[0].params.segment),this.updateQueryParamsFromComparisons(t,this.periodComparisons.value,o)}removeSegmentComparisonByDefinition(e){if(!this.isComparisonEnabled())throw new Error("Comparison disabled.");let t=null;this.getSegmentComparisons().forEach((o,i)=>{o&&o.params&&o.params.segment===e&&(t=i)}),null!==t&&this.removeSegmentComparison(t)}addSegmentComparison(e){if(!this.isComparisonEnabled())throw new Error("Comparison disabled.");const t=this.segmentComparisons.value.concat([{params:e,index:-1,title:""}]);this.updateQueryParamsFromComparisons(t,this.periodComparisons.value)}updateQueryParamsFromComparisons(e,t,o={}){const i={},n={};let a=!1,r=!1;e.forEach(e=>{a?i[e.params.segment]=!0:a=!0}),t.forEach(e=>{r?n[`${e.params.period}|${e.params.date}`]=!0:r=!0});const s=[],l=[];Object.keys(n).forEach(e=>{const t=e.split("|");s.push(t[0]),l.push(t[1])});const c={compareSegments:Object.keys(i),comparePeriods:s,compareDates:l},d=I.helper.isReportingPage()?U.hashParsed.value:U.urlParsed.value;U.updateLocation(Object.assign(Object.assign(Object.assign({},d),c),o))}getAllSeriesColors(){const{ColorManager:e}=I;if(!e)return[];const t=[];for(let o=0;o<$o;o+=1){t.push("series"+o);for(let e=0;e{this.privateState.comparisonsDisabledFor=e})}parseSegmentComparisons(){const{availableSegments:e}=_o.state,t=[...qo(U.parsed.value.compareSegments)];t.unshift(U.parsed.value.segment||"");const o=[];return t.forEach((t,i)=>{let n;e.forEach(e=>{e.definition!==t&&e.definition!==decodeURIComponent(t)&&decodeURIComponent(e.definition)!==t||(n=e)});let r=n?n.name:a("General_Unknown");""===t.trim()&&(r=a("SegmentEditor_DefaultAllVisits")),o.push({params:{segment:t},title:I.helper.htmlDecode(r),index:i})}),o}parsePeriodComparisons(){const e=[...qo(U.parsed.value.comparePeriods)],t=[...qo(U.parsed.value.compareDates)];e.unshift(U.parsed.value.period),t.unshift(U.parsed.value.date);const o=[];for(let n=0;nGo.isComparing()&&!window.broadcast.isNoDataPage()),t=Object(D["computed"])(()=>Go.getSegmentComparisons()),o=Object(D["computed"])(()=>Go.getPeriodComparisons()),i=Go.getSeriesColor.bind(Go);function n(){const e=window.$(this).attr("title");return e?window.vueSanitize(e.replace(/\n/g,"
")):e}return{isComparing:e,segmentComparisons:t,periodComparisons:o,getSeriesColor:i,transformTooltipContent:n}},methods:{comparisonHasSegment(e){return"undefined"!==typeof e.params.segment},removeSegmentComparison(e){window.$(this.$refs.root).tooltip("destroy"),Go.removeSegmentComparison(e)},getComparisonPeriodType(e){const{period:t}=e.params;if("range"===t)return a("CoreHome_PeriodRange");const o=a(`Intl_Period${t.substring(0,1).toUpperCase()}${t.substring(1)}`);return o.substring(0,1).toUpperCase()+o.substring(1)},getComparisonTooltip(e,t){if(this.comparisonTooltips&&Object.keys(this.comparisonTooltips).length)return(this.comparisonTooltips[t.index]||{})[e.index]},getTitleTooltip(e){return this.htmlentities(e.title)+"
"+this.htmlentities(decodeURIComponent(e.params.segment))},getUrlToSegment(e){const t=Object.assign({},U.hashParsed.value);return delete t.comparePeriods,delete t.compareDates,delete t.compareSegments,t.segment=e,`${window.location.search}#?${U.stringify(t)}`},onComparisonsChanged(){if(this.comparisonTooltips=null,!Go.isComparing())return;const e=Go.getPeriodComparisons(),t=Go.getSegmentComparisons();te.fetch({method:"API.getProcessedReport",apiModule:"VisitsSummary",apiAction:"get",compare:"1",compareSegments:U.getSearchParam("compareSegments"),comparePeriods:U.getSearchParam("comparePeriods"),compareDates:U.getSearchParam("compareDates"),format_metrics:"1"}).then(o=>{this.comparisonTooltips={},e.forEach(e=>{this.comparisonTooltips[e.index]={},t.forEach(t=>{const i=this.generateComparisonTooltip(o,e,t);this.comparisonTooltips[e.index][t.index]=i})})})},generateComparisonTooltip(e,t,o){if(!e.reportData.comparisons)return"";const i=Go.getComparisonSeriesIndex(t.index,0),n=e.reportData.comparisons[i],r=Go.getComparisonSeriesIndex(t.index,o.index),s=e.reportData.comparisons[r],l=e.reportData.comparisons[o.index];let c='
',d=(s.nb_visits/n.nb_visits*100).toFixed(2);return d+="%",c+=a("General_ComparisonCardTooltip1",[`'${this.htmlentities(s.compareSegmentPretty)}'`,s.comparePeriodPretty,d,s.nb_visits.toString(),n.nb_visits.toString()]),t.index>0&&(c+="

",c+=a("General_ComparisonCardTooltip2",[s.nb_visits_change.toString(),this.htmlentities(l.compareSegmentPretty),l.comparePeriodPretty])),c+="
",c},htmlentities(e){return I.helper.htmlEntities(e)}},mounted(){I.on("piwikComparisonsChanged",()=>{this.onComparisonsChanged()}),this.onComparisonsChanged()}});Ko.render=Ro;var Yo=Ko;const Qo={ref:"root",class:"menuDropdown"},Jo=["title"],Xo=["innerHTML"],Zo=Object(D["createElementVNode"])("span",{class:"icon-chevron-down reporting-menu-sub-icon"},null,-1),ei={class:"items"},ti={key:0,class:"search"},oi=["placeholder"],ii=["title"],ni=["title"];function ai(e,t,o,i,n,a){const r=Object(D["resolveDirective"])("focus-if"),s=Object(D["resolveDirective"])("focus-anywhere-but-here");return Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Qo,[Object(D["createElementVNode"])("span",{class:"title",onClick:t[0]||(t[0]=t=>e.showItems=!e.showItems),title:e.tooltip},[Object(D["createElementVNode"])("span",{class:"title-label",innerHTML:e.$sanitize(this.actualMenuTitle)},null,8,Xo),Zo],8,Jo),Object(D["withDirectives"])(Object(D["createElementVNode"])("div",ei,[e.showSearch&&e.showItems?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",ti,[Object(D["withDirectives"])(Object(D["createElementVNode"])("input",{type:"text","onUpdate:modelValue":t[1]||(t[1]=t=>e.searchTerm=t),onKeydown:t[2]||(t[2]=t=>e.onSearchTermKeydown(t)),placeholder:e.translate("General_Search")},null,40,oi),[[D["vModelText"],e.searchTerm],[r,{focused:e.showItems}]]),Object(D["withDirectives"])(Object(D["createElementVNode"])("div",{class:"search_ico icon-search",title:e.translate("General_Search")},null,8,ii),[[D["vShow"],!e.searchTerm]]),Object(D["withDirectives"])(Object(D["createElementVNode"])("div",{onClick:t[3]||(t[3]=t=>{e.searchTerm="",e.searchItems("")}),class:"reset icon-close",title:e.translate("General_Clear")},null,8,ni),[[D["vShow"],e.searchTerm]])])):Object(D["createCommentVNode"])("",!0),Object(D["createElementVNode"])("div",{onClick:t[4]||(t[4]=t=>e.selectItem(t))},[Object(D["renderSlot"])(e.$slots,"default")])],512),[[D["vShow"],e.showItems]])])),[[s,{blur:e.lostFocus}]])}const{$:ri}=window;var si=Object(D["defineComponent"])({props:{menuTitle:String,tooltip:String,showSearch:Boolean,menuTitleChangeOnClick:Boolean},directives:{FocusAnywhereButHere:ot,FocusIf:nt},emits:["afterSelect"],watch:{menuTitle(){this.actualMenuTitle=this.menuTitle}},data(){return{showItems:!1,searchTerm:"",actualMenuTitle:this.menuTitle}},methods:{lostFocus(){this.showItems=!1},selectItem(e){const t=e.target.classList;!t.contains("item")||t.contains("disabled")||t.contains("separator")||(this.menuTitleChangeOnClick&&(this.actualMenuTitle=(e.target.textContent||"").replace(/[\u0000-\u2666]/g,e=>`&#${e.charCodeAt(0)};`)),this.showItems=!1,ri(this.$slots.default()[0].el).find(".item").removeClass("active"),t.add("active"),this.$emit("afterSelect",e.target))},onSearchTermKeydown(){setTimeout(()=>{this.searchItems(this.searchTerm)})},searchItems(e){const t=e.toLowerCase();ri(this.$refs.root).find(".item").each((e,o)=>{const i=ri(o);-1===i.text().toLowerCase().indexOf(t)?i.hide():i.show()})}}});si.render=ai;var li=si;const ci={ref:"root"};function di(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",ci,null,512)}const ui=1,{$:mi}=window;var pi=Object(D["defineComponent"])({props:{selectedDateStart:Date,selectedDateEnd:Date,persistentHighlightedDateStart:Date,persistentHighlightedDateEnd:Date,highlightedDateStart:Date,highlightedDateEnd:Date,viewDate:[String,Date],stepMonths:Number,disableMonthDropdown:Boolean,disabled:Boolean,options:Object},emits:["cellHover","cellHoverLeave","dateSelect"],setup(e,t){const o=Object(D["ref"])(null);function i(t,o){const i=t.children("a"),{selectedDateStart:n,selectedDateEnd:a}=e,r=o.getTime(),s=!!(e.persistentHighlightedDateStart&&e.persistentHighlightedDateEnd&&o>=e.persistentHighlightedDateStart&&o<=e.persistentHighlightedDateEnd),l=!(!n||!a||r!==n.getTime()&&r!==a.getTime());l?t.addClass("ui-datepicker-current-period"):t.removeClass("ui-datepicker-current-period"),e.highlightedDateStart&&e.highlightedDateEnd&&o>=e.highlightedDateStart&&o<=e.highlightedDateEnd?(t.addClass("ui-state-hover"),i.length&&i.addClass("ui-state-hover")):(t.removeClass("ui-state-hover"),i.removeClass("ui-state-hover")),s?(t.addClass("ui-datepicker-persistent-highlight"),i.length&&i.addClass("ui-datepicker-persistent-highlight")):(t.removeClass("ui-datepicker-persistent-highlight"),i.removeClass("ui-datepicker-persistent-highlight"))}function n(e,t,o){if(e.hasClass("ui-datepicker-other-month"))return a(e,t,o);const i=parseInt(e.children("a,span").text(),10);return new Date(o,t,i)}function a(e,t,o){let i;const a=e.parent(),r=a.children("td");if(a.is(":first-child")){const s=a.children("td:not(.ui-datepicker-other-month)").first();return i=n(s,t,o),i.setDate(r.index(e)-r.index(s)+1),i}const s=a.children("td:not(.ui-datepicker-other-month)").last();return i=n(s,t,o),i.setDate(i.getDate()+r.index(e)-r.index(s)),i}function r(){const e=mi(o.value),t=e.find("td[data-month]"),i=parseInt(t.attr("data-month"),10),n=parseInt(t.attr("data-year"),10);return[i,n]}function s(){const e=mi(o.value),t=e.find(".ui-datepicker-calendar"),a=r(),s=t.find("td"),l=s.first(),c=n(l,a[0],a[1]);s.each((function(){i(mi(this),c),c.setDate(c.getDate()+1)}))}function l(){if(!e.viewDate)return!1;let t;if("string"===typeof e.viewDate)try{t=m(e.viewDate)}catch(a){return!1}else t=e.viewDate;const i=mi(o.value),n=r();return(n[0]!==t.getMonth()||n[1]!==t.getFullYear())&&(i.datepicker("setDate",t),!0)}function c(){const t=mi(o.value),i=t.find(".ui-datepicker-month")[0];i&&(i.disabled=e.disableMonthDropdown||!!e.disabled);const n=t.find(".ui-datepicker-year")[0];n&&(n.disabled=!!e.disabled)}function d(){const t=mi(o.value),i=e.disabled?-1:0;t.find("a, select").attr("tabindex",i),t.attr("aria-disabled",e.disabled?"true":"false"),e.disabled?t.find("a").attr("aria-disabled","true"):t.find("a").removeAttr("aria-disabled")}function u(){const e=mi(o.value);e.find("td[data-event]").off("click"),e.find(".ui-state-active").removeClass("ui-state-active"),e.find(".ui-datepicker-current-day").removeClass("ui-datepicker-current-day"),e.find(".ui-datepicker-prev,.ui-datepicker-next").attr("href",""),e.find(".ui-datepicker-prev .ui-icon").removeClass("ui-icon-circle-triangle-w").addClass("icon-chevron-left"),e.find(".ui-datepicker-next .ui-icon").removeClass("ui-icon-circle-triangle-e").addClass("icon-chevron-right"),d()}function p(){const t=mi(o.value),i=e.stepMonths||ui;if(t.datepicker("option","stepMonths")===i)return!1;const n=mi(".ui-datepicker-month",t).val(),a=mi(".ui-datepicker-year",t).val();return t.datepicker("option","stepMonths",i).datepicker("setDate",new Date(a,n)),u(),!0}function h(){if(!mi(this).hasClass("ui-state-hover"))return;const e=mi(this).parent(),t=e.parent();e.is(":first-child")?t.find("a").first().click():t.find("a").last().click()}function g(){c(),d(),s()}return Object(D["watch"])(()=>Object.assign({},e),(e,t)=>{let o=!1;[e=>e.selectedDateStart,e=>e.selectedDateEnd,e=>e.persistentHighlightedDateStart,e=>e.persistentHighlightedDateEnd,e=>e.highlightedDateStart,e=>e.highlightedDateEnd].forEach(i=>{if(o)return;const n=i(e),a=i(t);!n&&a&&(o=!0),n&&!a&&(o=!0),n&&a&&n.getTime()!==a.getTime()&&(o=!0)}),e.viewDate!==t.viewDate&&l()&&(o=!0),e.stepMonths!==t.stepMonths&&p(),e.disableMonthDropdown!==t.disableMonthDropdown&&c(),e.disabled!==t.disabled&&(c(),d()),o&&s()}),Object(D["onMounted"])(()=>{const i=mi(o.value),a=e.options||{},m=Object.assign(Object.assign(Object.assign({},I.getBaseDatePickerOptions()),a),{},{onChangeMonthYear:()=>{setTimeout(()=>{u()})}});i.datepicker(m),i.on("mouseover","tbody td a",e=>{e.originalEvent&&s()}),i.on("mouseenter","tbody td",(function(){const e=r(),o=mi(this),i=n(o,e[0],e[1]);t.emit("cellHover",{date:i,$cell:o})})),i.on("mouseout","tbody td a",()=>{s()}),i.on("mouseleave","table",()=>t.emit("cellHoverLeave")).on("mouseenter","thead",()=>t.emit("cellHoverLeave")),i.on("click","tbody td.ui-datepicker-other-month",h),i.on("click",e=>{e.preventDefault();const t=mi(e.target).closest("a");(t.is(".ui-datepicker-next")||t.is(".ui-datepicker-prev"))&&g()}),i.on("click","td[data-month]",e=>{const o=mi(e.target).closest("td"),i=parseInt(o.attr("data-month"),10),n=parseInt(o.attr("data-year"),10),a=parseInt(o.children("a,span").text(),10);t.emit("dateSelect",{date:new Date(n,i,a)})});const b=p();l(),c(),b||u(),d(),s()}),{root:o}}});pi.render=di;var hi=pi;const gi={class:"dateRangePicker"},bi={id:"calendarRangeFrom"},fi={class:"dateRangePicker-label"},vi=["disabled"],Oi={id:"calendarRangeTo"},yi={class:"dateRangePicker-label"},ji=["disabled"];function wi(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("DatePicker");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",gi,[Object(D["createElementVNode"])("div",bi,[Object(D["createElementVNode"])("h6",fi,[Object(D["createTextVNode"])(Object(D["toDisplayString"])(e.translate("General_DateRangeFrom"))+" ",1),Object(D["withDirectives"])(Object(D["createElementVNode"])("input",{type:"text",id:"inputCalendarFrom",name:"inputCalendarFrom",class:"browser-default dateRangePicker-field",disabled:e.disabled,"onUpdate:modelValue":t[0]||(t[0]=t=>e.startDateText=t),onKeydown:t[1]||(t[1]=t=>e.onRangeInputChanged("from",t)),onKeyup:t[2]||(t[2]=t=>e.handleEnterPress(t))},null,40,vi),[[D["vModelText"],e.startDateText]])]),Object(D["createVNode"])(r,{id:"calendarFrom","view-date":e.startDate,"selected-date-start":e.fromPickerSelectedDate,"selected-date-end":e.fromPickerSelectedDate,"highlighted-date-start":e.fromPickerHoveredDate,"highlighted-date-end":e.fromPickerHoveredDate,disabled:e.disabled,onDateSelect:t[3]||(t[3]=t=>e.setStartRangeDate(t.date)),onCellHover:t[4]||(t[4]=t=>e.fromPickerHoveredDate=e.getNewHoveredDate(t.date,t.$cell)),onCellHoverLeave:t[5]||(t[5]=t=>e.fromPickerHoveredDate=null)},null,8,["view-date","selected-date-start","selected-date-end","highlighted-date-start","highlighted-date-end","disabled"])]),Object(D["createElementVNode"])("div",Oi,[Object(D["createElementVNode"])("h6",yi,[Object(D["createTextVNode"])(Object(D["toDisplayString"])(e.translate("General_DateRangeTo"))+" ",1),Object(D["withDirectives"])(Object(D["createElementVNode"])("input",{type:"text",id:"inputCalendarTo",name:"inputCalendarTo",class:"browser-default dateRangePicker-field",disabled:e.disabled,"onUpdate:modelValue":t[6]||(t[6]=t=>e.endDateText=t),onKeydown:t[7]||(t[7]=t=>e.onRangeInputChanged("to",t)),onKeyup:t[8]||(t[8]=t=>e.handleEnterPress(t))},null,40,ji),[[D["vModelText"],e.endDateText]])]),Object(D["createVNode"])(r,{id:"calendarTo","view-date":e.endDate,"selected-date-start":e.toPickerSelectedDate,"selected-date-end":e.toPickerSelectedDate,"highlighted-date-start":e.toPickerHoveredDate,"highlighted-date-end":e.toPickerHoveredDate,disabled:e.disabled,onDateSelect:t[9]||(t[9]=t=>e.setEndRangeDate(t.date)),onCellHover:t[10]||(t[10]=t=>e.toPickerHoveredDate=e.getNewHoveredDate(t.date,t.$cell)),onCellHoverLeave:t[11]||(t[11]=t=>e.toPickerHoveredDate=null)},null,8,["view-date","selected-date-start","selected-date-end","highlighted-date-start","highlighted-date-end","disabled"])])])}const Si="YYYY-MM-DD";var Ci=Object(D["defineComponent"])({name:"DateRangePicker",props:{startDate:String,endDate:String,disabled:Boolean},components:{DatePicker:hi},data(){let e=null;try{this.startDate&&(e=m(this.startDate))}catch(o){}let t=null;try{this.endDate&&(t=m(this.endDate))}catch(o){}return{fromPickerSelectedDate:e,toPickerSelectedDate:t,fromPickerHoveredDate:null,toPickerHoveredDate:null,startDateText:this.startDate,endDateText:this.endDate,startDateInvalid:!1,endDateInvalid:!1}},emits:["rangeChange","submit"],watch:{startDate(){this.startDateText=this.startDate,this.syncStartRangeDateFromProp(this.startDate)},endDate(){this.endDateText=this.endDate,this.syncEndRangeDateFromProp(this.endDate)}},methods:{setStartRangeDate(e){this.fromPickerSelectedDate=e,this.rangeChanged()},setEndRangeDate(e){this.toPickerSelectedDate=e,this.rangeChanged()},onRangeInputChanged(e,t){setTimeout(()=>{"from"===e?this.setStartRangeDateFromStr(t.target.value):this.setEndRangeDateFromStr(t.target.value)})},getNewHoveredDate(e,t){return t.hasClass("ui-datepicker-unselectable")?null:e},handleEnterPress(e){13===e.keyCode&&this.$emit("submit",{start:this.startDate,end:this.endDate})},syncStartRangeDateFromProp(e){this.startDateInvalid=!0;let t=null;try{e&&e.length===Si.length&&(t=m(e))}catch(o){}t&&(this.fromPickerSelectedDate=t,this.startDateInvalid=!1)},setStartRangeDateFromStr(e){this.syncStartRangeDateFromProp(e),this.startDateInvalid||this.rangeChanged()},syncEndRangeDateFromProp(e){this.endDateInvalid=!0;let t=null;try{e&&e.length===Si.length&&(t=m(e))}catch(o){}t&&(this.toPickerSelectedDate=t,this.endDateInvalid=!1)},setEndRangeDateFromStr(e){this.syncEndRangeDateFromProp(e),this.endDateInvalid||this.rangeChanged()},rangeChanged(){this.$emit("rangeChange",{start:this.fromPickerSelectedDate?d(this.fromPickerSelectedDate):null,end:this.toPickerSelectedDate?d(this.toPickerSelectedDate):null})}}});Ci.render=wi;var ki=Ci;function Di(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("DatePicker");return Object(D["openBlock"])(),Object(D["createBlock"])(r,{"selected-date-start":e.selectedDates[0],"selected-date-end":e.selectedDates[1],"persistent-highlighted-date-start":e.committedBetweenHighlightDates[0],"persistent-highlighted-date-end":e.committedBetweenHighlightDates[1],"highlighted-date-start":e.highlightedDates?e.highlightedDates[0]:null,"highlighted-date-end":e.highlightedDates?e.highlightedDates[1]:null,"view-date":e.viewDate,"step-months":"year"===e.period?12:1,"disable-month-dropdown":"year"===e.period,disabled:e.disabled,onCellHover:t[0]||(t[0]=t=>e.onHoverNormalCell(t.date,t.$cell)),onCellHoverLeave:t[1]||(t[1]=t=>e.onHoverLeaveNormalCells()),onDateSelect:t[2]||(t[2]=t=>e.onDateSelected(t.date))},null,8,["selected-date-start","selected-date-end","persistent-highlighted-date-start","persistent-highlighted-date-end","highlighted-date-start","highlighted-date-end","view-date","step-months","disable-month-dropdown","disabled"])} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */const Di=" ",Ei=["custom","previousPeriod","previousYear"],Pi=[{key:"custom",value:a("General_Custom")},{key:"previousPeriod",value:a("General_PreviousPeriod").replace(/\s+/,Di)},{key:"previousYear",value:a("General_PreviousYear").replace(/\s+/,Di)}];function Ti(){return new Date(window.piwik.minDateYear,window.piwik.minDateMonth-1,window.piwik.minDateDay)}function xi(){return new Date(window.piwik.maxDateYear,window.piwik.maxDateMonth-1,window.piwik.maxDateDay)}const Vi="range";function Bi(e){return"[object Date]"===Object.prototype.toString.call(e)&&!Number.isNaN(e.getTime())}function Ni(e){return"day"===e||"week"===e||"month"===e||"year"===e}var Ii=Object(D["defineComponent"])({props:{period:{type:String,required:!0},date:[String,Date],disabled:Boolean},components:{DatePicker:mi},emits:["select"],setup(e,t){const o=Object(D["ref"])(e.date),i=Object(D["ref"])([null,null]),n=Object(D["ref"])([null,null]),a=Object(D["ref"])(null),r=Ti(),s=xi();function l(t){const o=c.get(e.period).parse(t).getDateRange();return o[0]=ro[1]?o[1]:s,o}function d(e,t){if(!e||!t||e.getTime()>=t.getTime())return[null,null];const o=new Date(e);o.setDate(o.getDate()+1);const i=new Date(t);return i.setDate(i.getDate()-1),o.getTime()>i.getTime()?[null,null]:[o,i]}function u(e){if(!e)return void(n.value=[null,null]);const t=l(e);n.value=d(t[0],t[1])}function m(t,o){const i=ts,n=o.hasClass("ui-datepicker-other-month")&&("month"===e.period||"day"===e.period);a.value=i||n?[null,null]:l(t)}function h(){a.value=null}function g(e){t.emit("select",{date:e})}function b(){if(!e.period||!e.date)return i.value=[null,null],n.value=[null,null],a.value=null,void(o.value=null);i.value=l(e.date),u(e.date),a.value=null,o.value=p(e.date)}return Object(D["watch"])(e,b),b(),{selectedDates:i,committedBetweenHighlightDates:n,highlightedDates:a,viewDate:o,onHoverNormalCell:m,onHoverLeaveNormalCells:h,onDateSelected:g}}});Ii.render=ki;var Mi=Ii;const Fi={key:0},Ri=["data-notification-instance-id"],Li={key:1},Ai={class:"notification-body"},_i=["innerHTML"],Hi={key:1};function $i(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createBlock"])(D["Transition"],{name:"toast"===e.type?"slow-fade-out":void 0,onAfterLeave:t[1]||(t[1]=t=>e.toastClosed())},{default:Object(D["withCtx"])(()=>[e.deleted?Object(D["createCommentVNode"])("",!0):(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Fi,[Object(D["createVNode"])(D["Transition"],{name:"toast"===e.type?"toast-slide-up":void 0,appear:""},{default:Object(D["withCtx"])(()=>[Object(D["createElementVNode"])("div",null,[Object(D["createVNode"])(D["Transition"],{name:e.animate?"fade-in":void 0,appear:""},{default:Object(D["withCtx"])(()=>[Object(D["createElementVNode"])("div",{class:Object(D["normalizeClass"])(["notification system",e.cssClasses]),style:Object(D["normalizeStyle"])(e.style),ref:"root","data-notification-instance-id":e.notificationInstanceId},[e.canClose?(Object(D["openBlock"])(),Object(D["createElementBlock"])("button",{key:0,type:"button",class:"close","data-dismiss":"alert",onClick:t[0]||(t[0]=t=>e.closeNotification(t))}," × ")):Object(D["createCommentVNode"])("",!0),e.title?(Object(D["openBlock"])(),Object(D["createElementBlock"])("strong",Li,Object(D["toDisplayString"])(e.title),1)):Object(D["createCommentVNode"])("",!0),Object(D["createElementVNode"])("div",Ai,[e.message?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{key:0,innerHTML:e.$sanitize(e.message)},null,8,_i)):Object(D["createCommentVNode"])("",!0),e.message?Object(D["createCommentVNode"])("",!0):(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Hi,[Object(D["renderSlot"])(e.$slots,"default")]))])],14,Ri)]),_:3},8,["name"])])]),_:3},8,["name"])]))]),_:3},8,["name"])}const{$:Ui}=window;var qi=Object(D["defineComponent"])({props:{notificationId:String,notificationInstanceId:String,title:String,context:String,type:String,noclear:Boolean,toastLength:{type:Number,default:12e3},style:[String,Object],animate:Boolean,message:String,cssClass:String},computed:{cssClasses(){const e={};return this.context&&(e["notification-"+this.context]=!0),this.cssClass&&(e[this.cssClass]=!0),e},canClose(){return"persistent"===this.type||!this.noclear}},emits:["closed"],data(){return{deleted:!1}},mounted(){const e=()=>{setTimeout(()=>{this.deleted=!0},this.toastLength)};"toast"===this.type&&e(),this.style&&Ui(this.$refs.root).css(this.style)},methods:{toastClosed(){Object(D["nextTick"])(()=>{this.$emit("closed")})},closeNotification(e){this.canClose&&e&&e.target&&(this.deleted=!0,Object(D["nextTick"])(()=>{this.$emit("closed")})),this.markNotificationAsRead()},markNotificationAsRead(){this.notificationId&&te.post({module:"CoreHome",action:"markNotificationAsRead"},{notificationId:this.notificationId},{withTokenInUrl:!0})}}});qi.render=$i;var Wi=qi;const zi={class:"notification-group"},Gi=["innerHTML"];function Ki(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("Notification");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",zi,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.notifications,(t,o)=>(Object(D["openBlock"])(),Object(D["createBlock"])(r,{key:t.id||"no-id-"+o,"notification-id":t.id,title:t.title,context:t.context,type:t.type,noclear:t.noclear,"toast-length":t.toastLength,style:Object(D["normalizeStyle"])(t.style),animate:t.animate,message:t.message,"notification-instance-id":t.notificationInstanceId,"css-class":t.class,onClosed:o=>e.removeNotification(t.id)},{default:Object(D["withCtx"])(()=>[Object(D["createElementVNode"])("div",{innerHTML:e.$sanitize(t.message)},null,8,Gi)]),_:2},1032,["notification-id","title","context","type","noclear","toast-length","style","animate","message","notification-instance-id","css-class","onClosed"]))),128))])}function Yi(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} + */const Ei=" ",Pi=["custom","previousPeriod","previousYear"],Ti=[{key:"custom",value:a("General_Custom")},{key:"previousPeriod",value:a("General_PreviousPeriod").replace(/\s+/,Ei)},{key:"previousYear",value:a("General_PreviousYear").replace(/\s+/,Ei)}];function xi(){return new Date(window.piwik.minDateYear,window.piwik.minDateMonth-1,window.piwik.minDateDay)}function Vi(){return new Date(window.piwik.maxDateYear,window.piwik.maxDateMonth-1,window.piwik.maxDateDay)}const Bi="range";function Ni(e){return"[object Date]"===Object.prototype.toString.call(e)&&!Number.isNaN(e.getTime())}function Mi(e){return"day"===e||"week"===e||"month"===e||"year"===e}var Ii=Object(D["defineComponent"])({props:{period:{type:String,required:!0},date:[String,Date],disabled:Boolean},components:{DatePicker:hi},emits:["select"],setup(e,t){const o=Object(D["ref"])(e.date),i=Object(D["ref"])([null,null]),n=Object(D["ref"])([null,null]),a=Object(D["ref"])(null),r=xi(),s=Vi();function l(t){const o=c.get(e.period).parse(t).getDateRange();return o[0]=ro[1]?o[1]:s,o}function d(e,t){if(!e||!t||e.getTime()>=t.getTime())return[null,null];const o=new Date(e);o.setDate(o.getDate()+1);const i=new Date(t);return i.setDate(i.getDate()-1),o.getTime()>i.getTime()?[null,null]:[o,i]}function u(e){if(!e)return void(n.value=[null,null]);const t=l(e);n.value=d(t[0],t[1])}function p(t,o){const i=ts,n=o.hasClass("ui-datepicker-other-month")&&("month"===e.period||"day"===e.period);a.value=i||n?[null,null]:l(t)}function h(){a.value=null}function g(e){t.emit("select",{date:e})}function b(){if(!e.period||!e.date)return i.value=[null,null],n.value=[null,null],a.value=null,void(o.value=null);i.value=l(e.date),u(e.date),a.value=null,o.value=m(e.date)}return Object(D["watch"])(e,b),b(),{selectedDates:i,committedBetweenHighlightDates:n,highlightedDates:a,viewDate:o,onHoverNormalCell:p,onHoverLeaveNormalCells:h,onDateSelected:g}}});Ii.render=Di;var Fi=Ii;const Ri={key:0},Li=["data-notification-instance-id"],Ai={key:1},_i={class:"notification-body"},Hi=["innerHTML"],$i={key:1};function Ui(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createBlock"])(D["Transition"],{name:"toast"===e.type?"slow-fade-out":void 0,onAfterLeave:t[1]||(t[1]=t=>e.toastClosed())},{default:Object(D["withCtx"])(()=>[e.deleted?Object(D["createCommentVNode"])("",!0):(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Ri,[Object(D["createVNode"])(D["Transition"],{name:"toast"===e.type?"toast-slide-up":void 0,appear:""},{default:Object(D["withCtx"])(()=>[Object(D["createElementVNode"])("div",null,[Object(D["createVNode"])(D["Transition"],{name:e.animate?"fade-in":void 0,appear:""},{default:Object(D["withCtx"])(()=>[Object(D["createElementVNode"])("div",{class:Object(D["normalizeClass"])(["notification system",e.cssClasses]),style:Object(D["normalizeStyle"])(e.style),ref:"root","data-notification-instance-id":e.notificationInstanceId},[e.canClose?(Object(D["openBlock"])(),Object(D["createElementBlock"])("button",{key:0,type:"button",class:"close","data-dismiss":"alert",onClick:t[0]||(t[0]=t=>e.closeNotification(t))}," × ")):Object(D["createCommentVNode"])("",!0),e.title?(Object(D["openBlock"])(),Object(D["createElementBlock"])("strong",Ai,Object(D["toDisplayString"])(e.title),1)):Object(D["createCommentVNode"])("",!0),Object(D["createElementVNode"])("div",_i,[e.message?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{key:0,innerHTML:e.$sanitize(e.message)},null,8,Hi)):Object(D["createCommentVNode"])("",!0),e.message?Object(D["createCommentVNode"])("",!0):(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",$i,[Object(D["renderSlot"])(e.$slots,"default")]))])],14,Li)]),_:3},8,["name"])])]),_:3},8,["name"])]))]),_:3},8,["name"])}const{$:qi}=window;var Wi=Object(D["defineComponent"])({props:{notificationId:String,notificationInstanceId:String,title:String,context:String,type:String,noclear:Boolean,toastLength:{type:Number,default:12e3},style:[String,Object],animate:Boolean,message:String,cssClass:String},computed:{cssClasses(){const e={};return this.context&&(e["notification-"+this.context]=!0),this.cssClass&&(e[this.cssClass]=!0),e},canClose(){return"persistent"===this.type||!this.noclear}},emits:["closed"],data(){return{deleted:!1}},mounted(){const e=()=>{setTimeout(()=>{this.deleted=!0},this.toastLength)};"toast"===this.type&&e(),this.style&&qi(this.$refs.root).css(this.style)},methods:{toastClosed(){Object(D["nextTick"])(()=>{this.$emit("closed")})},closeNotification(e){this.canClose&&e&&e.target&&(this.deleted=!0,Object(D["nextTick"])(()=>{this.$emit("closed")})),this.markNotificationAsRead()},markNotificationAsRead(){this.notificationId&&te.post({module:"CoreHome",action:"markNotificationAsRead"},{notificationId:this.notificationId},{withTokenInUrl:!0})}}});Wi.render=Ui;var zi=Wi;const Gi={class:"notification-group"},Ki=["innerHTML"];function Yi(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("Notification");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Gi,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.notifications,(t,o)=>(Object(D["openBlock"])(),Object(D["createBlock"])(r,{key:t.id||"no-id-"+o,"notification-id":t.id,title:t.title,context:t.context,type:t.type,noclear:t.noclear,"toast-length":t.toastLength,style:Object(D["normalizeStyle"])(t.style),animate:t.animate,message:t.message,"notification-instance-id":t.notificationInstanceId,"css-class":t.class,onClosed:o=>e.removeNotification(t.id)},{default:Object(D["withCtx"])(()=>[Object(D["createElementVNode"])("div",{innerHTML:e.$sanitize(t.message)},null,8,Ki)]),_:2},1032,["notification-id","title","context","type","noclear","toast-length","style","animate","message","notification-instance-id","css-class","onClosed"]))),128))])}function Qi(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */const{$:Qi}=window;class Ji{constructor(){Yi(this,"privateState",Object(D["reactive"])({notifications:[]})),Yi(this,"nextNotificationId",0)}get state(){return Object(D["readonly"])(this.privateState)}appendNotification(e){this.checkMessage(e.message),e.id&&this.remove(e.id),this.privateState.notifications.push(e)}prependNotification(e){this.checkMessage(e.message),e.id&&this.remove(e.id),this.privateState.notifications.unshift(e)}remove(e){this.privateState.notifications=this.privateState.notifications.filter(t=>t.id!==e)}parseNotificationDivs(){const e=Qi('[data-role="notification"]'),t=[];e.each((o,i)=>{const n=Qi(i),a=n.data(),r=n.html();r&&t.push(Object.assign(Object.assign({},a),{},{message:r,animate:!1})),e.remove()}),t.forEach(e=>this.show(e))}clearTransientNotifications(){this.privateState.notifications=this.privateState.notifications.filter(e=>"transient"!==e.type)}show(e){this.checkMessage(e.message);let t=e.prepend?this.prependNotification:this.appendNotification,o="#notificationContainer";if(e.placeat)o=e.placeat;else{const e=".modal.open .modal-content",i=document.querySelector(e);i&&(i.querySelector("#modalNotificationContainer")||Qi(i).prepend('
'),o=e+" #modalNotificationContainer",t=this.prependNotification)}const i=e.group||(o?o.toString():"");this.initializeNotificationContainer(o,i);const n=(this.nextNotificationId+=1).toString();return t.call(this,Object.assign(Object.assign({},e),{},{noclear:!!e.noclear,group:i,notificationId:e.id,notificationInstanceId:n,type:e.type||"transient"})),n}scrollToNotification(e){setTimeout(()=>{const t=document.querySelector(`[data-notification-instance-id='${e}']`);t&&M.helper.lazyScrollTo(t,250)})}toast(e){this.checkMessage(e.message);const t=e.placeat?Qi(e.placeat):void 0;if(!t||!t.length)throw new Error("A valid selector is required for the placeat option when using Notification.toast().");const o=document.createElement("div");o.style.position="absolute",o.style.top=t.offset().top+"px",o.style.left=t.offset().left+"px",o.style.zIndex="1000",document.body.appendChild(o);const i=ve({render:()=>Object(D["createVNode"])(Wi,Object.assign(Object.assign({},e),{},{notificationId:e.id,type:"toast",onClosed:()=>{i.unmount()}}))});i.mount(o)}initializeNotificationContainer(e,t){if(!e)return;const o=Qi(e);if(o.children(".notification-group").length)return;const i=window.CoreHome.NotificationGroup,n=ve({template:'',data:()=>({group:t})});n.component("NotificationGroup",i),n.mount(o[0])}checkMessage(e){if(!e)throw new Error("No message given, cannot display notification")}}const Xi=new Ji;var Zi=Xi;Qi(()=>Xi.parseNotificationDivs());var en=Object(D["defineComponent"])({props:{group:String},components:{Notification:Wi},computed:{notifications(){return Zi.state.notifications.filter(e=>this.group?this.group===e.group:!e.group)}},methods:{removeNotification(e){Zi.remove(e)}}});en.render=Ki;var tn=en; + */const{$:Ji}=window;class Xi{constructor(){Qi(this,"privateState",Object(D["reactive"])({notifications:[]})),Qi(this,"nextNotificationId",0)}get state(){return Object(D["readonly"])(this.privateState)}appendNotification(e){this.checkMessage(e.message),e.id&&this.remove(e.id),this.privateState.notifications.push(e)}prependNotification(e){this.checkMessage(e.message),e.id&&this.remove(e.id),this.privateState.notifications.unshift(e)}remove(e){this.privateState.notifications=this.privateState.notifications.filter(t=>t.id!==e)}parseNotificationDivs(){const e=Ji('[data-role="notification"]'),t=[];e.each((o,i)=>{const n=Ji(i),a=n.data(),r=n.html();r&&t.push(Object.assign(Object.assign({},a),{},{message:r,animate:!1})),e.remove()}),t.forEach(e=>this.show(e))}clearTransientNotifications(){this.privateState.notifications=this.privateState.notifications.filter(e=>"transient"!==e.type)}show(e){this.checkMessage(e.message);let t=e.prepend?this.prependNotification:this.appendNotification,o="#notificationContainer";if(e.placeat)o=e.placeat;else{const e=".modal.open .modal-content",i=document.querySelector(e);i&&(i.querySelector("#modalNotificationContainer")||Ji(i).prepend('
'),o=e+" #modalNotificationContainer",t=this.prependNotification)}const i=e.group||(o?o.toString():"");this.initializeNotificationContainer(o,i);const n=(this.nextNotificationId+=1).toString();return t.call(this,Object.assign(Object.assign({},e),{},{noclear:!!e.noclear,group:i,notificationId:e.id,notificationInstanceId:n,type:e.type||"transient"})),n}scrollToNotification(e){setTimeout(()=>{const t=document.querySelector(`[data-notification-instance-id='${e}']`);t&&I.helper.lazyScrollTo(t,250)})}toast(e){this.checkMessage(e.message);const t=e.placeat?Ji(e.placeat):void 0;if(!t||!t.length)throw new Error("A valid selector is required for the placeat option when using Notification.toast().");const o=document.createElement("div");o.style.position="absolute",o.style.top=t.offset().top+"px",o.style.left=t.offset().left+"px",o.style.zIndex="1000",document.body.appendChild(o);const i=ve({render:()=>Object(D["createVNode"])(zi,Object.assign(Object.assign({},e),{},{notificationId:e.id,type:"toast",onClosed:()=>{i.unmount()}}))});i.mount(o)}initializeNotificationContainer(e,t){if(!e)return;const o=Ji(e);if(o.children(".notification-group").length)return;const i=window.CoreHome.NotificationGroup,n=ve({template:'',data:()=>({group:t})});n.component("NotificationGroup",i),n.mount(o[0])}checkMessage(e){if(!e)throw new Error("No message given, cannot display notification")}}const Zi=new Xi;var en=Zi;Ji(()=>Zi.parseNotificationDivs());var tn=Object(D["defineComponent"])({props:{group:String},components:{Notification:zi},computed:{notifications(){return en.state.notifications.filter(e=>this.group?this.group===e.group:!e.group)}},methods:{removeNotification(e){en.remove(e)}}});tn.render=Yi;var on=tn; /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */const on=Object(D["createElementVNode"])("span",{class:"icon-help"},null,-1),nn=[on];function an(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{class:"item-help-icon",tabindex:"5",href:"javascript:",onClick:t[0]||(t[0]=(...t)=>e.showHelp&&e.showHelp(...t))},nn)}const rn="reportingMenu-help";var sn=Object(D["defineComponent"])({props:{message:{type:String,required:!0},name:{type:String,required:!0}},data(){return{currentName:""}},methods:{showHelp(){if(""!==this.currentName)return Zi.remove(rn),void(this.currentName="");Zi.show({context:"info",id:rn,type:"help",noclear:!0,class:"help-notification",message:this.message,placeat:"#notificationContainer",prepend:!0}),""!==this.name&&(this.currentName=this.name)}}});sn.render=an;var ln=sn;function cn(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} + */const nn=Object(D["createElementVNode"])("span",{class:"icon-help"},null,-1),an=[nn];function rn(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{class:"item-help-icon",tabindex:"5",href:"javascript:",onClick:t[0]||(t[0]=(...t)=>e.showHelp&&e.showHelp(...t))},an)}const sn="reportingMenu-help";var ln=Object(D["defineComponent"])({props:{message:{type:String,required:!0},name:{type:String,required:!0}},data(){return{currentName:""}},methods:{showHelp(){if(""!==this.currentName)return en.remove(sn),void(this.currentName="");en.show({context:"info",id:sn,type:"help",noclear:!0,class:"help-notification",message:this.message,placeat:"#notificationContainer",prepend:!0}),""!==this.name&&(this.currentName=this.name)}}});ln.render=rn;var cn=ln;function dn(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */class dn{constructor(){cn(this,"state",Object(D["reactive"])({initialSites:[],isInitialized:!1})),cn(this,"stateFiltered",Object(D["reactive"])({initialSites:[],isInitialized:!1,excludedSites:[],onlySitesWithAdminAccess:!1,onlySitesWithAtLeastWriteAccess:!1,siteTypesToExclude:[]})),cn(this,"currentRequestAbort",null),cn(this,"limitRequest",void 0),cn(this,"initialSites",Object(D["computed"])(()=>Object(D["readonly"])(this.state.initialSites))),cn(this,"initialSitesFiltered",Object(D["computed"])(()=>Object(D["readonly"])(this.stateFiltered.initialSites)))}isFiltered(e=!1,t=[],o=!1,i=[]){return t.length>0||e||o||i.length>0}matchesCurrentFilteredState(e=!1,t=[],o=!1,i=[]){return!this.stateFiltered.isInitialized&&!this.isFiltered(e,t,o,i)||this.stateFiltered.isInitialized&&t.length===this.stateFiltered.excludedSites.length&&t.every((e,t)=>e===this.stateFiltered.excludedSites[t])&&e===this.stateFiltered.onlySitesWithAdminAccess&&o===this.stateFiltered.onlySitesWithAtLeastWriteAccess&&i.length===this.stateFiltered.siteTypesToExclude.length&&i.every((e,t)=>e===this.stateFiltered.siteTypesToExclude[t])}loadInitialSites(e=!1,t=[],o=!1,i=[]){return this.state.isInitialized&&!this.isFiltered(e,t,o,i)?Promise.resolve(Object(D["readonly"])(this.state.initialSites)):this.stateFiltered.isInitialized&&this.matchesCurrentFilteredState(e,t,o,i)?Promise.resolve(Object(D["readonly"])(this.stateFiltered.initialSites)):this.isFiltered(e,t,o,i)?this.searchSite("%",e,t,o,i).then(n=>(this.stateFiltered.isInitialized=!0,this.stateFiltered.excludedSites=t,this.stateFiltered.onlySitesWithAdminAccess=e,this.stateFiltered.onlySitesWithAtLeastWriteAccess=o,this.stateFiltered.siteTypesToExclude=i,null!==n&&(this.stateFiltered.initialSites=n),n)):this.state.isInitialized?Promise.resolve(Object(D["readonly"])(this.state.initialSites)):this.searchSite("%",e,t,o,i).then(e=>(this.state.isInitialized=!0,null!==e&&(this.state.initialSites=e),e))}loadSite(e){"all"===e?U.updateUrl(Object.assign(Object.assign({},U.urlParsed.value),{},{module:"MultiSites",action:"index",date:U.parsed.value.date,period:U.parsed.value.period})):U.updateUrl(Object.assign(Object.assign({},U.urlParsed.value),{},{segment:"",idSite:e}),Object.assign(Object.assign({},U.hashParsed.value),{},{segment:"",idSite:e}))}searchSite(e,t=!1,o=[],i=!1,n=[]){return e?(this.currentRequestAbort&&this.currentRequestAbort.abort(),this.limitRequest||(this.limitRequest=te.fetch({method:"SitesManager.getNumWebsitesToDisplayPerPage"})),this.limitRequest.then(a=>{const r=a.value;let s="view";return t?s="admin":i&&(s="write"),this.currentRequestAbort=new AbortController,te.fetch({method:"SitesManager.getSitesWithMinimumAccess",permission:s,limit:r,pattern:e,sitesToExclude:o,siteTypesToExclude:n},{abortController:this.currentRequestAbort,abortable:!1})}).then(e=>e?this.processWebsitesList(e):null).finally(()=>{this.currentRequestAbort=null})):this.loadInitialSites(t,o,i,n)}processWebsitesList(e){let t=e;return t&&t.length?(t=t.map(e=>Object.assign(Object.assign({},e),{},{name:e.group?`[${e.group}] ${e.name}`:e.name})),t.sort((e,t)=>e.name.toLowerCase()t.name.toLowerCase()?1:0),t):[]}}var un=new dn;const pn=["value","name"],mn=["title"],hn=["textContent"],gn={key:1,class:"placeholder"},bn={class:"dropdown"},fn={class:"custom_select_search"},vn=["placeholder"],On={key:0},yn={class:"custom_select_container"},jn=["onClick"],wn=["innerHTML","href","title"],Sn={class:"custom_select_ul_list"},Cn={class:"noresult"},kn={key:1};function Dn(e,t,o,i,n,a){var r,s,l,c;const d=Object(D["resolveComponent"])("AllSitesLink"),u=Object(D["resolveDirective"])("tooltips"),p=Object(D["resolveDirective"])("focus-if"),m=Object(D["resolveDirective"])("focus-anywhere-but-here");return Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{class:Object(D["normalizeClass"])(["siteSelector piwikSelector borderedControl",{expanded:e.showSitesList,disabled:!e.hasMultipleSites}])},[e.name?(Object(D["openBlock"])(),Object(D["createElementBlock"])("input",{key:0,type:"hidden",value:null===(r=e.displayedModelValue)||void 0===r?void 0:r.id,name:e.name},null,8,pn)):Object(D["createCommentVNode"])("",!0),Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{ref:"selectorLink",onClick:t[0]||(t[0]=(...t)=>e.onClickSelector&&e.onClickSelector(...t)),onKeydown:t[1]||(t[1]=t=>e.onPressEnter(t)),href:"javascript:void(0)",class:Object(D["normalizeClass"])([{loading:e.isLoading},"title"]),tabindex:"4",title:e.selectorLinkTitle},[Object(D["createElementVNode"])("span",null,[null!==(s=e.displayedModelValue)&&void 0!==s&&s.name||!e.placeholder?(Object(D["openBlock"])(),Object(D["createElementBlock"])("span",{key:0,textContent:Object(D["toDisplayString"])((null===(l=e.displayedModelValue)||void 0===l?void 0:l.name)||e.firstSiteName)},null,8,hn)):Object(D["createCommentVNode"])("",!0),null!==(c=e.displayedModelValue)&&void 0!==c&&c.name||!e.placeholder?Object(D["createCommentVNode"])("",!0):(Object(D["openBlock"])(),Object(D["createElementBlock"])("span",gn,Object(D["toDisplayString"])(e.placeholder),1))]),Object(D["createElementVNode"])("span",{class:Object(D["normalizeClass"])(["icon icon-chevron-down",{iconHidden:e.isLoading,collapsed:!e.showSitesList}])},null,2)],42,mn)),[[u]]),Object(D["withDirectives"])(Object(D["createElementVNode"])("div",bn,[Object(D["withDirectives"])(Object(D["createElementVNode"])("div",fn,[Object(D["withDirectives"])(Object(D["createElementVNode"])("input",{type:"text",onClick:t[2]||(t[2]=t=>{e.searchTerm="",e.loadInitialSites()}),"onUpdate:modelValue":t[3]||(t[3]=t=>e.searchTerm=t),tabindex:"4",class:"websiteSearch inp browser-default",placeholder:e.translate("General_Search")},null,8,vn),[[D["vModelText"],e.searchTerm],[p,{focused:e.shouldFocusOnSearch}]]),Object(D["withDirectives"])(Object(D["createElementVNode"])("img",{title:"Clear",onClick:t[4]||(t[4]=t=>{e.searchTerm="",e.loadInitialSites()}),class:"reset",src:"plugins/CoreHome/images/reset_search.png"},null,512),[[D["vShow"],e.searchTerm]])],512),[[D["vShow"],e.autocompleteMinSites<=e.sites.length||e.searchTerm]]),"top"===e.allSitesLocation&&e.showAllSitesItem?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",On,[Object(D["createVNode"])(d,{href:e.urlAllSites,"all-sites-text":e.allSitesText,onClick:t[5]||(t[5]=t=>e.onAllSitesClick(t))},null,8,["href","all-sites-text"])])):Object(D["createCommentVNode"])("",!0),Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("div",yn,[Object(D["createElementVNode"])("ul",{class:"custom_select_ul_list",onClick:t[7]||(t[7]=t=>e.showSitesList=!1)},[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.sites,(o,i)=>Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{onClick:t=>e.switchSite(Object.assign(Object.assign({},o),{},{id:o.idsite}),t),key:i},[Object(D["createElementVNode"])("a",{onClick:t[6]||(t[6]=e=>e.preventDefault()),innerHTML:e.$sanitize(e.getMatchedSiteName(o.name)),tabindex:"4",href:e.getUrlForSiteId(o.idsite),title:o.name},null,8,wn)],8,jn)),[[D["vShow"],!(!e.showSelectedSite&&""+e.activeSiteId===""+o.idsite)]])),128))]),Object(D["withDirectives"])(Object(D["createElementVNode"])("ul",Sn,[Object(D["createElementVNode"])("li",null,[Object(D["createElementVNode"])("div",Cn,Object(D["toDisplayString"])(e.translate("SitesManager_NotFound")+" "+e.searchTerm),1)])],512),[[D["vShow"],!e.sites.length&&e.searchTerm]])])),[[u,{content:e.tooltipContent}]]),"bottom"===e.allSitesLocation&&e.showAllSitesItem?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",kn,[Object(D["createVNode"])(d,{href:e.urlAllSites,"all-sites-text":e.allSitesText,onClick:t[8]||(t[8]=t=>e.onAllSitesClick(t))},null,8,["href","all-sites-text"])])):Object(D["createCommentVNode"])("",!0)],512),[[D["vShow"],e.showSitesList]])],2)),[[m,{blur:e.onBlur}]])}const En=["innerHTML","href"];function Pn(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{onClick:t[1]||(t[1]=e=>this.onClick(e)),class:"custom_select_all"},[Object(D["createElementVNode"])("a",{onClick:t[0]||(t[0]=e=>e.preventDefault()),innerHTML:e.$sanitize(e.allSitesText),tabindex:"4",href:e.href},null,8,En)])}var Tn=Object(D["defineComponent"])({props:{href:String,allSitesText:String},emits:["click"],methods:{onClick(e){this.$emit("click",e)}}});Tn.render=Pn;var xn=Tn,Vn=Object(D["defineComponent"])({props:{modelValue:Object,showSelectedSite:{type:Boolean,default:!1},showAllSitesItem:{type:Boolean,default:!0},switchSiteOnSelect:{type:Boolean,default:!0},onlySitesWithAdminAccess:{type:Boolean,default:!1},name:{type:String,default:""},allSitesText:{type:String,default:a("General_MultiSitesSummary")},allSitesLocation:{type:String,default:"bottom"},placeholder:String,defaultToFirstSite:Boolean,sitesToExclude:{type:Array,default:()=>[]},onlySitesWithAtLeastWriteAccess:{type:Boolean,default:!1},siteTypesToExclude:{type:Array,default:()=>[]}},emits:["update:modelValue","blur"],components:{AllSitesLink:xn},directives:{FocusAnywhereButHere:tt,FocusIf:it,Tooltips:ct},watch:{searchTerm(){this.onSearchTermChanged()}},data(){return{searchTerm:"",activeSiteId:""+M.idSite,showSitesList:!1,isLoading:!1,sites:[],autocompleteMinSites:parseInt(M.config.autocomplete_min_sites,10)}},created(){this.searchSite=Pe(this.searchSite),!this.modelValue&&M.idSite&&this.$emit("update:modelValue",{id:M.idSite,name:M.helper.htmlDecode(M.siteName)})},mounted(){window.initTopControls(),this.loadInitialSites().then(()=>{this.shouldDefaultToFirstSite&&this.$emit("update:modelValue",{id:this.sites[0].idsite,name:this.sites[0].name})});const e=a("CoreHome_ShortcutWebsiteSelector");M.helper.registerShortcut("w",e,e=>{if(e.altKey)return;e.preventDefault?e.preventDefault():e.returnValue=!1;const t=this.$refs.selectorLink;t&&(t.click(),t.focus())})},computed:{shouldFocusOnSearch(){return this.showSitesList&&this.autocompleteMinSites<=this.sites.length||this.searchTerm},selectorLinkTitle(){return this.hasMultipleSites&&this.displayedModelValue?a("CoreHome_ChangeCurrentWebsite",this.htmlEntities(this.displayedModelValue.name)):""},hasMultipleSites(){const e=un.matchesCurrentFilteredState(this.onlySitesWithAdminAccess,this.sitesToExclude?this.sitesToExclude:[],this.onlySitesWithAtLeastWriteAccess,this.siteTypesToExclude?this.siteTypesToExclude:[])&&un.initialSitesFiltered.value&&un.initialSitesFiltered.value.length?un.initialSitesFiltered.value:un.initialSites.value;return e&&e.length>1},firstSiteName(){const e=un.initialSitesFiltered.value&&un.initialSitesFiltered.value.length?un.initialSitesFiltered.value:un.initialSites.value;return e&&e.length>0?e[0].name:""},urlAllSites(){const e=U.stringify(Object.assign(Object.assign({},U.urlParsed.value),{},{module:"MultiSites",action:"index",date:U.parsed.value.date,period:U.parsed.value.period}));return"?"+e},shouldDefaultToFirstSite(){var e;return!(null!==(e=this.modelValue)&&void 0!==e&&e.id)&&(!this.hasMultipleSites||this.defaultToFirstSite)&&this.sites[0]},displayedModelValue(){return this.modelValue?this.modelValue:M.idSite?{id:M.idSite,name:M.helper.htmlDecode(M.siteName)}:this.shouldDefaultToFirstSite?{id:this.sites[0].idsite,name:this.sites[0].name}:null},tooltipContent(){return function(){const e=$(this).attr("title")||"";return M.helper.htmlEntities(e)}}},methods:{onSearchTermChanged(){this.searchTerm?(this.isLoading=!0,this.searchSite(this.searchTerm)):(this.isLoading=!1,this.loadInitialSites())},onAllSitesClick(e){this.switchSite({id:"all",name:this.$props.allSitesText},e),this.showSitesList=!1},switchSite(e,t){const o=-1!==navigator.userAgent.indexOf("Mac OS X")?t.metaKey:t.ctrlKey;t&&o&&t.target&&t.target.href?window.open(t.target.href,"_blank"):(this.$emit("update:modelValue",{id:e.id,name:e.name}),this.switchSiteOnSelect&&this.activeSiteId!==e.id&&un.loadSite(e.id))},onBlur(){this.showSitesList=!1,this.$emit("blur")},onClickSelector(){this.hasMultipleSites&&(this.showSitesList=!this.showSitesList,this.isLoading||this.searchTerm||this.loadInitialSites())},onPressEnter(e){"Enter"===e.key&&(e.preventDefault(),this.showSitesList=!this.showSitesList,this.showSitesList&&!this.isLoading&&this.loadInitialSites())},getMatchedSiteName(e){const t=e.toUpperCase().indexOf(this.searchTerm.toUpperCase());if(-1===t||this.isLoading)return this.htmlEntities(e);const o=this.htmlEntities(e.substring(0,t)),i=this.htmlEntities(e.substring(t+this.searchTerm.length));return`${o}${this.searchTerm}${i}`},loadInitialSites(){return un.loadInitialSites(this.onlySitesWithAdminAccess,this.sitesToExclude?this.sitesToExclude:[],this.onlySitesWithAtLeastWriteAccess,this.siteTypesToExclude?this.siteTypesToExclude:[]).then(e=>{this.sites=e||[]})},searchSite(e){this.isLoading=!0,un.searchSite(e,this.onlySitesWithAdminAccess,this.sitesToExclude?this.sitesToExclude:[],this.onlySitesWithAtLeastWriteAccess,this.siteTypesToExclude?this.siteTypesToExclude:[]).then(t=>{e===this.searchTerm&&t&&(this.sites=t)}).finally(()=>{this.isLoading=!1})},getUrlForSiteId(e){const t=U.stringify(Object.assign(Object.assign({},U.urlParsed.value),{},{segment:"",idSite:e})),o=U.stringify(Object.assign(Object.assign({},U.hashParsed.value),{},{segment:"",idSite:e}));return`?${t}#?${o}`},htmlEntities(e){return M.helper.htmlEntities(e)}}});Vn.render=Dn;var Bn=Vn;const Nn={ref:"root",class:"quickAccessInside"},In=["title","placeholder"],Mn={class:"dropdown quickAccessDropdown"},Fn={class:"no-result"},Rn=["onClick"],Ln=["onMouseenter","onClick"],An={class:"quickAccessMatomoSearch"},_n=["onMouseenter","onClick"],Hn=["textContent"],$n={class:"quick-access-category helpCategory"},Un=["href"];function qn(e,t,o,i,n,a){const r=Object(D["resolveDirective"])("focus-if"),s=Object(D["resolveDirective"])("tooltips"),l=Object(D["resolveDirective"])("focus-anywhere-but-here");return Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Nn,[Object(D["createElementVNode"])("span",{class:"icon-search",onMouseenter:t[0]||(t[0]=t=>e.searchActive=!0)},null,32),Object(D["withDirectives"])(Object(D["createElementVNode"])("input",{class:"quickAccessInput browser-default",onKeydown:t[1]||(t[1]=t=>e.onKeypress(t)),onFocus:t[2]||(t[2]=t=>e.searchActive=!0),"onUpdate:modelValue":t[3]||(t[3]=t=>e.searchTerm=t),type:"text",tabindex:"5",title:e.quickAccessTitle,placeholder:e.translate("General_Search"),ref:"input"},null,40,In),[[D["vModelText"],e.searchTerm],[r,{focused:e.searchActive}],[s]]),Object(D["withDirectives"])(Object(D["createElementVNode"])("div",Mn,[Object(D["withDirectives"])(Object(D["createElementVNode"])("ul",null,[Object(D["createElementVNode"])("li",Fn,Object(D["toDisplayString"])(e.translate("General_SearchNoResults")),1)],512),[[D["vShow"],!(e.numMenuItems>0||e.sites.length)]]),(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.menuItems,t=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("ul",{key:t.title},[Object(D["createElementVNode"])("li",{class:"quick-access-category",onClick:o=>{e.searchTerm=t.title,e.searchMenu(e.searchTerm)}},Object(D["toDisplayString"])(t.title),9,Rn),(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(t.items,t=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{class:Object(D["normalizeClass"])(["result",{selected:t.menuIndex===e.searchIndex}]),onMouseenter:o=>e.searchIndex=t.menuIndex,onClick:o=>e.selectMenuItem(t),key:t.index},[Object(D["createElementVNode"])("a",null,Object(D["toDisplayString"])(t.name.trim()),1)],42,Ln))),128))]))),128)),Object(D["createElementVNode"])("ul",An,[Object(D["withDirectives"])(Object(D["createElementVNode"])("li",{class:"quick-access-category websiteCategory"},Object(D["toDisplayString"])(e.translate("SitesManager_Sites")),513),[[D["vShow"],e.hasSitesSelector&&e.sites.length||e.isLoading]]),Object(D["withDirectives"])(Object(D["createElementVNode"])("li",{class:"no-result"},Object(D["toDisplayString"])(e.translate("MultiSites_LoadingWebsites")),513),[[D["vShow"],e.hasSitesSelector&&e.isLoading]]),(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.sites,(t,o)=>Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{class:Object(D["normalizeClass"])(["result",{selected:e.numMenuItems+o===e.searchIndex}]),onMouseenter:t=>e.searchIndex=e.numMenuItems+o,onClick:o=>e.selectSite(t.idsite),key:t.idsite},[Object(D["createElementVNode"])("a",{textContent:Object(D["toDisplayString"])(t.name)},null,8,Hn)],42,_n)),[[D["vShow"],e.hasSitesSelector&&!e.isLoading]])),128))]),Object(D["createElementVNode"])("ul",null,[Object(D["createElementVNode"])("li",$n,Object(D["toDisplayString"])(e.translate("General_HelpResources")),1),Object(D["createElementVNode"])("li",{class:Object(D["normalizeClass"])([{selected:"help"===e.searchIndex},"quick-access-help"]),onMouseenter:t[4]||(t[4]=t=>e.searchIndex="help")},[Object(D["createElementVNode"])("a",{href:"https://matomo.org?mtm_campaign=App_Help&mtm_source=Matomo_App&mtm_keyword=QuickSearch&s="+encodeURIComponent(e.searchTerm),target:"_blank"},Object(D["toDisplayString"])(e.translate("CoreHome_SearchOnMatomo",e.searchTerm)),9,Un)],34)])],512),[[D["vShow"],e.searchTerm&&e.searchActive]])])),[[l,{blur:e.onBlur}]])}function Wn(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} + */class un{constructor(){dn(this,"state",Object(D["reactive"])({initialSites:[],isInitialized:!1})),dn(this,"stateFiltered",Object(D["reactive"])({initialSites:[],isInitialized:!1,excludedSites:[],onlySitesWithAdminAccess:!1,onlySitesWithAtLeastWriteAccess:!1,siteTypesToExclude:[]})),dn(this,"currentRequestAbort",null),dn(this,"limitRequest",void 0),dn(this,"initialSites",Object(D["computed"])(()=>Object(D["readonly"])(this.state.initialSites))),dn(this,"initialSitesFiltered",Object(D["computed"])(()=>Object(D["readonly"])(this.stateFiltered.initialSites)))}isFiltered(e=!1,t=[],o=!1,i=[]){return t.length>0||e||o||i.length>0}matchesCurrentFilteredState(e=!1,t=[],o=!1,i=[]){return!this.stateFiltered.isInitialized&&!this.isFiltered(e,t,o,i)||this.stateFiltered.isInitialized&&t.length===this.stateFiltered.excludedSites.length&&t.every((e,t)=>e===this.stateFiltered.excludedSites[t])&&e===this.stateFiltered.onlySitesWithAdminAccess&&o===this.stateFiltered.onlySitesWithAtLeastWriteAccess&&i.length===this.stateFiltered.siteTypesToExclude.length&&i.every((e,t)=>e===this.stateFiltered.siteTypesToExclude[t])}loadInitialSites(e=!1,t=[],o=!1,i=[]){return this.state.isInitialized&&!this.isFiltered(e,t,o,i)?Promise.resolve(Object(D["readonly"])(this.state.initialSites)):this.stateFiltered.isInitialized&&this.matchesCurrentFilteredState(e,t,o,i)?Promise.resolve(Object(D["readonly"])(this.stateFiltered.initialSites)):this.isFiltered(e,t,o,i)?this.searchSite("%",e,t,o,i).then(n=>(this.stateFiltered.isInitialized=!0,this.stateFiltered.excludedSites=t,this.stateFiltered.onlySitesWithAdminAccess=e,this.stateFiltered.onlySitesWithAtLeastWriteAccess=o,this.stateFiltered.siteTypesToExclude=i,null!==n&&(this.stateFiltered.initialSites=n),n)):this.state.isInitialized?Promise.resolve(Object(D["readonly"])(this.state.initialSites)):this.searchSite("%",e,t,o,i).then(e=>(this.state.isInitialized=!0,null!==e&&(this.state.initialSites=e),e))}loadSite(e){"all"===e?U.updateUrl(Object.assign(Object.assign({},U.urlParsed.value),{},{module:"MultiSites",action:"index",date:U.parsed.value.date,period:U.parsed.value.period})):U.updateUrl(Object.assign(Object.assign({},U.urlParsed.value),{},{segment:"",idSite:e}),Object.assign(Object.assign({},U.hashParsed.value),{},{segment:"",idSite:e}))}searchSite(e,t=!1,o=[],i=!1,n=[]){return e?(this.currentRequestAbort&&this.currentRequestAbort.abort(),this.limitRequest||(this.limitRequest=te.fetch({method:"SitesManager.getNumWebsitesToDisplayPerPage"})),this.limitRequest.then(a=>{const r=a.value;let s="view";return t?s="admin":i&&(s="write"),this.currentRequestAbort=new AbortController,te.fetch({method:"SitesManager.getSitesWithMinimumAccess",permission:s,limit:r,pattern:e,sitesToExclude:o,siteTypesToExclude:n},{abortController:this.currentRequestAbort,abortable:!1})}).then(e=>e?this.processWebsitesList(e):null).finally(()=>{this.currentRequestAbort=null})):this.loadInitialSites(t,o,i,n)}processWebsitesList(e){let t=e;return t&&t.length?(t=t.map(e=>Object.assign(Object.assign({},e),{},{name:e.group?`[${e.group}] ${e.name}`:e.name})),t.sort((e,t)=>e.name.toLowerCase()t.name.toLowerCase()?1:0),t):[]}}var mn=new un;const pn=["value","name"],hn=["title"],gn=["textContent"],bn={key:1,class:"placeholder"},fn={class:"dropdown"},vn={class:"custom_select_search"},On=["placeholder"],yn={key:0},jn={class:"custom_select_container"},wn=["onClick"],Sn=["innerHTML","href","title"],Cn={class:"custom_select_ul_list"},kn={class:"noresult"},Dn={key:1};function En(e,t,o,i,n,a){var r,s,l,c;const d=Object(D["resolveComponent"])("AllSitesLink"),u=Object(D["resolveDirective"])("tooltips"),m=Object(D["resolveDirective"])("focus-if"),p=Object(D["resolveDirective"])("focus-anywhere-but-here");return Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{class:Object(D["normalizeClass"])(["siteSelector piwikSelector borderedControl",{expanded:e.showSitesList,disabled:!e.hasMultipleSites}])},[e.name?(Object(D["openBlock"])(),Object(D["createElementBlock"])("input",{key:0,type:"hidden",value:null===(r=e.displayedModelValue)||void 0===r?void 0:r.id,name:e.name},null,8,pn)):Object(D["createCommentVNode"])("",!0),Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{ref:"selectorLink",onClick:t[0]||(t[0]=(...t)=>e.onClickSelector&&e.onClickSelector(...t)),onKeydown:t[1]||(t[1]=t=>e.onPressEnter(t)),href:"javascript:void(0)",class:Object(D["normalizeClass"])([{loading:e.isLoading},"title"]),tabindex:"4",title:e.selectorLinkTitle},[Object(D["createElementVNode"])("span",null,[null!==(s=e.displayedModelValue)&&void 0!==s&&s.name||!e.placeholder?(Object(D["openBlock"])(),Object(D["createElementBlock"])("span",{key:0,textContent:Object(D["toDisplayString"])((null===(l=e.displayedModelValue)||void 0===l?void 0:l.name)||e.firstSiteName)},null,8,gn)):Object(D["createCommentVNode"])("",!0),null!==(c=e.displayedModelValue)&&void 0!==c&&c.name||!e.placeholder?Object(D["createCommentVNode"])("",!0):(Object(D["openBlock"])(),Object(D["createElementBlock"])("span",bn,Object(D["toDisplayString"])(e.placeholder),1))]),Object(D["createElementVNode"])("span",{class:Object(D["normalizeClass"])(["icon icon-chevron-down",{iconHidden:e.isLoading,collapsed:!e.showSitesList}])},null,2)],42,hn)),[[u]]),Object(D["withDirectives"])(Object(D["createElementVNode"])("div",fn,[Object(D["withDirectives"])(Object(D["createElementVNode"])("div",vn,[Object(D["withDirectives"])(Object(D["createElementVNode"])("input",{type:"text",onClick:t[2]||(t[2]=t=>{e.searchTerm="",e.loadInitialSites()}),"onUpdate:modelValue":t[3]||(t[3]=t=>e.searchTerm=t),tabindex:"4",class:"websiteSearch inp browser-default",placeholder:e.translate("General_Search")},null,8,On),[[D["vModelText"],e.searchTerm],[m,{focused:e.shouldFocusOnSearch}]]),Object(D["withDirectives"])(Object(D["createElementVNode"])("img",{title:"Clear",onClick:t[4]||(t[4]=t=>{e.searchTerm="",e.loadInitialSites()}),class:"reset",src:"plugins/CoreHome/images/reset_search.png"},null,512),[[D["vShow"],e.searchTerm]])],512),[[D["vShow"],e.autocompleteMinSites<=e.sites.length||e.searchTerm]]),"top"===e.allSitesLocation&&e.showAllSitesItem?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",yn,[Object(D["createVNode"])(d,{href:e.urlAllSites,"all-sites-text":e.allSitesText,onClick:t[5]||(t[5]=t=>e.onAllSitesClick(t))},null,8,["href","all-sites-text"])])):Object(D["createCommentVNode"])("",!0),Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("div",jn,[Object(D["createElementVNode"])("ul",{class:"custom_select_ul_list",onClick:t[7]||(t[7]=t=>e.showSitesList=!1)},[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.sites,(o,i)=>Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{onClick:t=>e.switchSite(Object.assign(Object.assign({},o),{},{id:o.idsite}),t),key:i},[Object(D["createElementVNode"])("a",{onClick:t[6]||(t[6]=e=>e.preventDefault()),innerHTML:e.$sanitize(e.getMatchedSiteName(o.name)),tabindex:"4",href:e.getUrlForSiteId(o.idsite),title:o.name},null,8,Sn)],8,wn)),[[D["vShow"],!(!e.showSelectedSite&&""+e.activeSiteId===""+o.idsite)]])),128))]),Object(D["withDirectives"])(Object(D["createElementVNode"])("ul",Cn,[Object(D["createElementVNode"])("li",null,[Object(D["createElementVNode"])("div",kn,Object(D["toDisplayString"])(e.translate("SitesManager_NotFound")+" "+e.searchTerm),1)])],512),[[D["vShow"],!e.sites.length&&e.searchTerm]])])),[[u,{content:e.tooltipContent}]]),"bottom"===e.allSitesLocation&&e.showAllSitesItem?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Dn,[Object(D["createVNode"])(d,{href:e.urlAllSites,"all-sites-text":e.allSitesText,onClick:t[8]||(t[8]=t=>e.onAllSitesClick(t))},null,8,["href","all-sites-text"])])):Object(D["createCommentVNode"])("",!0)],512),[[D["vShow"],e.showSitesList]])],2)),[[p,{blur:e.onBlur}]])}const Pn=["innerHTML","href"];function Tn(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{onClick:t[1]||(t[1]=e=>this.onClick(e)),class:"custom_select_all"},[Object(D["createElementVNode"])("a",{onClick:t[0]||(t[0]=e=>e.preventDefault()),innerHTML:e.$sanitize(e.allSitesText),tabindex:"4",href:e.href},null,8,Pn)])}var xn=Object(D["defineComponent"])({props:{href:String,allSitesText:String},emits:["click"],methods:{onClick(e){this.$emit("click",e)}}});xn.render=Tn;var Vn=xn,Bn=Object(D["defineComponent"])({props:{modelValue:Object,showSelectedSite:{type:Boolean,default:!1},showAllSitesItem:{type:Boolean,default:!0},switchSiteOnSelect:{type:Boolean,default:!0},onlySitesWithAdminAccess:{type:Boolean,default:!1},name:{type:String,default:""},allSitesText:{type:String,default:a("General_MultiSitesSummary")},allSitesLocation:{type:String,default:"bottom"},placeholder:String,defaultToFirstSite:Boolean,sitesToExclude:{type:Array,default:()=>[]},onlySitesWithAtLeastWriteAccess:{type:Boolean,default:!1},siteTypesToExclude:{type:Array,default:()=>[]}},emits:["update:modelValue","blur"],components:{AllSitesLink:Vn},directives:{FocusAnywhereButHere:ot,FocusIf:nt,Tooltips:dt},watch:{searchTerm(){this.onSearchTermChanged()}},data(){return{searchTerm:"",activeSiteId:""+I.idSite,showSitesList:!1,isLoading:!1,sites:[],autocompleteMinSites:parseInt(I.config.autocomplete_min_sites,10)}},created(){this.searchSite=Pe(this.searchSite),!this.modelValue&&I.idSite&&this.$emit("update:modelValue",{id:I.idSite,name:I.helper.htmlDecode(I.siteName)})},mounted(){window.initTopControls(),this.loadInitialSites().then(()=>{this.shouldDefaultToFirstSite&&this.$emit("update:modelValue",{id:this.sites[0].idsite,name:this.sites[0].name})});const e=a("CoreHome_ShortcutWebsiteSelector");I.helper.registerShortcut("w",e,e=>{if(e.altKey)return;e.preventDefault?e.preventDefault():e.returnValue=!1;const t=this.$refs.selectorLink;t&&(t.click(),t.focus())})},computed:{shouldFocusOnSearch(){return this.showSitesList&&this.autocompleteMinSites<=this.sites.length||this.searchTerm},selectorLinkTitle(){return this.hasMultipleSites&&this.displayedModelValue?a("CoreHome_ChangeCurrentWebsite",this.htmlEntities(this.displayedModelValue.name)):""},hasMultipleSites(){const e=mn.matchesCurrentFilteredState(this.onlySitesWithAdminAccess,this.sitesToExclude?this.sitesToExclude:[],this.onlySitesWithAtLeastWriteAccess,this.siteTypesToExclude?this.siteTypesToExclude:[])&&mn.initialSitesFiltered.value&&mn.initialSitesFiltered.value.length?mn.initialSitesFiltered.value:mn.initialSites.value;return e&&e.length>1},firstSiteName(){const e=mn.initialSitesFiltered.value&&mn.initialSitesFiltered.value.length?mn.initialSitesFiltered.value:mn.initialSites.value;return e&&e.length>0?e[0].name:""},urlAllSites(){const e=U.stringify(Object.assign(Object.assign({},U.urlParsed.value),{},{module:"MultiSites",action:"index",date:U.parsed.value.date,period:U.parsed.value.period}));return"?"+e},shouldDefaultToFirstSite(){var e;return!(null!==(e=this.modelValue)&&void 0!==e&&e.id)&&(!this.hasMultipleSites||this.defaultToFirstSite)&&this.sites[0]},displayedModelValue(){return this.modelValue?this.modelValue:I.idSite?{id:I.idSite,name:I.helper.htmlDecode(I.siteName)}:this.shouldDefaultToFirstSite?{id:this.sites[0].idsite,name:this.sites[0].name}:null},tooltipContent(){return function(){const e=$(this).attr("title")||"";return I.helper.htmlEntities(e)}}},methods:{onSearchTermChanged(){this.searchTerm?(this.isLoading=!0,this.searchSite(this.searchTerm)):(this.isLoading=!1,this.loadInitialSites())},onAllSitesClick(e){this.switchSite({id:"all",name:this.$props.allSitesText},e),this.showSitesList=!1},switchSite(e,t){const o=-1!==navigator.userAgent.indexOf("Mac OS X")?t.metaKey:t.ctrlKey;t&&o&&t.target&&t.target.href?window.open(t.target.href,"_blank"):(this.$emit("update:modelValue",{id:e.id,name:e.name}),this.switchSiteOnSelect&&this.activeSiteId!==e.id&&mn.loadSite(e.id))},onBlur(){this.showSitesList=!1,this.$emit("blur")},onClickSelector(){this.hasMultipleSites&&(this.showSitesList=!this.showSitesList,this.isLoading||this.searchTerm||this.loadInitialSites())},onPressEnter(e){"Enter"===e.key&&(e.preventDefault(),this.showSitesList=!this.showSitesList,this.showSitesList&&!this.isLoading&&this.loadInitialSites())},getMatchedSiteName(e){const t=e.toUpperCase().indexOf(this.searchTerm.toUpperCase());if(-1===t||this.isLoading)return this.htmlEntities(e);const o=this.htmlEntities(e.substring(0,t)),i=this.htmlEntities(e.substring(t+this.searchTerm.length));return`${o}${this.searchTerm}${i}`},loadInitialSites(){return mn.loadInitialSites(this.onlySitesWithAdminAccess,this.sitesToExclude?this.sitesToExclude:[],this.onlySitesWithAtLeastWriteAccess,this.siteTypesToExclude?this.siteTypesToExclude:[]).then(e=>{this.sites=e||[]})},searchSite(e){this.isLoading=!0,mn.searchSite(e,this.onlySitesWithAdminAccess,this.sitesToExclude?this.sitesToExclude:[],this.onlySitesWithAtLeastWriteAccess,this.siteTypesToExclude?this.siteTypesToExclude:[]).then(t=>{e===this.searchTerm&&t&&(this.sites=t)}).finally(()=>{this.isLoading=!1})},getUrlForSiteId(e){const t=U.stringify(Object.assign(Object.assign({},U.urlParsed.value),{},{segment:"",idSite:e})),o=U.stringify(Object.assign(Object.assign({},U.hashParsed.value),{},{segment:"",idSite:e}));return`?${t}#?${o}`},htmlEntities(e){return I.helper.htmlEntities(e)}}});Bn.render=En;var Nn=Bn;const Mn={ref:"root",class:"quickAccessInside"},In=["title","placeholder"],Fn={class:"dropdown quickAccessDropdown"},Rn={class:"no-result"},Ln=["onClick"],An=["onMouseenter","onClick"],_n={class:"quickAccessMatomoSearch"},Hn=["onMouseenter","onClick"],$n=["textContent"],Un={class:"quick-access-category helpCategory"},qn=["href"];function Wn(e,t,o,i,n,a){const r=Object(D["resolveDirective"])("focus-if"),s=Object(D["resolveDirective"])("tooltips"),l=Object(D["resolveDirective"])("focus-anywhere-but-here");return Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Mn,[Object(D["createElementVNode"])("span",{class:"icon-search",onMouseenter:t[0]||(t[0]=t=>e.searchActive=!0)},null,32),Object(D["withDirectives"])(Object(D["createElementVNode"])("input",{class:"quickAccessInput browser-default",onKeydown:t[1]||(t[1]=t=>e.onKeypress(t)),onFocus:t[2]||(t[2]=t=>e.searchActive=!0),"onUpdate:modelValue":t[3]||(t[3]=t=>e.searchTerm=t),type:"text",tabindex:"5",title:e.quickAccessTitle,placeholder:e.translate("General_Search"),ref:"input"},null,40,In),[[D["vModelText"],e.searchTerm],[r,{focused:e.searchActive}],[s]]),Object(D["withDirectives"])(Object(D["createElementVNode"])("div",Fn,[Object(D["withDirectives"])(Object(D["createElementVNode"])("ul",null,[Object(D["createElementVNode"])("li",Rn,Object(D["toDisplayString"])(e.translate("General_SearchNoResults")),1)],512),[[D["vShow"],!(e.numMenuItems>0||e.sites.length)]]),(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.menuItems,t=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("ul",{key:t.title},[Object(D["createElementVNode"])("li",{class:"quick-access-category",onClick:o=>{e.searchTerm=t.title,e.searchMenu(e.searchTerm)}},Object(D["toDisplayString"])(t.title),9,Ln),(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(t.items,t=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{class:Object(D["normalizeClass"])(["result",{selected:t.menuIndex===e.searchIndex}]),onMouseenter:o=>e.searchIndex=t.menuIndex,onClick:o=>e.selectMenuItem(t),key:t.index},[Object(D["createElementVNode"])("a",null,Object(D["toDisplayString"])(t.name.trim()),1)],42,An))),128))]))),128)),Object(D["createElementVNode"])("ul",_n,[Object(D["withDirectives"])(Object(D["createElementVNode"])("li",{class:"quick-access-category websiteCategory"},Object(D["toDisplayString"])(e.translate("SitesManager_Sites")),513),[[D["vShow"],e.hasSitesSelector&&e.sites.length||e.isLoading]]),Object(D["withDirectives"])(Object(D["createElementVNode"])("li",{class:"no-result"},Object(D["toDisplayString"])(e.translate("MultiSites_LoadingWebsites")),513),[[D["vShow"],e.hasSitesSelector&&e.isLoading]]),(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.sites,(t,o)=>Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{class:Object(D["normalizeClass"])(["result",{selected:e.numMenuItems+o===e.searchIndex}]),onMouseenter:t=>e.searchIndex=e.numMenuItems+o,onClick:o=>e.selectSite(t.idsite),key:t.idsite},[Object(D["createElementVNode"])("a",{textContent:Object(D["toDisplayString"])(t.name)},null,8,$n)],42,Hn)),[[D["vShow"],e.hasSitesSelector&&!e.isLoading]])),128))]),Object(D["createElementVNode"])("ul",null,[Object(D["createElementVNode"])("li",Un,Object(D["toDisplayString"])(e.translate("General_HelpResources")),1),Object(D["createElementVNode"])("li",{class:Object(D["normalizeClass"])([{selected:"help"===e.searchIndex},"quick-access-help"]),onMouseenter:t[4]||(t[4]=t=>e.searchIndex="help")},[Object(D["createElementVNode"])("a",{href:"https://matomo.org?mtm_campaign=App_Help&mtm_source=Matomo_App&mtm_keyword=QuickSearch&s="+encodeURIComponent(e.searchTerm),target:"_blank"},Object(D["toDisplayString"])(e.translate("CoreHome_SearchOnMatomo",e.searchTerm)),9,qn)],34)])],512),[[D["vShow"],e.searchTerm&&e.searchActive]])])),[[l,{blur:e.onBlur}]])}function zn(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */class zn{constructor(){Wn(this,"privateState",Object(D["reactive"])({pages:[]})),Wn(this,"state",Object(D["computed"])(()=>Object(D["readonly"])(this.privateState))),Wn(this,"fetchAllPagesPromise",void 0),Wn(this,"pages",Object(D["computed"])(()=>this.state.value.pages))}findPageInCategory(e){return this.pages.value.find(t=>t&&t.category&&t.category.id===e&&t.subcategory&&t.subcategory.id)}findPage(e,t){return this.pages.value.find(o=>o&&o.category&&o.subcategory&&o.category.id===e&&""+o.subcategory.id===t)}reloadAllPages(){return delete this.fetchAllPagesPromise,this.getAllPages()}getAllPages(){return this.fetchAllPagesPromise||(this.fetchAllPagesPromise=te.fetch({method:"API.getReportPagesMetadata",filter_limit:"-1"}).then(e=>(this.privateState.pages=e,this.pages.value))),this.fetchAllPagesPromise.then(()=>this.pages.value)}}var Gn=new zn; + */class Gn{constructor(){zn(this,"privateState",Object(D["reactive"])({pages:[]})),zn(this,"state",Object(D["computed"])(()=>Object(D["readonly"])(this.privateState))),zn(this,"fetchAllPagesPromise",void 0),zn(this,"pages",Object(D["computed"])(()=>this.state.value.pages))}findPageInCategory(e){return this.pages.value.find(t=>t&&t.category&&t.category.id===e&&t.subcategory&&t.subcategory.id)}findPage(e,t){return this.pages.value.find(o=>o&&o.category&&o.subcategory&&o.category.id===e&&""+o.subcategory.id===t)}reloadAllPages(){return delete this.fetchAllPagesPromise,this.getAllPages()}getAllPages(){return this.fetchAllPagesPromise||(this.fetchAllPagesPromise=te.fetch({method:"API.getReportPagesMetadata",filter_limit:"-1"}).then(e=>(this.privateState.pages=e,this.pages.value))),this.fetchAllPagesPromise.then(()=>this.pages.value)}}var Kn=new Gn; /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */function Kn(e){const t=[...e||[]];return t.sort((e,t)=>e.ordert.order?1:0),t} + */function Yn(e){const t=[...e||[]];return t.sort((e,t)=>e.ordert.order?1:0),t} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */function Yn(e){const t=e;return t.subcategories?t.subcategories:[]} + */function Qn(e){const t=e;return t.subcategories?t.subcategories:[]} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */function Qn(e){const t=e;return t.subcategories?t.subcategories:[]}function Jn(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} + */function Jn(e){const t=e;return t.subcategories?t.subcategories:[]}function Xn(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */function Xn(e){const t=parseFloat(e);return!Number.isNaN(t)&&Number.isFinite(t)}const Zn="";function ea(e){const{groups:t}=e;return t&&t.length?t:[Zn]}class ta{constructor(){Jn(this,"privateState",Object(D["reactive"])({activeSubcategoryId:null,activeSubsubcategoryId:null})),Jn(this,"state",Object(D["computed"])(()=>Object(D["readonly"])(this.privateState))),Jn(this,"activeCategory",Object(D["computed"])(()=>"undefined"!==typeof this.state.value.activeCategoryId?this.state.value.activeCategoryId:U.parsed.value.category)),Jn(this,"activeSubcategory",Object(D["computed"])(()=>this.state.value.activeSubcategoryId||U.parsed.value.subcategory)),Jn(this,"activeSubsubcategory",Object(D["computed"])(()=>{const e=this.state.value.activeSubsubcategoryId;if(e)return e;const t=this.findSubcategory(this.activeCategory.value,this.activeSubcategory.value);return t.subsubcategory&&t.subsubcategory.id===this.activeSubcategory.value?t.subsubcategory.id:null})),Jn(this,"menu",Object(D["computed"])(()=>this.buildMenuFromPages(U.parsed.value.group||Zn))),Jn(this,"fullMenu",Object(D["computed"])(()=>this.buildMenuFromPages(null)))}fetchMenuItems(){return Gn.getAllPages().then(()=>this.menu.value)}reloadMenuItems(){return Gn.reloadAllPages().then(()=>this.menu.value)}findSubcategory(e,t){let o=void 0,i=void 0,n=void 0;return this.menu.value.forEach(a=>{a.id===e&&(Yn(a)||[]).forEach(e=>{e.id===t&&(o=a,i=e),e.isGroup&&(Qn(e)||[]).forEach(r=>{r.id===t&&(o=a,i=e,n=r)})})}),{category:o,subcategory:i,subsubcategory:n}}buildMenuFromPages(e){const t=[],o=U.parsed.value.category,i=U.parsed.value.subcategory,n=Gn.pages.value,r={};return n.forEach(s=>{const l=Object.assign({},s.category),c=l.id,d=c===o;if(r[c])return;if(null!==e&&!ea(l).includes(e))return;r[c]=!0,l.subcategories=[];let u=null;const p=n.filter(e=>e.category.id===c);p.forEach(e=>{const t=Object.assign({},e.subcategory),o=t.id===i&&d;if(e.widgets&&e.widgets[0]&&Xn(e.subcategory.id)){u||(u=Object.assign({},t),u.name=a("CoreHome_ChooseX",[l.name]),u.isGroup=!0,u.subcategories=[],u.order=10),o&&(u.name=t.name);const e=t.id;return t.tooltip=`${t.name} (id = ${e})`,void u.subcategories.push(t)}l.subcategories.push(t)}),u&&u.subcategories&&u.subcategories.length<=5?u.subcategories.forEach(e=>l.subcategories.push(e)):u&&l.subcategories.push(u),l.subcategories=Kn(Yn(l)),t.push(l)}),Kn(t)}toggleCategory(e){return this.privateState.activeSubcategoryId=null,this.privateState.activeSubsubcategoryId=null,this.activeCategory.value===e.id?(this.privateState.activeCategoryId=null,!1):(this.privateState.activeCategoryId=e.id,!0)}enterSubcategory(e,t,o){e&&t&&(this.privateState.activeCategoryId=e.id,this.privateState.activeSubcategoryId=t.id,o&&(this.privateState.activeSubsubcategoryId=o.id))}}var oa=new ta;const{ListingFormatter:ia}=window;function na(e){const t=e.getBoundingClientRect(),o=window.$(window);return t.top>=0&&t.left>=0&&t.bottom<=o.height()&&t.right<=o.width()}function aa(e){e&&e.scrollIntoView&&e.scrollIntoView()}var ra=Object(D["defineComponent"])({name:"QuickAccess",directives:{FocusAnywhereButHere:tt,FocusIf:it,Tooltips:ct},watch:{searchActive(e){const t=this.$refs.root;if(!t||!t.parentElement)return;const o=t.parentElement.classList;o.toggle("active",e),o.toggle("expanded",e)},reportingGroup(){this.topMenuItems=null,this.leftMenuItems=null,this.segmentItems=null,this.deactivateSearch()}},mounted(){const e=this.$refs.root;e&&e.parentElement&&e.parentElement.classList.add("quick-access","piwikSelector"),M.helper.registerShortcut("f",a("CoreHome_ShortcutSearch"),e=>{if(e.altKey)return;e.preventDefault();const t=document.querySelector("nav .activateLeftMenu");t&&window.$(t).is(":visible")&&Yt(),aa(this.$refs.root),this.activateSearch()})},data(){const e=!!document.querySelector(".segmentEditorPanel");return{menuItems:[],numMenuItems:0,searchActive:!1,searchTerm:"",searchIndex:0,menuIndexCounter:-1,topMenuItems:null,leftMenuItems:null,segmentItems:null,hasSegmentSelector:e,sites:[],isLoading:!1}},created(){this.searchMenu=Pe(this.searchMenu.bind(this))},computed:{reportingGroup(){return U.parsed.value.group||Zn},hasSitesSelector(){return!!document.querySelector('.top_controls .siteSelector,.top_controls [vue-entry="CoreHome.SiteSelector"]')},quickAccessTitle(){const e=[a("CoreHome_MenuEntries")];return this.hasSegmentSelector&&e.push(a("CoreHome_Segments")),this.hasSitesSelector&&e.push(a("SitesManager_Sites")),a("CoreHome_QuickAccessTitle",ia.formatAnd(e))}},emits:["itemSelected","blur"],methods:{onKeypress(e){const t=this.searchTerm&&this.searchActive,o=9===e.which,i=27===e.which;38===e.which?(this.highlightPreviousItem(),e.preventDefault()):40===e.which?(this.highlightNextItem(),e.preventDefault()):13===e.which?this.clickQuickAccessMenuItem():o&&t||i&&t?this.deactivateSearch():o?this.searchActive=!1:setTimeout(()=>{this.searchActive=!0,this.searchMenu(this.searchTerm)})},highlightPreviousItem(){this.searchIndex-1<0?this.searchIndex=0:this.searchIndex-=1,this.makeSureSelectedItemIsInViewport()},highlightNextItem(){const e=this.$refs.root.querySelectorAll("li.result").length;e<=this.searchIndex+1?this.searchIndex=e-1:this.searchIndex+=1,this.makeSureSelectedItemIsInViewport()},clickQuickAccessMenuItem(){const e=this.getCurrentlySelectedElement();e&&setTimeout(()=>{e.click(),this.$emit("itemSelected",e)},20)},deactivateSearch(){this.searchTerm="",this.searchActive=!1,this.$refs.input&&this.$refs.input.blur()},makeSureSelectedItemIsInViewport(){const e=this.getCurrentlySelectedElement();e&&!na(e)&&aa(e)},getCurrentlySelectedElement(){const e=this.$refs.root.querySelectorAll("li.result");if(e&&e.length&&e.item(this.searchIndex))return e.item(this.searchIndex)},searchMenu(e){const t=e.toLowerCase();let o=-1;const i={},n=[],a=e=>{const t=Object.assign({},e);o+=1,t.menuIndex=o;const{category:a}=t;a in i||(n.push({title:a,items:[]}),i[a]=n.length-1);const r=i[a];n[r].items.push(t)};this.resetSearchIndex(),this.hasSitesSelector&&(this.isLoading=!0,un.searchSite(t).then(e=>{e&&(this.sites=e)}).finally(()=>{this.isLoading=!1}));const r=e=>-1!==e.name.toLowerCase().indexOf(t)||-1!==e.category.toLowerCase().indexOf(t);null===this.topMenuItems&&(this.topMenuItems=this.getTopMenuItems()),null===this.leftMenuItems&&(this.leftMenuItems=this.getLeftMenuItems()),null===this.segmentItems&&(this.segmentItems=this.getSegmentItems());const s=this.topMenuItems.filter(r),l=this.leftMenuItems.filter(r),c=this.segmentItems.filter(r),d=this.getReportingMenuItemsFromOtherGroups().filter(r);s.forEach(a),l.forEach(a),c.forEach(a),d.forEach(a),this.numMenuItems=s.length+l.length+c.length+d.length,this.menuItems=n},resetSearchIndex(){this.searchIndex=0,this.makeSureSelectedItemIsInViewport()},selectSite(e){this.deactivateSearch(),Qt(),un.loadSite(e)},selectMenuItem(e){if(e.page)return void this.navigateToReportingPage(e.page);const t=document.querySelector(`[quick_access='${e.index}']`);if(t){this.deactivateSearch(),Qt();const e=t.getAttribute("href");if(e&&e.length>10&&t&&t.click)try{t.click()}catch(o){window.$(t).click()}else window.$(t).click()}},onBlur(){this.searchActive=!1,this.$emit("blur")},activateSearch(){this.searchActive=!0},getTopMenuItems(){const e=a("CoreHome_Menu"),t=[];return document.querySelectorAll("nav .sidenav li > a, nav .sidenav li > div > a").forEach(o=>{var i;let n=null===(i=o.textContent)||void 0===i?void 0:i.trim();var a;(!n||null!=o.parentElement&&null!=o.parentElement.tagName&&"DIV"===o.parentElement.tagName)&&(n=null===(a=o.getAttribute("title"))||void 0===a?void 0:a.trim());n&&(t.push({name:n,index:this.menuIndexCounter+=1,category:e}),o.setAttribute("quick_access",""+this.menuIndexCounter))}),t},getLeftMenuItems(){const e=[];return document.querySelectorAll("#secondNavBar .menuTab").forEach(t=>{var o;const i=window.$(t).find("> .item");let n=(null===(o=i[0])||void 0===o?void 0:o.innerText.trim())||"";n&&-1!==n.lastIndexOf("\n")&&(n=n.slice(0,n.lastIndexOf("\n")).trim()),window.$(t).find("li .item").each((t,o)=>{var i;const a=null===(i=o.textContent)||void 0===i?void 0:i.trim();a&&(e.push({name:a,category:n,index:this.menuIndexCounter+=1}),o.setAttribute("quick_access",""+this.menuIndexCounter))})}),e},getSegmentItems(){if(!this.hasSegmentSelector)return[];const e=a("CoreHome_Segments"),t=[];return document.querySelectorAll(".segmentList [data-idsegment]").forEach(o=>{var i;const n=null===(i=o.querySelector(".segname"))||void 0===i||null===(i=i.textContent)||void 0===i?void 0:i.trim();n&&(t.push({name:n,category:e,index:this.menuIndexCounter+=1}),o.setAttribute("quick_access",""+this.menuIndexCounter))}),t},getReportingMenuItemsFromOtherGroups(){if(!Gn.pages.value.length)return[];const e=U.parsed.value.group||Zn,t=[];return oa.fullMenu.value.forEach(o=>{const i=ea(o);if(i.includes(e))return;const n=i[0],a=e=>{var i;const a=null===(i=e.name)||void 0===i?void 0:i.trim();a&&t.push({name:a,category:o.name,index:this.menuIndexCounter+=1,page:{category:o.id,subcategory:e.id,group:n}})};Yn(o).forEach(e=>{e.isGroup?Qn(e).forEach(a):a(e)})}),t},navigateToReportingPage(e){this.deactivateSearch(),Qt();const{idSite:t,period:o,date:i,segment:n,comparePeriods:a,compareDates:r,compareSegments:s}=U.parsed.value,l={idSite:t,period:o,date:i,segment:n,comparePeriods:a,compareDates:r,compareSegments:s,category:e.category,subcategory:e.subcategory};e.group&&(l.group=e.group),U.updateHash(l)}}});ra.render=qn;var sa=ra;const la={class:"searchInputContainer"},ca=Object(D["createElementVNode"])("span",{class:"icon-search"},null,-1),da=["value","placeholder"];function ua(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",la,[ca,Object(D["createElementVNode"])("input",Object(D["mergeProps"])({class:"searchInputField browser-default",type:"text",value:e.modelValue,placeholder:e.resolvedPlaceholder},e.$attrs,{onInput:t[0]||(t[0]=t=>e.onInput(t))}),null,16,da),e.showClear&&e.modelValue?(Object(D["openBlock"])(),Object(D["createElementBlock"])("button",{key:0,type:"button",class:"searchInputClear",onClick:t[1]||(t[1]=t=>e.onClear())})):Object(D["createCommentVNode"])("",!0)])}var pa=Object(D["defineComponent"])({name:"SearchInput",inheritAttrs:!1,props:{modelValue:{type:String,required:!0},placeholder:{type:String,default:""},showClear:{type:Boolean,default:!1}},emits:["update:modelValue"],computed:{resolvedPlaceholder(){return this.placeholder||a("General_Search")}},methods:{translate:a,onInput(e){this.$emit("update:modelValue",e.target.value)},onClear(){this.$emit("update:modelValue","")}}});pa.render=ua;var ma=pa;const ha={class:"fieldArray form-group"},ga={key:0,class:"fieldUiControl"},ba=["onClick","title"];function fa(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("Field");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",ha,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.modelValue,(t,o)=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{class:Object(D["normalizeClass"])(["fieldArrayTable multiple valign-wrapper",{["fieldArrayTable"+o]:!0}]),key:o},[e.field.uiControl?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",ga,[Object(D["createVNode"])(r,{"full-width":!0,"model-value":t,options:e.field.availableValues,"onUpdate:modelValue":t=>e.onEntryChange(t,o),"model-modifiers":e.field.modelModifiers,placeholder:" ",uicontrol:e.field.uiControl,title:e.field.title,name:`${e.name}-${o}`,id:`${e.id}-${o}`,"template-file":e.field.templateFile,component:e.field.component},null,8,["model-value","options","onUpdate:modelValue","model-modifiers","uicontrol","title","name","id","template-file","component"])])):Object(D["createCommentVNode"])("",!0),Object(D["withDirectives"])(Object(D["createElementVNode"])("span",{onClick:t=>e.removeEntry(o),class:"icon-minus valign",title:e.translate("General_Remove")},null,8,ba),[[D["vShow"],o+1!==e.modelValue.length]])],2))),128))])}const va=Ce("CorePluginsAdmin","Field");var Oa=Object(D["defineComponent"])({props:{modelValue:Array,name:String,id:String,field:Object,rows:String},components:{Field:va},emits:["update:modelValue"],watch:{modelValue(e){this.checkEmptyModelValue(e)}},mounted(){this.checkEmptyModelValue(this.modelValue)},methods:{checkEmptyModelValue(e){e&&e.length&&""===e.slice(-1)[0]||this.rows&&!((this.modelValue||[]).length-1&&this.modelValue){const t=this.modelValue.filter((t,o)=>o!==e);this.$emit("update:modelValue",t)}}}});Oa.render=fa;var ya=Oa;const ja={class:"multiPairField form-group"},wa={key:1,class:"fieldUiControl fieldUiControl2"},Sa={key:2,class:"fieldUiControl fieldUiControl3"},Ca={key:3,class:"fieldUiControl fieldUiControl4"},ka=["onClick","title"];function Da(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("Field");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",ja,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.modelValue,(t,o)=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{class:Object(D["normalizeClass"])(["multiPairFieldTable multiple valign-wrapper",{["multiPairFieldTable"+o]:!0,[`has${e.fieldCount}Fields`]:!0}]),key:o},[e.field1?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{key:0,class:Object(D["normalizeClass"])(["fieldUiControl fieldUiControl1",{hasMultiFields:e.field1.type&&e.field2.type}])},[Object(D["createVNode"])(r,{"full-width":!0,"model-value":t[e.field1.key],options:e.field1.availableValues,"onUpdate:modelValue":t=>e.onEntryChange(o,e.field1.key,t),"model-modifiers":e.field1.modelModifiers,placeholder:" ",uicontrol:e.field1.uiControl,name:`${e.name}-p1-${o}`,id:`${e.id}-p1-${o}`,title:e.field1.title,"template-file":e.field1.templateFile,component:e.field1.component},null,8,["model-value","options","onUpdate:modelValue","model-modifiers","uicontrol","name","id","title","template-file","component"])],2)):Object(D["createCommentVNode"])("",!0),e.field2?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",wa,[Object(D["createVNode"])(r,{"full-width":!0,options:e.field2.availableValues,"onUpdate:modelValue":t=>e.onEntryChange(o,e.field2.key,t),"model-value":t[e.field2.key],"model-modifiers":e.field2.modelModifiers,placeholder:" ",uicontrol:e.field2.uiControl,name:`${e.name}-p2-${o}`,id:`${e.id}-p2-${o}`,title:e.field2.title,"template-file":e.field2.templateFile,component:e.field2.component},null,8,["options","onUpdate:modelValue","model-value","model-modifiers","uicontrol","name","id","title","template-file","component"])])):Object(D["createCommentVNode"])("",!0),e.field3?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Sa,[Object(D["createVNode"])(r,{"full-width":!0,options:e.field3.availableValues,"onUpdate:modelValue":t=>e.onEntryChange(o,e.field3.key,t),"model-value":t[e.field3.key],"model-modifiers":e.field3.modelModifiers,placeholder:" ",uicontrol:e.field3.uiControl,name:`${e.name}-p3-${o}`,id:`${e.id}-p3-${o}`,title:e.field3.title,"template-file":e.field3.templateFile,component:e.field3.component},null,8,["options","onUpdate:modelValue","model-value","model-modifiers","uicontrol","name","id","title","template-file","component"])])):Object(D["createCommentVNode"])("",!0),e.field4?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Ca,[Object(D["createVNode"])(r,{"full-width":!0,options:e.field4.availableValues,"onUpdate:modelValue":t=>e.onEntryChange(o,e.field4.key,t),"model-value":t[e.field4.key],"model-modifiers":e.field4.modelModifiers,placeholder:" ",uicontrol:e.field4.uiControl,name:`${e.name}-p4-${o}`,id:`${e.id}-p4-${o}`,title:e.field4.title,"template-file":e.field4.templateFile,component:e.field4.component},null,8,["options","onUpdate:modelValue","model-value","model-modifiers","uicontrol","name","id","title","template-file","component"])])):Object(D["createCommentVNode"])("",!0),Object(D["withDirectives"])(Object(D["createElementVNode"])("span",{onClick:t=>e.removeEntry(o),class:"icon-minus valign",title:e.translate("General_Remove")},null,8,ka),[[D["vShow"],o+1!==e.modelValue.length]])],2))),128))])}const Ea=Ce("CorePluginsAdmin","Field");var Pa=Object(D["defineComponent"])({props:{modelValue:Array,name:String,id:String,field1:Object,field2:Object,field3:Object,field4:Object,rows:Number},components:{Field:Ea},computed:{fieldCount(){return this.field1&&this.field2&&this.field3&&this.field4?4:this.field1&&this.field2&&this.field3?3:this.field1&&this.field2?2:this.field1?1:0}},emits:["update:modelValue"],watch:{modelValue(e){this.checkEmptyModelValue(e)}},mounted(){this.checkEmptyModelValue(this.modelValue)},methods:{checkEmptyModelValue(e){e&&e.length&&!this.isEmptyValue(e.slice(-1)[0])||this.rows&&!(this.modelValue.length-1&&this.modelValue){const t=this.modelValue.filter((t,o)=>o!==e);this.$emit("update:modelValue",t)}},isEmptyValue(e){const{fieldCount:t}=this;if(4===t){if(!e[this.field1.key]&&!e[this.field2.key]&&!e[this.field3.key]&&!e[this.field4.key])return!1}else if(3===t){if(!e[this.field1.key]&&!e[this.field2.key]&&!e[this.field3.key])return!1}else if(2===t){if(!e[this.field1.key]&&!e[this.field2.key])return!1}else if(1===t&&!e[this.field1.key])return!1;return!0},makeEmptyValue(){const e={};return this.field1&&this.field1.key&&(e[this.field1.key]=""),this.field2&&this.field2.key&&(e[this.field2.key]=""),this.field3&&this.field3.key&&(e[this.field3.key]=""),this.field4&&this.field4.key&&(e[this.field4.key]=""),e}}});Pa.render=Da;var Ta=Pa;const xa=["disabled"],Va=Object(D["createElementVNode"])("span",{class:"icon-chevron-left"},null,-1),Ba=[Va],Na=["title"],Ia=Object(D["createElementVNode"])("span",{class:"icon icon-calendar"},null,-1),Ma={class:"flex"},Fa={key:0,id:"ajaxLoadingCalendar"},Ra={class:"loadingSegment"},La=["disabled"],Aa=Object(D["createElementVNode"])("span",{class:"icon-chevron-right"},null,-1),_a=[Aa];function Ha(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("PeriodSelectorOptionsColumn"),s=Object(D["resolveComponent"])("PeriodSelectorCalendarColumn"),l=Object(D["resolveComponent"])("ActivityIndicator"),c=Object(D["resolveDirective"])("tooltips"),d=Object(D["resolveDirective"])("expand-on-click");return Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{ref:"root",class:Object(D["normalizeClass"])(["periodSelector piwikSelector",{"periodSelector-withPrevNext":e.canShowMovePeriod}])},[e.canShowMovePeriod?(Object(D["openBlock"])(),Object(D["createElementBlock"])("button",{key:0,class:"move-period move-period-prev",onClick:t[0]||(t[0]=t=>e.movePeriod(-1)),disabled:e.isPeriodMoveDisabled(-1)},Ba,8,xa)):Object(D["createCommentVNode"])("",!0),Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("button",{ref:"title",id:"date",class:"title",tabindex:"4",title:e.translate("General_ChooseDate",e.currentlyViewingText)},[Ia,Object(D["createTextVNode"])(" "+Object(D["toDisplayString"])(e.currentlyViewingText),1)],8,Na)),[[c]]),Object(D["createElementVNode"])("div",{id:"periodMore",class:Object(D["normalizeClass"])(["dropdown","range"===e.selectedPeriod?"dual-calendar":"single-calendar"])},[Object(D["createElementVNode"])("div",Ma,[Object(D["createVNode"])(r,{"ui-selected-period":e.selectedPeriod,"periods-filtered":e.periodsFiltered,"applied-period":e.committedPeriod,"active-preset-id":e.activePresetId,"min-allowed-date":e.minAllowedDate,"max-allowed-date":e.maxAllowedDate,"onUpdate:uiSelectedPeriod":t[1]||(t[1]=t=>e.selectedPeriod=t),onPeriodSelect:t[2]||(t[2]=t=>e.onPeriodOptionSelected(t)),onPeriodDblclick:t[3]||(t[3]=t=>e.onPeriodOptionDblClick(t)),onPresetSelect:t[4]||(t[4]=t=>e.onPresetDateRangeSelected(t)),onPresetDblclick:t[5]||(t[5]=t=>e.onPresetDateRangeDblClick(t))},null,8,["ui-selected-period","periods-filtered","applied-period","active-preset-id","min-allowed-date","max-allowed-date"]),Object(D["createVNode"])(s,{"ui-selection":e.uiSelection,"calendar-viewport":e.calendarViewport,"display-range-start-date":e.displayRangeStartDate,"display-range-end-date":e.displayRangeEndDate,"single-calendar-period":e.singleCalendarPeriod,"single-calendar-selected-date":e.singleCalendarSelectedDate,"is-comparison-enabled":e.isComparisonEnabled,"is-comparing":e.isComparing,"compare-period-type":e.comparePeriodType,"compare-start-date":e.compareStartDate,"compare-end-date":e.compareEndDate,"compare-period-dropdown-options":e.comparePeriodDropdownOptions,"show-invalid-comparison-message":e.shouldDisplayInvalidComparisonMessage(),"is-apply-enabled":e.isApplyEnabled(),onRangeChange:t[6]||(t[6]=t=>e.onRangeChange(t.start,t.end)),onSingleDateSelect:t[7]||(t[7]=t=>e.onDatePickerSelected(t)),onApplyClick:t[8]||(t[8]=t=>e.onApplyClicked()),onDisabledApplyInteraction:t[9]||(t[9]=t=>e.onDisabledApplyInteraction()),"onUpdate:isComparing":t[10]||(t[10]=t=>e.onCompareToggleUpdated(t)),"onUpdate:comparePeriodType":t[11]||(t[11]=t=>e.onComparePeriodTypeUpdated(t)),"onUpdate:compareStartDate":t[12]||(t[12]=t=>e.onCompareStartDateUpdated(t)),"onUpdate:compareEndDate":t[13]||(t[13]=t=>e.onCompareEndDateUpdated(t))},null,8,["ui-selection","calendar-viewport","display-range-start-date","display-range-end-date","single-calendar-period","single-calendar-selected-date","is-comparison-enabled","is-comparing","compare-period-type","compare-start-date","compare-end-date","compare-period-dropdown-options","show-invalid-comparison-message","is-apply-enabled"])]),e.isLoadingNewPage?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Fa,[Object(D["createVNode"])(l,{loading:!0}),Object(D["createElementVNode"])("div",Ra,Object(D["toDisplayString"])(e.translate("SegmentEditor_LoadingSegmentedDataMayTakeSomeTime")),1)])):Object(D["createCommentVNode"])("",!0)],2),e.canShowMovePeriod?(Object(D["openBlock"])(),Object(D["createElementBlock"])("button",{key:1,class:"move-period move-period-next",onClick:t[14]||(t[14]=t=>e.movePeriod(1)),disabled:e.isPeriodMoveDisabled(1)},_a,8,La)):Object(D["createCommentVNode"])("",!0)],2)),[[d,{expander:"title",onExpand:e.onExpand,onClosed:e.onClosed}]])} + */function Zn(e){const t=parseFloat(e);return!Number.isNaN(t)&&Number.isFinite(t)}const ea="";function ta(e){const{groups:t}=e;return t&&t.length?t:[ea]}class oa{constructor(){Xn(this,"privateState",Object(D["reactive"])({activeSubcategoryId:null,activeSubsubcategoryId:null})),Xn(this,"state",Object(D["computed"])(()=>Object(D["readonly"])(this.privateState))),Xn(this,"activeCategory",Object(D["computed"])(()=>"undefined"!==typeof this.state.value.activeCategoryId?this.state.value.activeCategoryId:U.parsed.value.category)),Xn(this,"activeSubcategory",Object(D["computed"])(()=>this.state.value.activeSubcategoryId||U.parsed.value.subcategory)),Xn(this,"activeSubsubcategory",Object(D["computed"])(()=>{const e=this.state.value.activeSubsubcategoryId;if(e)return e;const t=this.findSubcategory(this.activeCategory.value,this.activeSubcategory.value);return t.subsubcategory&&t.subsubcategory.id===this.activeSubcategory.value?t.subsubcategory.id:null})),Xn(this,"menu",Object(D["computed"])(()=>this.buildMenuFromPages(U.parsed.value.group||ea))),Xn(this,"fullMenu",Object(D["computed"])(()=>this.buildMenuFromPages(null)))}fetchMenuItems(){return Kn.getAllPages().then(()=>this.menu.value)}reloadMenuItems(){return Kn.reloadAllPages().then(()=>this.menu.value)}findSubcategory(e,t){let o=void 0,i=void 0,n=void 0;return this.menu.value.forEach(a=>{a.id===e&&(Qn(a)||[]).forEach(e=>{e.id===t&&(o=a,i=e),e.isGroup&&(Jn(e)||[]).forEach(r=>{r.id===t&&(o=a,i=e,n=r)})})}),{category:o,subcategory:i,subsubcategory:n}}buildMenuFromPages(e){const t=[],o=U.parsed.value.category,i=U.parsed.value.subcategory,n=Kn.pages.value,r={};return n.forEach(s=>{const l=Object.assign({},s.category),c=l.id,d=c===o;if(r[c])return;if(null!==e&&!ta(l).includes(e))return;r[c]=!0,l.subcategories=[];let u=null;const m=n.filter(e=>e.category.id===c);m.forEach(e=>{const t=Object.assign({},e.subcategory),o=t.id===i&&d;if(e.widgets&&e.widgets[0]&&Zn(e.subcategory.id)){u||(u=Object.assign({},t),u.name=a("CoreHome_ChooseX",[l.name]),u.isGroup=!0,u.subcategories=[],u.order=10),o&&(u.name=t.name);const e=t.id;return t.tooltip=`${t.name} (id = ${e})`,void u.subcategories.push(t)}l.subcategories.push(t)}),u&&u.subcategories&&u.subcategories.length<=5?u.subcategories.forEach(e=>l.subcategories.push(e)):u&&l.subcategories.push(u),l.subcategories=Yn(Qn(l)),t.push(l)}),Yn(t)}toggleCategory(e){return this.privateState.activeSubcategoryId=null,this.privateState.activeSubsubcategoryId=null,this.activeCategory.value===e.id?(this.privateState.activeCategoryId=null,!1):(this.privateState.activeCategoryId=e.id,!0)}enterSubcategory(e,t,o){e&&t&&(this.privateState.activeCategoryId=e.id,this.privateState.activeSubcategoryId=t.id,o&&(this.privateState.activeSubsubcategoryId=o.id))}}var ia=new oa;const{ListingFormatter:na}=window;function aa(e){const t=e.getBoundingClientRect(),o=window.$(window);return t.top>=0&&t.left>=0&&t.bottom<=o.height()&&t.right<=o.width()}function ra(e){e&&e.scrollIntoView&&e.scrollIntoView()}var sa=Object(D["defineComponent"])({name:"QuickAccess",directives:{FocusAnywhereButHere:ot,FocusIf:nt,Tooltips:dt},watch:{searchActive(e){const t=this.$refs.root;if(!t||!t.parentElement)return;const o=t.parentElement.classList;o.toggle("active",e),o.toggle("expanded",e)},reportingGroup(){this.topMenuItems=null,this.leftMenuItems=null,this.segmentItems=null,this.deactivateSearch()}},mounted(){const e=this.$refs.root;e&&e.parentElement&&e.parentElement.classList.add("quick-access","piwikSelector"),I.helper.registerShortcut("f",a("CoreHome_ShortcutSearch"),e=>{if(e.altKey)return;e.preventDefault();const t=document.querySelector("nav .activateLeftMenu");t&&window.$(t).is(":visible")&&Qt(),ra(this.$refs.root),this.activateSearch()})},data(){const e=!!document.querySelector(".segmentEditorPanel");return{menuItems:[],numMenuItems:0,searchActive:!1,searchTerm:"",searchIndex:0,menuIndexCounter:-1,topMenuItems:null,leftMenuItems:null,segmentItems:null,hasSegmentSelector:e,sites:[],isLoading:!1}},created(){this.searchMenu=Pe(this.searchMenu.bind(this))},computed:{reportingGroup(){return U.parsed.value.group||ea},hasSitesSelector(){return!!document.querySelector('.top_controls .siteSelector,.top_controls [vue-entry="CoreHome.SiteSelector"]')},quickAccessTitle(){const e=[a("CoreHome_MenuEntries")];return this.hasSegmentSelector&&e.push(a("CoreHome_Segments")),this.hasSitesSelector&&e.push(a("SitesManager_Sites")),a("CoreHome_QuickAccessTitle",na.formatAnd(e))}},emits:["itemSelected","blur"],methods:{onKeypress(e){const t=this.searchTerm&&this.searchActive,o=9===e.which,i=27===e.which;38===e.which?(this.highlightPreviousItem(),e.preventDefault()):40===e.which?(this.highlightNextItem(),e.preventDefault()):13===e.which?this.clickQuickAccessMenuItem():o&&t||i&&t?this.deactivateSearch():o?this.searchActive=!1:setTimeout(()=>{this.searchActive=!0,this.searchMenu(this.searchTerm)})},highlightPreviousItem(){this.searchIndex-1<0?this.searchIndex=0:this.searchIndex-=1,this.makeSureSelectedItemIsInViewport()},highlightNextItem(){const e=this.$refs.root.querySelectorAll("li.result").length;e<=this.searchIndex+1?this.searchIndex=e-1:this.searchIndex+=1,this.makeSureSelectedItemIsInViewport()},clickQuickAccessMenuItem(){const e=this.getCurrentlySelectedElement();e&&setTimeout(()=>{e.click(),this.$emit("itemSelected",e)},20)},deactivateSearch(){this.searchTerm="",this.searchActive=!1,this.$refs.input&&this.$refs.input.blur()},makeSureSelectedItemIsInViewport(){const e=this.getCurrentlySelectedElement();e&&!aa(e)&&ra(e)},getCurrentlySelectedElement(){const e=this.$refs.root.querySelectorAll("li.result");if(e&&e.length&&e.item(this.searchIndex))return e.item(this.searchIndex)},searchMenu(e){const t=e.toLowerCase();let o=-1;const i={},n=[],a=e=>{const t=Object.assign({},e);o+=1,t.menuIndex=o;const{category:a}=t;a in i||(n.push({title:a,items:[]}),i[a]=n.length-1);const r=i[a];n[r].items.push(t)};this.resetSearchIndex(),this.hasSitesSelector&&(this.isLoading=!0,mn.searchSite(t).then(e=>{e&&(this.sites=e)}).finally(()=>{this.isLoading=!1}));const r=e=>-1!==e.name.toLowerCase().indexOf(t)||-1!==e.category.toLowerCase().indexOf(t);null===this.topMenuItems&&(this.topMenuItems=this.getTopMenuItems()),null===this.leftMenuItems&&(this.leftMenuItems=this.getLeftMenuItems()),null===this.segmentItems&&(this.segmentItems=this.getSegmentItems());const s=this.topMenuItems.filter(r),l=this.leftMenuItems.filter(r),c=this.segmentItems.filter(r),d=this.getReportingMenuItemsFromOtherGroups().filter(r);s.forEach(a),l.forEach(a),c.forEach(a),d.forEach(a),this.numMenuItems=s.length+l.length+c.length+d.length,this.menuItems=n},resetSearchIndex(){this.searchIndex=0,this.makeSureSelectedItemIsInViewport()},selectSite(e){this.deactivateSearch(),Jt(),mn.loadSite(e)},selectMenuItem(e){if(e.page)return void this.navigateToReportingPage(e.page);const t=document.querySelector(`[quick_access='${e.index}']`);if(t){this.deactivateSearch(),Jt();const e=t.getAttribute("href");if(e&&e.length>10&&t&&t.click)try{t.click()}catch(o){window.$(t).click()}else window.$(t).click()}},onBlur(){this.searchActive=!1,this.$emit("blur")},activateSearch(){this.searchActive=!0},getTopMenuItems(){const e=a("CoreHome_Menu"),t=[];return document.querySelectorAll("nav .sidenav li > a, nav .sidenav li > div > a").forEach(o=>{var i;let n=null===(i=o.textContent)||void 0===i?void 0:i.trim();var a;(!n||null!=o.parentElement&&null!=o.parentElement.tagName&&"DIV"===o.parentElement.tagName)&&(n=null===(a=o.getAttribute("title"))||void 0===a?void 0:a.trim());n&&(t.push({name:n,index:this.menuIndexCounter+=1,category:e}),o.setAttribute("quick_access",""+this.menuIndexCounter))}),t},getLeftMenuItems(){const e=[];return document.querySelectorAll("#secondNavBar .menuTab").forEach(t=>{var o;const i=window.$(t).find("> .item");let n=(null===(o=i[0])||void 0===o?void 0:o.innerText.trim())||"";n&&-1!==n.lastIndexOf("\n")&&(n=n.slice(0,n.lastIndexOf("\n")).trim()),window.$(t).find("li .item").each((t,o)=>{var i;const a=null===(i=o.textContent)||void 0===i?void 0:i.trim();a&&(e.push({name:a,category:n,index:this.menuIndexCounter+=1}),o.setAttribute("quick_access",""+this.menuIndexCounter))})}),e},getSegmentItems(){if(!this.hasSegmentSelector)return[];const e=a("CoreHome_Segments"),t=[];return document.querySelectorAll(".segmentList [data-idsegment]").forEach(o=>{var i;const n=null===(i=o.querySelector(".segname"))||void 0===i||null===(i=i.textContent)||void 0===i?void 0:i.trim();n&&(t.push({name:n,category:e,index:this.menuIndexCounter+=1}),o.setAttribute("quick_access",""+this.menuIndexCounter))}),t},getReportingMenuItemsFromOtherGroups(){if(!Kn.pages.value.length)return[];const e=U.parsed.value.group||ea,t=[];return ia.fullMenu.value.forEach(o=>{const i=ta(o);if(i.includes(e))return;const n=i[0],a=e=>{var i;const a=null===(i=e.name)||void 0===i?void 0:i.trim();a&&t.push({name:a,category:o.name,index:this.menuIndexCounter+=1,page:{category:o.id,subcategory:e.id,group:n}})};Qn(o).forEach(e=>{e.isGroup?Jn(e).forEach(a):a(e)})}),t},navigateToReportingPage(e){this.deactivateSearch(),Jt();const{idSite:t,period:o,date:i,segment:n,comparePeriods:a,compareDates:r,compareSegments:s}=U.parsed.value,l={idSite:t,period:o,date:i,segment:n,comparePeriods:a,compareDates:r,compareSegments:s,category:e.category,subcategory:e.subcategory};e.group&&(l.group=e.group),U.updateHash(l)}}});sa.render=Wn;var la=sa;const ca={class:"searchInputContainer"},da=Object(D["createElementVNode"])("span",{class:"icon-search"},null,-1),ua=["value","placeholder"];function ma(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",ca,[da,Object(D["createElementVNode"])("input",Object(D["mergeProps"])({class:"searchInputField browser-default",type:"text",value:e.modelValue,placeholder:e.resolvedPlaceholder},e.$attrs,{onInput:t[0]||(t[0]=t=>e.onInput(t))}),null,16,ua),e.showClear&&e.modelValue?(Object(D["openBlock"])(),Object(D["createElementBlock"])("button",{key:0,type:"button",class:"searchInputClear",onClick:t[1]||(t[1]=t=>e.onClear())})):Object(D["createCommentVNode"])("",!0)])}var pa=Object(D["defineComponent"])({name:"SearchInput",inheritAttrs:!1,props:{modelValue:{type:String,required:!0},placeholder:{type:String,default:""},showClear:{type:Boolean,default:!1}},emits:["update:modelValue"],computed:{resolvedPlaceholder(){return this.placeholder||a("General_Search")}},methods:{translate:a,onInput(e){this.$emit("update:modelValue",e.target.value)},onClear(){this.$emit("update:modelValue","")}}});pa.render=ma;var ha=pa;const ga={class:"fieldArray form-group"},ba={key:0,class:"fieldUiControl"},fa=["onClick","title"];function va(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("Field");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",ga,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.modelValue,(t,o)=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{class:Object(D["normalizeClass"])(["fieldArrayTable multiple valign-wrapper",{["fieldArrayTable"+o]:!0}]),key:o},[e.field.uiControl?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",ba,[Object(D["createVNode"])(r,{"full-width":!0,"model-value":t,options:e.field.availableValues,"onUpdate:modelValue":t=>e.onEntryChange(t,o),"model-modifiers":e.field.modelModifiers,placeholder:" ",uicontrol:e.field.uiControl,title:e.field.title,name:`${e.name}-${o}`,id:`${e.id}-${o}`,"template-file":e.field.templateFile,component:e.field.component},null,8,["model-value","options","onUpdate:modelValue","model-modifiers","uicontrol","title","name","id","template-file","component"])])):Object(D["createCommentVNode"])("",!0),Object(D["withDirectives"])(Object(D["createElementVNode"])("span",{onClick:t=>e.removeEntry(o),class:"icon-minus valign",title:e.translate("General_Remove")},null,8,fa),[[D["vShow"],o+1!==e.modelValue.length]])],2))),128))])}const Oa=Ce("CorePluginsAdmin","Field");var ya=Object(D["defineComponent"])({props:{modelValue:Array,name:String,id:String,field:Object,rows:String},components:{Field:Oa},emits:["update:modelValue"],watch:{modelValue(e){this.checkEmptyModelValue(e)}},mounted(){this.checkEmptyModelValue(this.modelValue)},methods:{checkEmptyModelValue(e){e&&e.length&&""===e.slice(-1)[0]||this.rows&&!((this.modelValue||[]).length-1&&this.modelValue){const t=this.modelValue.filter((t,o)=>o!==e);this.$emit("update:modelValue",t)}}}});ya.render=va;var ja=ya;const wa={class:"multiPairField form-group"},Sa={key:1,class:"fieldUiControl fieldUiControl2"},Ca={key:2,class:"fieldUiControl fieldUiControl3"},ka={key:3,class:"fieldUiControl fieldUiControl4"},Da=["onClick","title"];function Ea(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("Field");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",wa,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.modelValue,(t,o)=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{class:Object(D["normalizeClass"])(["multiPairFieldTable multiple valign-wrapper",{["multiPairFieldTable"+o]:!0,[`has${e.fieldCount}Fields`]:!0}]),key:o},[e.field1?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{key:0,class:Object(D["normalizeClass"])(["fieldUiControl fieldUiControl1",{hasMultiFields:e.field1.type&&e.field2.type}])},[Object(D["createVNode"])(r,{"full-width":!0,"model-value":t[e.field1.key],options:e.field1.availableValues,"onUpdate:modelValue":t=>e.onEntryChange(o,e.field1.key,t),"model-modifiers":e.field1.modelModifiers,placeholder:" ",uicontrol:e.field1.uiControl,name:`${e.name}-p1-${o}`,id:`${e.id}-p1-${o}`,title:e.field1.title,"template-file":e.field1.templateFile,component:e.field1.component},null,8,["model-value","options","onUpdate:modelValue","model-modifiers","uicontrol","name","id","title","template-file","component"])],2)):Object(D["createCommentVNode"])("",!0),e.field2?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Sa,[Object(D["createVNode"])(r,{"full-width":!0,options:e.field2.availableValues,"onUpdate:modelValue":t=>e.onEntryChange(o,e.field2.key,t),"model-value":t[e.field2.key],"model-modifiers":e.field2.modelModifiers,placeholder:" ",uicontrol:e.field2.uiControl,name:`${e.name}-p2-${o}`,id:`${e.id}-p2-${o}`,title:e.field2.title,"template-file":e.field2.templateFile,component:e.field2.component},null,8,["options","onUpdate:modelValue","model-value","model-modifiers","uicontrol","name","id","title","template-file","component"])])):Object(D["createCommentVNode"])("",!0),e.field3?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Ca,[Object(D["createVNode"])(r,{"full-width":!0,options:e.field3.availableValues,"onUpdate:modelValue":t=>e.onEntryChange(o,e.field3.key,t),"model-value":t[e.field3.key],"model-modifiers":e.field3.modelModifiers,placeholder:" ",uicontrol:e.field3.uiControl,name:`${e.name}-p3-${o}`,id:`${e.id}-p3-${o}`,title:e.field3.title,"template-file":e.field3.templateFile,component:e.field3.component},null,8,["options","onUpdate:modelValue","model-value","model-modifiers","uicontrol","name","id","title","template-file","component"])])):Object(D["createCommentVNode"])("",!0),e.field4?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",ka,[Object(D["createVNode"])(r,{"full-width":!0,options:e.field4.availableValues,"onUpdate:modelValue":t=>e.onEntryChange(o,e.field4.key,t),"model-value":t[e.field4.key],"model-modifiers":e.field4.modelModifiers,placeholder:" ",uicontrol:e.field4.uiControl,name:`${e.name}-p4-${o}`,id:`${e.id}-p4-${o}`,title:e.field4.title,"template-file":e.field4.templateFile,component:e.field4.component},null,8,["options","onUpdate:modelValue","model-value","model-modifiers","uicontrol","name","id","title","template-file","component"])])):Object(D["createCommentVNode"])("",!0),Object(D["withDirectives"])(Object(D["createElementVNode"])("span",{onClick:t=>e.removeEntry(o),class:"icon-minus valign",title:e.translate("General_Remove")},null,8,Da),[[D["vShow"],o+1!==e.modelValue.length]])],2))),128))])}const Pa=Ce("CorePluginsAdmin","Field");var Ta=Object(D["defineComponent"])({props:{modelValue:Array,name:String,id:String,field1:Object,field2:Object,field3:Object,field4:Object,rows:Number},components:{Field:Pa},computed:{fieldCount(){return this.field1&&this.field2&&this.field3&&this.field4?4:this.field1&&this.field2&&this.field3?3:this.field1&&this.field2?2:this.field1?1:0}},emits:["update:modelValue"],watch:{modelValue(e){this.checkEmptyModelValue(e)}},mounted(){this.checkEmptyModelValue(this.modelValue)},methods:{checkEmptyModelValue(e){e&&e.length&&!this.isEmptyValue(e.slice(-1)[0])||this.rows&&!(this.modelValue.length-1&&this.modelValue){const t=this.modelValue.filter((t,o)=>o!==e);this.$emit("update:modelValue",t)}},isEmptyValue(e){const{fieldCount:t}=this;if(4===t){if(!e[this.field1.key]&&!e[this.field2.key]&&!e[this.field3.key]&&!e[this.field4.key])return!1}else if(3===t){if(!e[this.field1.key]&&!e[this.field2.key]&&!e[this.field3.key])return!1}else if(2===t){if(!e[this.field1.key]&&!e[this.field2.key])return!1}else if(1===t&&!e[this.field1.key])return!1;return!0},makeEmptyValue(){const e={};return this.field1&&this.field1.key&&(e[this.field1.key]=""),this.field2&&this.field2.key&&(e[this.field2.key]=""),this.field3&&this.field3.key&&(e[this.field3.key]=""),this.field4&&this.field4.key&&(e[this.field4.key]=""),e}}});Ta.render=Ea;var xa=Ta;const Va=["disabled"],Ba=Object(D["createElementVNode"])("span",{class:"icon-chevron-left"},null,-1),Na=[Ba],Ma=["title"],Ia=Object(D["createElementVNode"])("span",{class:"icon icon-calendar"},null,-1),Fa={class:"flex"},Ra={key:0,id:"ajaxLoadingCalendar"},La={class:"loadingSegment"},Aa=["disabled"],_a=Object(D["createElementVNode"])("span",{class:"icon-chevron-right"},null,-1),Ha=[_a];function $a(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("PeriodSelectorOptionsColumn"),s=Object(D["resolveComponent"])("PeriodSelectorCalendarColumn"),l=Object(D["resolveComponent"])("ActivityIndicator"),c=Object(D["resolveDirective"])("tooltips"),d=Object(D["resolveDirective"])("expand-on-click");return Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{ref:"root",class:Object(D["normalizeClass"])(["periodSelector piwikSelector",{"periodSelector-withPrevNext":e.canShowMovePeriod}])},[e.canShowMovePeriod?(Object(D["openBlock"])(),Object(D["createElementBlock"])("button",{key:0,class:"move-period move-period-prev",onClick:t[0]||(t[0]=t=>e.movePeriod(-1)),disabled:e.isPeriodMoveDisabled(-1)},Na,8,Va)):Object(D["createCommentVNode"])("",!0),Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("button",{ref:"title",id:"date",class:"title",tabindex:"4",title:e.translate("General_ChooseDate",e.currentlyViewingText)},[Ia,Object(D["createTextVNode"])(" "+Object(D["toDisplayString"])(e.currentlyViewingText),1)],8,Ma)),[[c]]),Object(D["createElementVNode"])("div",{id:"periodMore",class:Object(D["normalizeClass"])(["dropdown","range"===e.selectedPeriod?"dual-calendar":"single-calendar"])},[Object(D["createElementVNode"])("div",Fa,[Object(D["createVNode"])(r,{"ui-selected-period":e.selectedPeriod,"periods-filtered":e.periodsFiltered,"applied-period":e.committedPeriod,"active-preset-id":e.activePresetId,"min-allowed-date":e.minAllowedDate,"max-allowed-date":e.maxAllowedDate,"onUpdate:uiSelectedPeriod":t[1]||(t[1]=t=>e.selectedPeriod=t),onPeriodSelect:t[2]||(t[2]=t=>e.onPeriodOptionSelected(t)),onPeriodDblclick:t[3]||(t[3]=t=>e.onPeriodOptionDblClick(t)),onPresetSelect:t[4]||(t[4]=t=>e.onPresetDateRangeSelected(t)),onPresetDblclick:t[5]||(t[5]=t=>e.onPresetDateRangeDblClick(t))},null,8,["ui-selected-period","periods-filtered","applied-period","active-preset-id","min-allowed-date","max-allowed-date"]),Object(D["createVNode"])(s,{"ui-selection":e.uiSelection,"calendar-viewport":e.calendarViewport,"display-range-start-date":e.displayRangeStartDate,"display-range-end-date":e.displayRangeEndDate,"single-calendar-period":e.singleCalendarPeriod,"single-calendar-selected-date":e.singleCalendarSelectedDate,"is-comparison-enabled":e.isComparisonEnabled,"is-comparing":e.isComparing,"compare-period-type":e.comparePeriodType,"compare-start-date":e.compareStartDate,"compare-end-date":e.compareEndDate,"compare-period-dropdown-options":e.comparePeriodDropdownOptions,"show-invalid-comparison-message":e.shouldDisplayInvalidComparisonMessage(),"is-apply-enabled":e.isApplyEnabled(),onRangeChange:t[6]||(t[6]=t=>e.onRangeChange(t.start,t.end)),onSingleDateSelect:t[7]||(t[7]=t=>e.onDatePickerSelected(t)),onApplyClick:t[8]||(t[8]=t=>e.onApplyClicked()),onDisabledApplyInteraction:t[9]||(t[9]=t=>e.onDisabledApplyInteraction()),"onUpdate:isComparing":t[10]||(t[10]=t=>e.onCompareToggleUpdated(t)),"onUpdate:comparePeriodType":t[11]||(t[11]=t=>e.onComparePeriodTypeUpdated(t)),"onUpdate:compareStartDate":t[12]||(t[12]=t=>e.onCompareStartDateUpdated(t)),"onUpdate:compareEndDate":t[13]||(t[13]=t=>e.onCompareEndDateUpdated(t))},null,8,["ui-selection","calendar-viewport","display-range-start-date","display-range-end-date","single-calendar-period","single-calendar-selected-date","is-comparison-enabled","is-comparing","compare-period-type","compare-start-date","compare-end-date","compare-period-dropdown-options","show-invalid-comparison-message","is-apply-enabled"])]),e.isLoadingNewPage?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Ra,[Object(D["createVNode"])(l,{loading:!0}),Object(D["createElementVNode"])("div",La,Object(D["toDisplayString"])(e.translate("SegmentEditor_LoadingSegmentedDataMayTakeSomeTime")),1)])):Object(D["createCommentVNode"])("",!0)],2),e.canShowMovePeriod?(Object(D["openBlock"])(),Object(D["createElementBlock"])("button",{key:1,class:"move-period move-period-next",onClick:t[14]||(t[14]=t=>e.movePeriod(1)),disabled:e.isPeriodMoveDisabled(1)},Ha,8,Aa)):Object(D["createCommentVNode"])("",!0)],2)),[[d,{expander:"title",onExpand:e.onExpand,onClosed:e.onClosed}]])} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */function $a(e){return!e.hasPendingNonRangePeriodChange&&("period"===e.uiSelectionType&&e.uiSelectedPeriod!==Vi&&!e.isCompareDirty||!(e.uiSelectedPeriod===Vi&&!e.hasPendingPresetSelection&&!e.isRangeValid)&&!(e.isComparing&&"custom"===e.comparePeriodType&&!e.isCompareRangeValid))}function Ua(e){if(e.hasPendingNonRangePeriodChange)return{type:"stop"};if(!e.isCompareDirty)return e.shouldCloseSelectorWithoutApplying?{type:"close"}:{type:"stop"};if(e.appliedPeriod===Vi){if(!e.hasCommittedRangeBounds)return{type:"stop"};const t=`${e.appliedRangeStartDate},${e.appliedRangeEndDate}`;return{type:"commit",date:e.rollingDateParam||t,period:Vi}}return e.formattedAppliedAnchorDate?{type:"commit",date:e.rollingDateParam||e.formattedAppliedAnchorDate,period:e.appliedPeriod}:{type:"stop"}} + */function Ua(e){return!e.hasPendingNonRangePeriodChange&&("period"===e.uiSelectionType&&e.uiSelectedPeriod!==Bi&&!e.isCompareDirty||!(e.uiSelectedPeriod===Bi&&!e.hasPendingPresetSelection&&!e.isRangeValid)&&!(e.isComparing&&"custom"===e.comparePeriodType&&!e.isCompareRangeValid))}function qa(e){if(e.hasPendingNonRangePeriodChange)return{type:"stop"};if(!e.isCompareDirty)return e.shouldCloseSelectorWithoutApplying?{type:"close"}:{type:"stop"};if(e.appliedPeriod===Bi){if(!e.hasCommittedRangeBounds)return{type:"stop"};const t=`${e.appliedRangeStartDate},${e.appliedRangeEndDate}`;return{type:"commit",date:e.rollingDateParam||t,period:Bi}}return e.formattedAppliedAnchorDate?{type:"commit",date:e.rollingDateParam||e.formattedAppliedAnchorDate,period:e.appliedPeriod}:{type:"stop"}} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */function qa(e){return 0===e.detail}function Wa(e){const t=Object.assign({},e);return delete t.comparePeriods,delete t.comparePeriodType,delete t.compareDates,t}function za(e,t,o){const i=new Date(e.getTime());switch(t){case"day":i.setDate(i.getDate()+o);break;case"week":i.setDate(i.getDate()+7*o);break;case"month":i.setMonth(i.getMonth()+o);break;case"year":i.setFullYear(i.getFullYear()+o);break;default:break}return i}function Ga(e,t,o){const i=new Date(e.getTime());return io&&i.setTime(o.getTime()),i}const Ka={class:"period-type period-selector-options-column"},Ya={id:"otherPeriods"};function Qa(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("PeriodOptions"),s=Object(D["resolveComponent"])("PresetDateRanges");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Ka,[Object(D["createElementVNode"])("h6",null,[Object(D["createElementVNode"])("b",null,Object(D["toDisplayString"])(e.translate("General_ChoosePeriod")),1)]),Object(D["createElementVNode"])("div",Ya,[Object(D["createVNode"])(r,{"model-value":e.uiSelectedPeriod,periods:e.periodsFiltered,"checked-period-id":e.uiSelectedPeriod,"active-date-period":e.appliedPeriod,"onUpdate:modelValue":t[0]||(t[0]=t=>e.$emit("update:uiSelectedPeriod",t)),onSelect:t[1]||(t[1]=t=>e.$emit("period-select",t)),onDblclick:t[2]||(t[2]=t=>e.$emit("period-dblclick",t))},null,8,["model-value","periods","checked-period-id","active-date-period"]),Object(D["createVNode"])(s,{"checked-preset-id":e.activePresetId,"allowed-periods":e.periodsFiltered,"min-date":e.minAllowedDate,"max-date":e.maxAllowedDate,onSelect:t[3]||(t[3]=t=>e.$emit("preset-select",t)),onDblclick:t[4]||(t[4]=t=>e.$emit("preset-dblclick",t))},null,8,["checked-preset-id","allowed-periods","min-date","max-date"])])])}const Ja={class:"presetDateRanges"},Xa={key:0,class:"preset-date-range-group-separator"},Za=["title","onDblclick"],er=["name","id","checked","onClick","onChange"],tr={class:"preset-option-text"};function or(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Ja,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.groupedPresetDateRanges,(t,o)=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{key:o,class:"preset-date-range-group"},[o>0?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Xa)):Object(D["createCommentVNode"])("",!0),(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(t,t=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("p",{key:t.id},[Object(D["createElementVNode"])("label",{class:Object(D["normalizeClass"])({"selected-period-label":e.checkedPresetId===t.id}),title:e.checkedPresetId===t.id?"":e.translate("General_DoubleClickToChangePeriod"),onDblclick:o=>e.handlePresetDoubleClick(t.id)},[Object(D["createElementVNode"])("input",{type:"radio",class:"preset-option-input",name:e.presetInputName,id:"preset_date_"+t.id,checked:e.checkedPresetId===t.id,onClick:o=>e.handlePresetClick(t.id),onChange:o=>e.handlePresetSelected(t.id)},null,40,er),Object(D["createElementVNode"])("span",tr,Object(D["toDisplayString"])(e.translate(t.labelKey)),1)],42,Za)]))),128))]))),128))])} + */function Wa(e){return 0===e.detail}function za(e){const t=Object.assign({},e);return delete t.comparePeriods,delete t.comparePeriodType,delete t.compareDates,t}function Ga(e,t,o){const i=new Date(e.getTime());switch(t){case"day":i.setDate(i.getDate()+o);break;case"week":i.setDate(i.getDate()+7*o);break;case"month":i.setMonth(i.getMonth()+o);break;case"year":i.setFullYear(i.getFullYear()+o);break;default:break}return i}function Ka(e,t,o){const i=new Date(e.getTime());return io&&i.setTime(o.getTime()),i}const Ya={class:"period-type period-selector-options-column"},Qa={id:"otherPeriods"};function Ja(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("PeriodOptions"),s=Object(D["resolveComponent"])("PresetDateRanges");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Ya,[Object(D["createElementVNode"])("h6",null,[Object(D["createElementVNode"])("b",null,Object(D["toDisplayString"])(e.translate("General_ChoosePeriod")),1)]),Object(D["createElementVNode"])("div",Qa,[Object(D["createVNode"])(r,{"model-value":e.uiSelectedPeriod,periods:e.periodsFiltered,"checked-period-id":e.uiSelectedPeriod,"active-date-period":e.appliedPeriod,"onUpdate:modelValue":t[0]||(t[0]=t=>e.$emit("update:uiSelectedPeriod",t)),onSelect:t[1]||(t[1]=t=>e.$emit("period-select",t)),onDblclick:t[2]||(t[2]=t=>e.$emit("period-dblclick",t))},null,8,["model-value","periods","checked-period-id","active-date-period"]),Object(D["createVNode"])(s,{"checked-preset-id":e.activePresetId,"allowed-periods":e.periodsFiltered,"min-date":e.minAllowedDate,"max-date":e.maxAllowedDate,onSelect:t[3]||(t[3]=t=>e.$emit("preset-select",t)),onDblclick:t[4]||(t[4]=t=>e.$emit("preset-dblclick",t))},null,8,["checked-preset-id","allowed-periods","min-date","max-date"])])])}const Xa={class:"presetDateRanges"},Za={key:0,class:"preset-date-range-group-separator"},er=["title","onDblclick"],tr=["name","id","checked","onClick","onChange"],or={class:"preset-option-text"};function ir(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Xa,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.groupedPresetDateRanges,(t,o)=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{key:o,class:"preset-date-range-group"},[o>0?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Za)):Object(D["createCommentVNode"])("",!0),(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(t,t=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("p",{key:t.id},[Object(D["createElementVNode"])("label",{class:Object(D["normalizeClass"])({"selected-period-label":e.checkedPresetId===t.id}),title:e.checkedPresetId===t.id?"":e.translate("General_DoubleClickToChangePeriod"),onDblclick:o=>e.handlePresetDoubleClick(t.id)},[Object(D["createElementVNode"])("input",{type:"radio",class:"preset-option-input",name:e.presetInputName,id:"preset_date_"+t.id,checked:e.checkedPresetId===t.id,onClick:o=>e.handlePresetClick(t.id),onChange:o=>e.handlePresetSelected(t.id)},null,40,tr),Object(D["createElementVNode"])("span",or,Object(D["toDisplayString"])(e.translate(t.labelKey)),1)],42,er)]))),128))]))),128))])} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */const ir={today:"day",yesterday:"day",last7days:"range",last30days:"range",last90days:"range",lastWeekMonSun:"week",lastMonth:"month",lastQuarter:"range",lastYear:"year",thisWeekMonToday:"week",thisMonth:"month",thisQuarter:"range",thisYear:"year"},nr=[{id:"today",labelKey:"CoreHome_PresetDateToday"},{id:"yesterday",labelKey:"CoreHome_PresetDateYesterday"},{id:"last7days",labelKey:"CoreHome_PresetDateLast7Days"},{id:"last30days",labelKey:"CoreHome_PresetDateLast30Days"},{id:"last90days",labelKey:"CoreHome_PresetDateLast90Days"},{id:"lastWeekMonSun",labelKey:"CoreHome_PresetDateLastWeekMonSun"},{id:"lastMonth",labelKey:"CoreHome_PresetDateLastMonth"},{id:"lastQuarter",labelKey:"CoreHome_PresetDateLastQuarter"},{id:"lastYear",labelKey:"CoreHome_PresetDateLastYear"},{id:"thisWeekMonToday",labelKey:"CoreHome_PresetDateThisWeekMonToday"},{id:"thisMonth",labelKey:"CoreHome_PresetDateThisMonth"},{id:"thisQuarter",labelKey:"CoreHome_PresetDateThisQuarter"},{id:"thisYear",labelKey:"CoreHome_PresetDateThisYear"}],ar={"day|today":"today","day|yesterday":"yesterday","range|last7":"last7days","range|last30":"last30days","range|last90":"last90days","week|lastweek":"lastWeekMonSun","month|lastmonth":"lastMonth","year|lastyear":"lastYear","week|today":"thisWeekMonToday","month|today":"thisMonth","year|today":"thisYear"},rr={today:"today",yesterday:"yesterday",last7days:"last7",last30days:"last30",last90days:"last90",lastWeekMonSun:"lastweek",lastMonth:"lastmonth",lastYear:"lastyear",thisWeekMonToday:"today",thisMonth:"today",thisYear:"today"};function sr(e,t){return ar[`${e}|${t}`]||null}function lr(e){return new Date(e.getTime())}function cr(e,t){const o=lr(e);return o.setDate(o.getDate()+t),o}function dr(e){return new Date(e.getFullYear(),e.getMonth(),1)}function ur(e){return new Date(e.getFullYear(),e.getMonth()+1,0)}function pr(e){const t=(e.getDay()+6)%7;return cr(e,-t)}function mr(e){const t=e.getMonth(),o=t-t%3;return new Date(e.getFullYear(),o,1)}function hr(e,t){return`${d(e)},${d(t)}`}function gr(e,t,o){return eo?new Date(o.getTime()):e}function br(e,t){const o=lr(t),i=t=>Object.assign(Object.assign({},t),{},{urlDate:rr[e]||t.date});switch(e){case"today":return i({id:e,period:"day",date:d(o),selectedDate:o,startDate:o,endDate:o});case"yesterday":{const t=cr(o,-1);return i({id:e,period:"day",date:d(t),selectedDate:t,startDate:t,endDate:t})}case"last7days":{const t=cr(o,-6);return i({id:e,period:"range",date:hr(t,o),selectedDate:o,startDate:t,endDate:o})}case"last30days":{const t=cr(o,-29);return i({id:e,period:"range",date:hr(t,o),selectedDate:o,startDate:t,endDate:o})}case"last90days":{const t=cr(o,-89);return i({id:e,period:"range",date:hr(t,o),selectedDate:o,startDate:t,endDate:o})}case"lastWeekMonSun":{const t=pr(o),n=cr(t,-7),a=cr(n,6);return i({id:e,period:"week",date:d(n),selectedDate:n,startDate:n,endDate:a})}case"lastMonth":{const t=new Date(o.getFullYear(),o.getMonth()-1,1),n=dr(t),a=ur(t);return i({id:e,period:"month",date:d(n),selectedDate:n,startDate:n,endDate:a})}case"lastQuarter":{const t=mr(o),n=cr(t,-1),a=mr(n);return i({id:e,period:"range",date:hr(a,n),selectedDate:n,startDate:a,endDate:n})}case"lastYear":{const t=o.getFullYear()-1,n=new Date(t,0,1),a=new Date(t,11,31);return i({id:e,period:"year",date:d(n),selectedDate:n,startDate:n,endDate:a})}case"thisWeekMonToday":{const t=pr(o);return i({id:e,period:"week",date:d(o),selectedDate:o,startDate:t,endDate:o})}case"thisMonth":{const t=dr(o);return i({id:e,period:"month",date:d(o),selectedDate:o,startDate:t,endDate:o})}case"thisQuarter":{const t=mr(o);return i({id:e,period:"range",date:hr(t,o),selectedDate:o,startDate:t,endDate:o})}case"thisYear":{const t=new Date(o.getFullYear(),0,1);return i({id:e,period:"year",date:d(o),selectedDate:o,startDate:t,endDate:o})}default:throw new Error("Unknown preset date range: "+e)}}function fr(e,t,o=u()){try{let i=null,n=null;const a=nr.find(a=>{const r=br(a.id,o);if(r.period!==e)return!1;if(r.date===t)return!0;if("range"!==e)return i=i||p(t),g(i,r.selectedDate,e);n=n||c.parse(e,t).getDateRange();const s=[r.startDate,r.endDate];return n[0].getTime()===s[0].getTime()&&n[1].getTime()===s[1].getTime()});return(null===a||void 0===a?void 0:a.id)||sr(e,t)}catch(i){return sr(e,t)}}const vr=[["today","yesterday"],["last7days","last30days","last90days"],["lastWeekMonSun","lastMonth","lastQuarter","lastYear"],["thisWeekMonToday","thisMonth","thisQuarter","thisYear"]];let Or=0;var yr=Object(D["defineComponent"])({props:{checkedPresetId:{type:String,default:null},minDate:{type:Date,required:!0},maxDate:{type:Date,required:!0},today:{type:Date,default:()=>u()},allowedPeriods:{type:Array,required:!0}},data(){const e="preset-date-range-"+Or;return Or+=1,{presetInputName:e}},emits:["select","dblclick"],computed:{presetDateRanges(){return nr.filter(e=>this.allowedPeriods.includes(ir[e.id]))},groupedPresetDateRanges(){const e=new Map(this.presetDateRanges.map(e=>[e.id,e]));return vr.map(t=>t.map(t=>e.get(t)).filter(e=>!!e)).filter(e=>e.length)}},methods:{translate:a,handlePresetClick(e){this.checkedPresetId===e&&this.handlePresetSelected(e)},handlePresetSelected(e){const t=br(e,this.today);this.$emit("select",Object.assign(Object.assign({},t),{},{startDate:gr(t.startDate,this.minDate,this.maxDate),endDate:gr(t.endDate,this.minDate,this.maxDate)}))},handlePresetDoubleClick(e){const t=br(e,this.today);this.$emit("dblclick",Object.assign(Object.assign({},t),{},{startDate:gr(t.startDate,this.minDate,this.maxDate),endDate:gr(t.endDate,this.minDate,this.maxDate)}))}}});yr.render=or;var jr=yr;const wr=["aria-label"],Sr=["title","onDblclick"],Cr=["name","id","checked","onChange","onKeydown"],kr={class:"period-option-text"};function Dr(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{class:"periodOptions",role:"radiogroup","aria-label":e.translate("General_ChoosePeriod")},[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.displayPeriods,t=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("p",{key:t},[Object(D["createElementVNode"])("label",{class:Object(D["normalizeClass"])(["period-option-label",{"selected-period-label":e.checkedPeriodId===t}]),title:t===e.activeDatePeriod?"":e.translate("General_DoubleClickToChangePeriod"),onDblclick:o=>e.handlePeriodDoubleClick(t)},[Object(D["createElementVNode"])("input",{class:"period-option-input",type:"radio",name:e.periodInputName,id:"period_id_"+t,checked:e.checkedPeriodId===t,onChange:o=>e.handlePeriodSelected(t),onKeydown:Object(D["withKeys"])(Object(D["withModifiers"])(o=>e.handlePeriodEnter(t),["prevent"]),["enter"])},null,40,Cr),Object(D["createElementVNode"])("span",kr,Object(D["toDisplayString"])(e.getPeriodDisplayText(t)),1)],42,Sr)]))),128))],8,wr)}let Er=0;var Pr=Object(D["defineComponent"])({name:"PeriodOptions",props:{modelValue:{type:String,default:null},periods:{type:Array,required:!0},checkedPeriodId:{type:String,default:null},activeDatePeriod:{type:String,required:!0}},data(){const e="period-"+Er;return Er+=1,{periodInputName:e}},emits:["update:modelValue","select","dblclick"],computed:{displayPeriods(){return this.periods.includes("range")?["range"].concat(this.periods.filter(e=>"range"!==e)):this.periods}},methods:{translate:a,getPeriodDisplayText(e){const t="range"===e?`${a("General_Custom")} ${a("General_DateRangeInPeriodList")}`:c.get(e).getDisplayText();return t.charAt(0).toUpperCase()+t.slice(1)},handlePeriodSelected(e){const t={period:e};this.$emit("update:modelValue",e),this.$emit("select",t)},handlePeriodEnter(e){this.handlePeriodSelected(e)},handlePeriodDoubleClick(e){const t={period:e};this.$emit("dblclick",t)}}});Pr.render=Dr;var Tr=Pr,xr=Object(D["defineComponent"])({name:"PeriodSelectorOptionsColumn",components:{PresetDateRanges:jr,PeriodOptions:Tr},props:{uiSelectedPeriod:{type:String,required:!0},periodsFiltered:{type:Array,required:!0},appliedPeriod:{type:String,required:!0},activePresetId:{type:String,default:null},minAllowedDate:{type:Date,required:!0},maxAllowedDate:{type:Date,required:!0}},emits:["update:uiSelectedPeriod","period-select","period-dblclick","preset-select","preset-dblclick"],methods:{translate:a}});xr.render=Qa;var Vr=xr;const Br={class:"period-selector-calendar-column"},Nr={class:"period-date"},Ir=["disabled","value"];function Mr(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("DateRangePicker"),s=Object(D["resolveComponent"])("PeriodDatePicker"),l=Object(D["resolveComponent"])("PeriodSelectorCompareControls");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Br,[Object(D["createElementVNode"])("div",null,[Object(D["withDirectives"])(Object(D["createVNode"])(r,{class:"period-range","start-date":e.displayRangeStartDate,"end-date":e.displayRangeEndDate,onRangeChange:t[0]||(t[0]=t=>e.$emit("range-change",t)),onSubmit:t[1]||(t[1]=t=>e.$emit("apply-click"))},null,8,["start-date","end-date"]),[[D["vShow"],"range"===e.calendarViewport]])]),Object(D["withDirectives"])(Object(D["createElementVNode"])("div",Nr,[Object(D["createVNode"])(s,{id:"datepicker",period:e.singleCalendarPeriod,date:e.singleCalendarSelectedDate,onSelect:t[2]||(t[2]=t=>e.$emit("single-date-select",t.date))},null,8,["period","date"])],512),[[D["vShow"],"single"===e.calendarViewport]]),Object(D["createVNode"])(l,{"is-comparison-enabled":e.isComparisonEnabled,"is-comparing":e.isComparing,"compare-period-type":e.comparePeriodType,"compare-start-date":e.compareStartDate,"compare-end-date":e.compareEndDate,"compare-period-dropdown-options":e.comparePeriodDropdownOptions,"show-invalid-comparison-message":e.showInvalidComparisonMessage,"onUpdate:isComparing":t[3]||(t[3]=t=>e.$emit("update:isComparing",t)),"onUpdate:comparePeriodType":t[4]||(t[4]=t=>e.$emit("update:comparePeriodType",t)),"onUpdate:compareStartDate":t[5]||(t[5]=t=>e.$emit("update:compareStartDate",t)),"onUpdate:compareEndDate":t[6]||(t[6]=t=>e.$emit("update:compareEndDate",t))},null,8,["is-comparison-enabled","is-comparing","compare-period-type","compare-start-date","compare-end-date","compare-period-dropdown-options","show-invalid-comparison-message"]),Object(D["createElementVNode"])("div",{class:"apply-button-container",onMousedownCapture:t[8]||(t[8]=(...t)=>e.onApplyButtonInteraction&&e.onApplyButtonInteraction(...t))},[Object(D["createElementVNode"])("input",{type:"submit",id:"calendarApply",class:"btn",onClick:t[7]||(t[7]=t=>e.$emit("apply-click")),disabled:!e.isApplyEnabled,value:e.translate("General_Apply")},null,8,Ir)],32)])}const Fr={key:0,class:"compare-checkbox"},Rr={class:"compare-checkbox-label"},Lr=["checked"],Ar={class:"compare-checkbox-text"},_r={id:"comparePeriodToDropdown"},Hr={key:1,class:"compare-date-range"},$r={id:"comparePeriodStartDate"},Ur=Object(D["createElementVNode"])("span",{class:"compare-dates-separator"},null,-1),qr={id:"comparePeriodEndDate"},Wr={key:0,class:"compare-validation-message"};function zr(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("Field");return Object(D["openBlock"])(),Object(D["createElementBlock"])(D["Fragment"],null,[e.isComparisonEnabled?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Fr,[Object(D["createElementVNode"])("label",Rr,[Object(D["createElementVNode"])("input",{class:"compare-checkbox-input",id:"comparePeriodTo",type:"checkbox",checked:!!e.isComparing,onChange:t[0]||(t[0]=t=>e.onCompareToggle(t))},null,40,Lr),Object(D["createElementVNode"])("span",Ar,Object(D["toDisplayString"])(e.translate("General_CompareTo")),1)]),Object(D["createElementVNode"])("div",_r,[Object(D["createVNode"])(r,{"model-value":e.comparePeriodType,"onUpdate:modelValue":t[1]||(t[1]=t=>e.$emit("update:comparePeriodType",t)),style:Object(D["normalizeStyle"])({visibility:e.isComparing?"visible":"hidden"}),name:"comparePeriodToDropdown",uicontrol:"select",options:e.comparePeriodDropdownOptions,"full-width":!0,disabled:!e.isComparing},null,8,["model-value","style","options","disabled"])])])):Object(D["createCommentVNode"])("",!0),e.isComparing&&"custom"===e.comparePeriodType?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Hr,[Object(D["createElementVNode"])("div",null,[Object(D["createElementVNode"])("div",$r,[Object(D["createElementVNode"])("div",null,[Object(D["createVNode"])(r,{"model-value":e.compareStartDate,"onUpdate:modelValue":t[2]||(t[2]=t=>e.$emit("update:compareStartDate",t)),name:"comparePeriodStartDate",uicontrol:"text","full-width":!0,title:e.translate("CoreHome_StartDate"),placeholder:"YYYY-MM-DD"},null,8,["model-value","title"])])]),Ur,Object(D["createElementVNode"])("div",qr,[Object(D["createElementVNode"])("div",null,[Object(D["createVNode"])(r,{"model-value":e.compareEndDate,"onUpdate:modelValue":t[3]||(t[3]=t=>e.$emit("update:compareEndDate",t)),name:"comparePeriodEndDate",uicontrol:"text","full-width":!0,title:e.translate("CoreHome_EndDate"),placeholder:"YYYY-MM-DD"},null,8,["model-value","title"])])])]),e.showInvalidComparisonMessage?(Object(D["openBlock"])(),Object(D["createElementBlock"])("p",Wr,Object(D["toDisplayString"])(e.translate("CoreHome_InvalidComparisonDateRange")),1)):Object(D["createCommentVNode"])("",!0)])):Object(D["createCommentVNode"])("",!0)],64)}const Gr=Ce("CorePluginsAdmin","Field");var Kr=Object(D["defineComponent"])({name:"PeriodSelectorCompareControls",components:{Field:Gr},props:{isComparisonEnabled:{type:Boolean,required:!0},isComparing:{type:Boolean,default:null},comparePeriodType:{type:String,required:!0},compareStartDate:{type:String,required:!0},compareEndDate:{type:String,required:!0},comparePeriodDropdownOptions:{type:Array,required:!0},showInvalidComparisonMessage:{type:Boolean,default:!1}},emits:["update:isComparing","update:comparePeriodType","update:compareStartDate","update:compareEndDate"],methods:{translate:a,onCompareToggle(e){this.$emit("update:isComparing",e.target.checked)}}});Kr.render=zr;var Yr=Kr,Qr=Object(D["defineComponent"])({name:"PeriodSelectorCalendarColumn",components:{DateRangePicker:Ci,PeriodDatePicker:Mi,PeriodSelectorCompareControls:Yr},props:{uiSelection:{type:Object,required:!0},calendarViewport:{type:String,required:!0},displayRangeStartDate:{type:String,default:null},displayRangeEndDate:{type:String,default:null},singleCalendarPeriod:{type:String,required:!0},singleCalendarSelectedDate:{type:Date,default:null},isComparisonEnabled:{type:Boolean,required:!0},isComparing:{type:Boolean,default:null},comparePeriodType:{type:String,required:!0},compareStartDate:{type:String,required:!0},compareEndDate:{type:String,required:!0},comparePeriodDropdownOptions:{type:Array,required:!0},showInvalidComparisonMessage:{type:Boolean,default:!1},isApplyEnabled:{type:Boolean,required:!0}},emits:["range-change","single-date-select","apply-click","disabled-apply-interaction","update:isComparing","update:comparePeriodType","update:compareStartDate","update:compareEndDate"],methods:{translate:a,onApplyButtonInteraction(){this.isApplyEnabled||this.$emit("disabled-apply-interaction")}}});Qr.render=Mr;var Jr=Qr; + */const nr={today:"day",yesterday:"day",last7days:"range",last30days:"range",last90days:"range",lastWeekMonSun:"week",lastMonth:"month",lastQuarter:"range",lastYear:"year",thisWeekMonToday:"week",thisMonth:"month",thisQuarter:"range",thisYear:"year"},ar=[{id:"today",labelKey:"CoreHome_PresetDateToday"},{id:"yesterday",labelKey:"CoreHome_PresetDateYesterday"},{id:"last7days",labelKey:"CoreHome_PresetDateLast7Days"},{id:"last30days",labelKey:"CoreHome_PresetDateLast30Days"},{id:"last90days",labelKey:"CoreHome_PresetDateLast90Days"},{id:"lastWeekMonSun",labelKey:"CoreHome_PresetDateLastWeekMonSun"},{id:"lastMonth",labelKey:"CoreHome_PresetDateLastMonth"},{id:"lastQuarter",labelKey:"CoreHome_PresetDateLastQuarter"},{id:"lastYear",labelKey:"CoreHome_PresetDateLastYear"},{id:"thisWeekMonToday",labelKey:"CoreHome_PresetDateThisWeekMonToday"},{id:"thisMonth",labelKey:"CoreHome_PresetDateThisMonth"},{id:"thisQuarter",labelKey:"CoreHome_PresetDateThisQuarter"},{id:"thisYear",labelKey:"CoreHome_PresetDateThisYear"}],rr={"day|today":"today","day|yesterday":"yesterday","range|last7":"last7days","range|last30":"last30days","range|last90":"last90days","week|lastweek":"lastWeekMonSun","month|lastmonth":"lastMonth","year|lastyear":"lastYear","week|today":"thisWeekMonToday","month|today":"thisMonth","year|today":"thisYear"},sr={today:"today",yesterday:"yesterday",last7days:"last7",last30days:"last30",last90days:"last90",lastWeekMonSun:"lastweek",lastMonth:"lastmonth",lastYear:"lastyear",thisWeekMonToday:"today",thisMonth:"today",thisYear:"today"};function lr(e,t){return rr[`${e}|${t}`]||null}function cr(e){return new Date(e.getTime())}function dr(e,t){const o=cr(e);return o.setDate(o.getDate()+t),o}function ur(e){return new Date(e.getFullYear(),e.getMonth(),1)}function mr(e){return new Date(e.getFullYear(),e.getMonth()+1,0)}function pr(e){const t=(e.getDay()+6)%7;return dr(e,-t)}function hr(e){const t=e.getMonth(),o=t-t%3;return new Date(e.getFullYear(),o,1)}function gr(e,t){return`${d(e)},${d(t)}`}function br(e,t,o){return eo?new Date(o.getTime()):e}function fr(e,t){const o=cr(t),i=t=>Object.assign(Object.assign({},t),{},{urlDate:sr[e]||t.date});switch(e){case"today":return i({id:e,period:"day",date:d(o),selectedDate:o,startDate:o,endDate:o});case"yesterday":{const t=dr(o,-1);return i({id:e,period:"day",date:d(t),selectedDate:t,startDate:t,endDate:t})}case"last7days":{const t=dr(o,-6);return i({id:e,period:"range",date:gr(t,o),selectedDate:o,startDate:t,endDate:o})}case"last30days":{const t=dr(o,-29);return i({id:e,period:"range",date:gr(t,o),selectedDate:o,startDate:t,endDate:o})}case"last90days":{const t=dr(o,-89);return i({id:e,period:"range",date:gr(t,o),selectedDate:o,startDate:t,endDate:o})}case"lastWeekMonSun":{const t=pr(o),n=dr(t,-7),a=dr(n,6);return i({id:e,period:"week",date:d(n),selectedDate:n,startDate:n,endDate:a})}case"lastMonth":{const t=new Date(o.getFullYear(),o.getMonth()-1,1),n=ur(t),a=mr(t);return i({id:e,period:"month",date:d(n),selectedDate:n,startDate:n,endDate:a})}case"lastQuarter":{const t=hr(o),n=dr(t,-1),a=hr(n);return i({id:e,period:"range",date:gr(a,n),selectedDate:n,startDate:a,endDate:n})}case"lastYear":{const t=o.getFullYear()-1,n=new Date(t,0,1),a=new Date(t,11,31);return i({id:e,period:"year",date:d(n),selectedDate:n,startDate:n,endDate:a})}case"thisWeekMonToday":{const t=pr(o);return i({id:e,period:"week",date:d(o),selectedDate:o,startDate:t,endDate:o})}case"thisMonth":{const t=ur(o);return i({id:e,period:"month",date:d(o),selectedDate:o,startDate:t,endDate:o})}case"thisQuarter":{const t=hr(o);return i({id:e,period:"range",date:gr(t,o),selectedDate:o,startDate:t,endDate:o})}case"thisYear":{const t=new Date(o.getFullYear(),0,1);return i({id:e,period:"year",date:d(o),selectedDate:o,startDate:t,endDate:o})}default:throw new Error("Unknown preset date range: "+e)}}function vr(e,t,o=u()){try{let i=null,n=null;const a=ar.find(a=>{const r=fr(a.id,o);if(r.period!==e)return!1;if(r.date===t)return!0;if("range"!==e)return i=i||m(t),g(i,r.selectedDate,e);n=n||c.parse(e,t).getDateRange();const s=[r.startDate,r.endDate];return n[0].getTime()===s[0].getTime()&&n[1].getTime()===s[1].getTime()});return(null===a||void 0===a?void 0:a.id)||lr(e,t)}catch(i){return lr(e,t)}}const Or=[["today","yesterday"],["last7days","last30days","last90days"],["lastWeekMonSun","lastMonth","lastQuarter","lastYear"],["thisWeekMonToday","thisMonth","thisQuarter","thisYear"]];let yr=0;var jr=Object(D["defineComponent"])({props:{checkedPresetId:{type:String,default:null},minDate:{type:Date,required:!0},maxDate:{type:Date,required:!0},today:{type:Date,default:()=>u()},allowedPeriods:{type:Array,required:!0}},data(){const e="preset-date-range-"+yr;return yr+=1,{presetInputName:e}},emits:["select","dblclick"],computed:{presetDateRanges(){return ar.filter(e=>this.allowedPeriods.includes(nr[e.id]))},groupedPresetDateRanges(){const e=new Map(this.presetDateRanges.map(e=>[e.id,e]));return Or.map(t=>t.map(t=>e.get(t)).filter(e=>!!e)).filter(e=>e.length)}},methods:{translate:a,handlePresetClick(e){this.checkedPresetId===e&&this.handlePresetSelected(e)},handlePresetSelected(e){const t=fr(e,this.today);this.$emit("select",Object.assign(Object.assign({},t),{},{startDate:br(t.startDate,this.minDate,this.maxDate),endDate:br(t.endDate,this.minDate,this.maxDate)}))},handlePresetDoubleClick(e){const t=fr(e,this.today);this.$emit("dblclick",Object.assign(Object.assign({},t),{},{startDate:br(t.startDate,this.minDate,this.maxDate),endDate:br(t.endDate,this.minDate,this.maxDate)}))}}});jr.render=ir;var wr=jr;const Sr=["aria-label"],Cr=["title","onDblclick"],kr=["name","id","checked","onChange","onKeydown"],Dr={class:"period-option-text"};function Er(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{class:"periodOptions",role:"radiogroup","aria-label":e.translate("General_ChoosePeriod")},[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.displayPeriods,t=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("p",{key:t},[Object(D["createElementVNode"])("label",{class:Object(D["normalizeClass"])(["period-option-label",{"selected-period-label":e.checkedPeriodId===t}]),title:t===e.activeDatePeriod?"":e.translate("General_DoubleClickToChangePeriod"),onDblclick:o=>e.handlePeriodDoubleClick(t)},[Object(D["createElementVNode"])("input",{class:"period-option-input",type:"radio",name:e.periodInputName,id:"period_id_"+t,checked:e.checkedPeriodId===t,onChange:o=>e.handlePeriodSelected(t),onKeydown:Object(D["withKeys"])(Object(D["withModifiers"])(o=>e.handlePeriodEnter(t),["prevent"]),["enter"])},null,40,kr),Object(D["createElementVNode"])("span",Dr,Object(D["toDisplayString"])(e.getPeriodDisplayText(t)),1)],42,Cr)]))),128))],8,Sr)}let Pr=0;var Tr=Object(D["defineComponent"])({name:"PeriodOptions",props:{modelValue:{type:String,default:null},periods:{type:Array,required:!0},checkedPeriodId:{type:String,default:null},activeDatePeriod:{type:String,required:!0}},data(){const e="period-"+Pr;return Pr+=1,{periodInputName:e}},emits:["update:modelValue","select","dblclick"],computed:{displayPeriods(){return this.periods.includes("range")?["range"].concat(this.periods.filter(e=>"range"!==e)):this.periods}},methods:{translate:a,getPeriodDisplayText(e){const t="range"===e?`${a("General_Custom")} ${a("General_DateRangeInPeriodList")}`:c.get(e).getDisplayText();return t.charAt(0).toUpperCase()+t.slice(1)},handlePeriodSelected(e){const t={period:e};this.$emit("update:modelValue",e),this.$emit("select",t)},handlePeriodEnter(e){this.handlePeriodSelected(e)},handlePeriodDoubleClick(e){const t={period:e};this.$emit("dblclick",t)}}});Tr.render=Er;var xr=Tr,Vr=Object(D["defineComponent"])({name:"PeriodSelectorOptionsColumn",components:{PresetDateRanges:wr,PeriodOptions:xr},props:{uiSelectedPeriod:{type:String,required:!0},periodsFiltered:{type:Array,required:!0},appliedPeriod:{type:String,required:!0},activePresetId:{type:String,default:null},minAllowedDate:{type:Date,required:!0},maxAllowedDate:{type:Date,required:!0}},emits:["update:uiSelectedPeriod","period-select","period-dblclick","preset-select","preset-dblclick"],methods:{translate:a}});Vr.render=Ja;var Br=Vr;const Nr={class:"period-selector-calendar-column"},Mr={class:"period-date"},Ir=["disabled","value"];function Fr(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("DateRangePicker"),s=Object(D["resolveComponent"])("PeriodDatePicker"),l=Object(D["resolveComponent"])("PeriodSelectorCompareControls");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Nr,[Object(D["createElementVNode"])("div",null,[Object(D["withDirectives"])(Object(D["createVNode"])(r,{class:"period-range","start-date":e.displayRangeStartDate,"end-date":e.displayRangeEndDate,onRangeChange:t[0]||(t[0]=t=>e.$emit("range-change",t)),onSubmit:t[1]||(t[1]=t=>e.$emit("apply-click"))},null,8,["start-date","end-date"]),[[D["vShow"],"range"===e.calendarViewport]])]),Object(D["withDirectives"])(Object(D["createElementVNode"])("div",Mr,[Object(D["createVNode"])(s,{id:"datepicker",period:e.singleCalendarPeriod,date:e.singleCalendarSelectedDate,onSelect:t[2]||(t[2]=t=>e.$emit("single-date-select",t.date))},null,8,["period","date"])],512),[[D["vShow"],"single"===e.calendarViewport]]),Object(D["createVNode"])(l,{"is-comparison-enabled":e.isComparisonEnabled,"is-comparing":e.isComparing,"compare-period-type":e.comparePeriodType,"compare-start-date":e.compareStartDate,"compare-end-date":e.compareEndDate,"compare-period-dropdown-options":e.comparePeriodDropdownOptions,"show-invalid-comparison-message":e.showInvalidComparisonMessage,"onUpdate:isComparing":t[3]||(t[3]=t=>e.$emit("update:isComparing",t)),"onUpdate:comparePeriodType":t[4]||(t[4]=t=>e.$emit("update:comparePeriodType",t)),"onUpdate:compareStartDate":t[5]||(t[5]=t=>e.$emit("update:compareStartDate",t)),"onUpdate:compareEndDate":t[6]||(t[6]=t=>e.$emit("update:compareEndDate",t))},null,8,["is-comparison-enabled","is-comparing","compare-period-type","compare-start-date","compare-end-date","compare-period-dropdown-options","show-invalid-comparison-message"]),Object(D["createElementVNode"])("div",{class:"apply-button-container",onMousedownCapture:t[8]||(t[8]=(...t)=>e.onApplyButtonInteraction&&e.onApplyButtonInteraction(...t))},[Object(D["createElementVNode"])("input",{type:"submit",id:"calendarApply",class:"btn",onClick:t[7]||(t[7]=t=>e.$emit("apply-click")),disabled:!e.isApplyEnabled,value:e.translate("General_Apply")},null,8,Ir)],32)])}const Rr={key:0,class:"compare-checkbox"},Lr={class:"compare-checkbox-label"},Ar=["checked"],_r={class:"compare-checkbox-text"},Hr={id:"comparePeriodToDropdown"},$r={key:1,class:"compare-date-range"},Ur={id:"comparePeriodStartDate"},qr=Object(D["createElementVNode"])("span",{class:"compare-dates-separator"},null,-1),Wr={id:"comparePeriodEndDate"},zr={key:0,class:"compare-validation-message"};function Gr(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("Field");return Object(D["openBlock"])(),Object(D["createElementBlock"])(D["Fragment"],null,[e.isComparisonEnabled?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Rr,[Object(D["createElementVNode"])("label",Lr,[Object(D["createElementVNode"])("input",{class:"compare-checkbox-input",id:"comparePeriodTo",type:"checkbox",checked:!!e.isComparing,onChange:t[0]||(t[0]=t=>e.onCompareToggle(t))},null,40,Ar),Object(D["createElementVNode"])("span",_r,Object(D["toDisplayString"])(e.translate("General_CompareTo")),1)]),Object(D["createElementVNode"])("div",Hr,[Object(D["createVNode"])(r,{"model-value":e.comparePeriodType,"onUpdate:modelValue":t[1]||(t[1]=t=>e.$emit("update:comparePeriodType",t)),style:Object(D["normalizeStyle"])({visibility:e.isComparing?"visible":"hidden"}),name:"comparePeriodToDropdown",uicontrol:"select",options:e.comparePeriodDropdownOptions,"full-width":!0,disabled:!e.isComparing},null,8,["model-value","style","options","disabled"])])])):Object(D["createCommentVNode"])("",!0),e.isComparing&&"custom"===e.comparePeriodType?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",$r,[Object(D["createElementVNode"])("div",null,[Object(D["createElementVNode"])("div",Ur,[Object(D["createElementVNode"])("div",null,[Object(D["createVNode"])(r,{"model-value":e.compareStartDate,"onUpdate:modelValue":t[2]||(t[2]=t=>e.$emit("update:compareStartDate",t)),name:"comparePeriodStartDate",uicontrol:"text","full-width":!0,title:e.translate("CoreHome_StartDate"),placeholder:"YYYY-MM-DD"},null,8,["model-value","title"])])]),qr,Object(D["createElementVNode"])("div",Wr,[Object(D["createElementVNode"])("div",null,[Object(D["createVNode"])(r,{"model-value":e.compareEndDate,"onUpdate:modelValue":t[3]||(t[3]=t=>e.$emit("update:compareEndDate",t)),name:"comparePeriodEndDate",uicontrol:"text","full-width":!0,title:e.translate("CoreHome_EndDate"),placeholder:"YYYY-MM-DD"},null,8,["model-value","title"])])])]),e.showInvalidComparisonMessage?(Object(D["openBlock"])(),Object(D["createElementBlock"])("p",zr,Object(D["toDisplayString"])(e.translate("CoreHome_InvalidComparisonDateRange")),1)):Object(D["createCommentVNode"])("",!0)])):Object(D["createCommentVNode"])("",!0)],64)}const Kr=Ce("CorePluginsAdmin","Field");var Yr=Object(D["defineComponent"])({name:"PeriodSelectorCompareControls",components:{Field:Kr},props:{isComparisonEnabled:{type:Boolean,required:!0},isComparing:{type:Boolean,default:null},comparePeriodType:{type:String,required:!0},compareStartDate:{type:String,required:!0},compareEndDate:{type:String,required:!0},comparePeriodDropdownOptions:{type:Array,required:!0},showInvalidComparisonMessage:{type:Boolean,default:!1}},emits:["update:isComparing","update:comparePeriodType","update:compareStartDate","update:compareEndDate"],methods:{translate:a,onCompareToggle(e){this.$emit("update:isComparing",e.target.checked)}}});Yr.render=Gr;var Qr=Yr,Jr=Object(D["defineComponent"])({name:"PeriodSelectorCalendarColumn",components:{DateRangePicker:ki,PeriodDatePicker:Fi,PeriodSelectorCompareControls:Qr},props:{uiSelection:{type:Object,required:!0},calendarViewport:{type:String,required:!0},displayRangeStartDate:{type:String,default:null},displayRangeEndDate:{type:String,default:null},singleCalendarPeriod:{type:String,required:!0},singleCalendarSelectedDate:{type:Date,default:null},isComparisonEnabled:{type:Boolean,required:!0},isComparing:{type:Boolean,default:null},comparePeriodType:{type:String,required:!0},compareStartDate:{type:String,required:!0},compareEndDate:{type:String,required:!0},comparePeriodDropdownOptions:{type:Array,required:!0},showInvalidComparisonMessage:{type:Boolean,default:!1},isApplyEnabled:{type:Boolean,required:!0}},emits:["range-change","single-date-select","apply-click","disabled-apply-interaction","update:isComparing","update:comparePeriodType","update:compareStartDate","update:compareEndDate"],methods:{translate:a,onApplyButtonInteraction(){this.isApplyEnabled||this.$emit("disabled-apply-interaction")}}});Jr.render=Fr;var Xr=Jr; /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */const Xr=["date","period","comparePeriods","comparePeriodType","compareDates","compareSegments"];function Zr(e,t){return`${e}|${t}`}function es(e){const t={};return Object.keys(e).filter(e=>!Xr.includes(e)).sort().forEach(o=>{t[o]=e[o]}),JSON.stringify(t)}function ts(e,t,o,i,n){return!o&&e===i&&t===n}function os(e,t,o,i){const n=!!o&&i===e,a=n&&o?Object.assign({},o):null;return{syncedUiSelection:a,lastKnownHashSelectionKey:e,lastKnownHashContextKey:t,nextHashUiSelection:null,nextHashSelectionKey:null,lastInteractionSource:null}}function is(e){if(e.pendingPresetSelection)return e.pendingPresetSelection.id;let t=null;return e.selectedPeriod===Vi?e.appliedRangeStartDate&&e.appliedRangeEndDate&&(t=`${e.appliedRangeStartDate},${e.appliedRangeEndDate}`):e.committedAnchorDate&&(t=d(e.committedAnchorDate)),t?fr(e.selectedPeriod,t,u()):null}function ns(e){e.calendarViewport=e.selectedPeriod===Vi?"range":"single",Ni(e.selectedPeriod)?e.singleCalendarPeriod=e.selectedPeriod:Ni(e.singleCalendarPeriod)||(e.singleCalendarPeriod="day"),e.selectedPeriod!==Vi?e.pendingPresetSelection&&Ni(e.pendingPresetSelection.period)?e.singleCalendarSelectedDate=e.pendingPresetSelection.selectedDate:e.singleCalendarSelectedDate=e.committedPeriod===e.selectedPeriod?e.committedAnchorDate:null:e.singleCalendarSelectedDate=null}var as=Object(D["defineComponent"])({name:"PeriodSelector",props:{periods:Array},components:{PeriodSelectorOptionsColumn:Vr,PeriodSelectorCalendarColumn:Jr,ActivityIndicator:We},directives:{ExpandOnClick:Et,Tooltips:ct},data(){const e=U.parsed.value.period,t=Ni(e)?e:"day",o=Ti(),i=xi();return{uiSelection:{type:"period",id:e},lastInteractionSource:null,nextHashUiSelection:null,nextHashSelectionKey:null,lastKnownHashSelectionKey:null,lastKnownHashContextKey:null,minAllowedDate:o,maxAllowedDate:i,pendingPresetSelection:null,committedPeriod:e,committedAnchorDate:null,selectedPeriod:e,calendarViewport:e===Vi?"range":"single",singleCalendarPeriod:t,singleCalendarSelectedDate:null,appliedRangeStartDate:null,appliedRangeEndDate:null,isRangeValid:null,isLoadingNewPage:!1,isComparing:null,comparePeriodType:"previousPeriod",compareStartDate:"",compareEndDate:"",compareAppliedSignature:"",shouldShowInvalidComparisonMessage:!1}},mounted(){M.on("hidePeriodSelector",()=>{window.$(this.$refs.root).parent("#periodString").hide()}),M.on("matomoPageChange",()=>{window.$(this.$refs.root).parent("#periodString").show()}),window.initTopControls(),this.handleZIndexPositionRelativeCompareDropdownIssue()},computed:{activePresetId(){return is(this)},matomoParsed(){return U.parsed.value},isComparingStoreValue(){return zo.isComparingPeriods()},periodComparisonsStoreValue(){return zo.getPeriodComparisons()},comparePeriodDropdownOptions(){return Pi},currentlyViewingText(){let e;if("range"===this.committedPeriod){if(!this.appliedRangeStartDate||!this.appliedRangeEndDate)return a("General_Error");e=`${this.appliedRangeStartDate},${this.appliedRangeEndDate}`}else{if(!this.committedAnchorDate)return a("General_Error");e=d(this.committedAnchorDate)}try{return c.parse(this.committedPeriod,e).getPrettyString()}catch(t){return a("General_Error")}},isComparisonEnabled(){return zo.isComparisonEnabled()},periodsFiltered(){return(this.periods||[]).filter(e=>c.isRecognizedPeriod(e))},selectedComparisonParams(){if(!this.isComparing)return{};if("custom"===this.comparePeriodType)return{comparePeriods:["range"],comparePeriodType:"custom",compareDates:[`${this.compareStartDate},${this.compareEndDate}`]};if("previousPeriod"===this.comparePeriodType)return{comparePeriods:[this.selectedPeriod],comparePeriodType:"previousPeriod",compareDates:[this.previousPeriodDateToSelectedPeriod]};if("previousYear"===this.comparePeriodType){const e="range"===this.selectedPeriod?`${this.appliedRangeStartDate},${this.appliedRangeEndDate}`:d(this.committedAnchorDate),t=c.parse(this.selectedPeriod,e).getDateRange();return t[0].setFullYear(t[0].getFullYear()-1),t[1].setFullYear(t[1].getFullYear()-1),"range"===this.selectedPeriod?{comparePeriods:["range"],comparePeriodType:"previousYear",compareDates:[`${d(t[0])},${d(t[1])}`]}:{comparePeriods:[this.selectedPeriod],comparePeriodType:"previousYear",compareDates:[d(t[0])]}}return console.warn("Unknown compare period type: "+this.comparePeriodType),{}},previousPeriodDateToSelectedPeriod(){if("range"===this.selectedPeriod){const e=p(this.appliedRangeStartDate),t=p(this.appliedRangeEndDate),o=k.getLastNRange("day",2,e).startDate,i=Math.floor((t.valueOf()-e.valueOf())/864e5),n=k.getLastNRange("day",1+i,o);return`${d(n.startDate)},${d(n.endDate)}`}const e=k.getLastNRange(this.selectedPeriod,2,this.committedAnchorDate).startDate;return d(e)},selectedDateString(){if("range"===this.selectedPeriod){const e=this.appliedRangeStartDate,t=this.appliedRangeEndDate,o=p(e),i=p(t);return!Bi(o)||!Bi(i)||o>i?(window.$("#alert").find("h2").text(a("General_InvalidDateRange")),M.helper.modalConfirm("#alert",{}),null):`${e},${t}`}return d(this.committedAnchorDate)},isErrorDisplayed(){return this.currentlyViewingText===a("General_Error")},isRangeSelection(){return"range"===this.committedPeriod},canShowMovePeriod(){return!this.isRangeSelection&&!this.isErrorDisplayed},compareCurrentSignature(){return JSON.stringify({isComparing:!!this.isComparing,comparePeriodType:this.comparePeriodType||"",compareStartDate:this.compareStartDate||"",compareEndDate:this.compareEndDate||""})},isCompareDirty(){return this.compareCurrentSignature!==this.compareAppliedSignature},hasPendingNonRangePeriodChange(){return"period"===this.uiSelection.type&&"period"===this.lastInteractionSource&&this.selectedPeriod!==Vi&&this.selectedPeriod!==this.committedPeriod},isRangePresetSelection(){return"preset"===this.uiSelection.type&&this.selectedPeriod===Vi},displayRangeStartDate(){return this.isRangePresetSelection&&this.pendingPresetSelection?d(this.pendingPresetSelection.startDate):this.appliedRangeStartDate},displayRangeEndDate(){return this.isRangePresetSelection&&this.pendingPresetSelection?d(this.pendingPresetSelection.endDate):this.appliedRangeEndDate}},watch:{isComparingStoreValue:{immediate:!0,handler(e){this.isComparing=e}},matomoParsed:{immediate:!0,handler(){this.updateSelectedValuesFromHash()}},periodComparisonsStoreValue:{immediate:!0,handler(){this.updateComparisonValuesFromStore(),this.compareAppliedSignature=this.compareCurrentSignature}}},methods:{onExpand(e){if(qa(e)){const e=this.$refs.root,t="preset"===this.uiSelection.type?"#preset_date_"+this.uiSelection.id:"#period_id_"+this.uiSelection.id,o=e.querySelector(t)||e.querySelector("#preset_date_today");o instanceof HTMLElement&&o.focus()}},onClosed(e){qa(e)&&window.$(this.$refs.title).focus()},handleZIndexPositionRelativeCompareDropdownIssue(){const e=window.$(this.$refs.root);e.on("focus","#comparePeriodToDropdown .select-dropdown",()=>{e.addClass("compare-dropdown-open")}).on("blur","#comparePeriodToDropdown .select-dropdown",()=>{e.removeClass("compare-dropdown-open")})},setUiSelection(e,t){this.uiSelection=e,this.lastInteractionSource=t},clearPresetSelection(){this.pendingPresetSelection=null},setPendingPeriodAndDate(e,t){this.committedPeriod=e,this.selectedPeriod=e,this.committedAnchorDate=t,this.setRangeStartEndFromPeriod(e,d(t)),ns(this)},setPiwikPeriodAndDate(e,t){this.setPendingPeriodAndDate(e,t),this.setUiSelection({type:"period",id:e},"period");const o=d(t);this.clearPresetSelection(),this.commitSelectionToUrl(o,this.selectedPeriod)},commitSelectionToUrl(e,t){this.nextHashUiSelection=Object.assign({},this.uiSelection),this.nextHashSelectionKey=Zr(t,e),this.compareAppliedSignature=this.compareCurrentSignature,this.propagateNewUrlParams(e,t),window.initTopControls()},onPeriodOptionSelected(e){this.setUiSelection({type:"period",id:e.period},"period"),this.selectedPeriod=e.period,this.clearPresetSelection(),ns(this),e.period===Vi&&(this.isRangeValid=!0)},onPeriodOptionDblClick(e){this.onPeriodOptionSelected(e),this.hasInvalidCustomComparison()?this.showInvalidComparisonMessage():e.period!==Vi&&e.period!==this.committedPeriod&&this.committedAnchorDate&&this.setPiwikPeriodAndDate(e.period,this.committedAnchorDate)},canInteractWithSingleCalendar(){return"single"===this.calendarViewport&&this.selectedPeriod!==Vi},onDatePickerSelected(e){this.canInteractWithSingleCalendar()&&(this.setUiSelection({type:"period",id:this.selectedPeriod},"calendar"),this.setPendingPeriodAndDate(this.selectedPeriod,e),this.clearPresetSelection(),ns(this),this.commitSelectionToUrl(d(e),this.selectedPeriod))},onPresetDateRangeSelected(e){this.periodsFiltered.includes(e.period)&&(this.selectedPeriod=e.period,this.pendingPresetSelection=e,this.isRangeValid=e.period===Vi||this.isRangeValid,this.setUiSelection({type:"preset",id:e.id},"preset"),ns(this))},onPresetDateRangeDblClick(e){this.onPresetDateRangeSelected(e),this.hasInvalidCustomComparison()?this.showInvalidComparisonMessage():this.onApplyClicked()},propagateNewUrlParams(e,t){const o=this.selectedComparisonParams;let i;M.helper.isReportingPage()?(this.closePeriodSelector(),i=U.hashParsed.value):(this.isLoadingNewPage=!0,i=U.parsed.value),U.updateLocation(Object.assign(Object.assign({},Wa(i)),{},{date:e,period:t},o))},hasPendingPresetSelectionOwnedByUi(){return!!this.pendingPresetSelection&&"preset"===this.uiSelection.type&&this.pendingPresetSelection.id===this.uiSelection.id},shouldCloseSelectorWithoutApplying(){return this.selectedPeriod!==Vi&&!this.hasPendingNonRangePeriodChange},hasCommittedRangeBounds(){return!!this.appliedRangeStartDate&&!!this.appliedRangeEndDate},applyPendingPresetSelection(){if(!this.hasPendingPresetSelectionOwnedByUi())return!1;const e=this.pendingPresetSelection;return this.committedPeriod=e.period,this.committedAnchorDate=e.selectedDate,this.appliedRangeStartDate=d(e.startDate),this.appliedRangeEndDate=d(e.endDate),this.setUiSelection({type:"period",id:e.period},"preset"),this.pendingPresetSelection=null,ns(this),this.commitSelectionToUrl(e.urlDate,e.period),!0},applyRangeSelection(){if(this.selectedPeriod!==Vi)return!1;const e=this.selectedDateString;return!e||(this.committedPeriod=Vi,this.commitSelectionToUrl(this.getCurrentRollingDateParamIfOwnedByPreset()||e,Vi),!0)},applyNonRangeOrCompareChanges(){const e=Ua({hasPendingNonRangePeriodChange:this.hasPendingNonRangePeriodChange,isCompareDirty:this.isCompareDirty,shouldCloseSelectorWithoutApplying:this.shouldCloseSelectorWithoutApplying(),appliedPeriod:this.committedPeriod,hasCommittedRangeBounds:this.hasCommittedRangeBounds(),rollingDateParam:this.getCurrentRollingDateParamIfOwnedByPreset(),appliedRangeStartDate:this.appliedRangeStartDate,appliedRangeEndDate:this.appliedRangeEndDate,formattedAppliedAnchorDate:this.committedAnchorDate?d(this.committedAnchorDate):null});"stop"!==e.type&&("close"!==e.type?this.commitSelectionToUrl(e.date,e.period):this.closePeriodSelector())},onApplyClicked(){this.applyPendingPresetSelection()||this.applyRangeSelection()||this.applyNonRangeOrCompareChanges()},updateComparisonValuesFromStore(){this.comparePeriodType="previousPeriod",this.compareStartDate="",this.compareEndDate="";const e=zo.getPeriodComparisons();if(e.length<2)return;const t=U.parsed.value.comparePeriodType;if(!Ei.includes(t))return;if(this.comparePeriodType=t,"custom"!==this.comparePeriodType||"range"!==e[1].params.period)return;let o;try{o=c.parse(e[1].params.period,e[1].params.date)}catch(a){return}const[i,n]=o.getDateRange();this.compareStartDate=d(i),this.compareEndDate=d(n)},getCurrentContextKey(){return es(U.parsed.value)},applyUiSelectionFromHash(e,t,o){if(o){if("preset"===o.type)return void(this.uiSelection=o);const i=sr(e,t);return i&&this.periodsFiltered.includes(e)?void(this.uiSelection={type:"preset",id:i}):void(this.uiSelection=o)}const i=sr(e,t);if(i&&this.periodsFiltered.includes(e))return this.uiSelection={type:"preset",id:i},void(this.pendingPresetSelection=null);this.setUiSelection({type:"period",id:e},null),this.clearPresetSelection()},getCurrentRollingDateParamIfOwnedByPreset(){if("preset"!==this.uiSelection.type)return null;const e=U.parsed.value.period||"",t=U.parsed.value.date||"";if(e!==this.committedPeriod||!t)return null;const o=sr(e,t);return o!==this.uiSelection.id?null:t},resetSelectedDateValues(){this.committedAnchorDate=null,this.appliedRangeStartDate=null,this.appliedRangeEndDate=null},applyDateValuesFromHash(e,t){if(e===Vi){const o=c.get(e).parse(t),[i,n]=o.getDateRange();return this.committedAnchorDate=i,this.appliedRangeStartDate=d(i),void(this.appliedRangeEndDate=d(n))}this.committedAnchorDate=p(t),this.setRangeStartEndFromPeriod(e,t),Ni(e)&&(this.singleCalendarPeriod=e),this.singleCalendarSelectedDate=this.committedAnchorDate},updateSelectedValuesFromHash(){const e=U.parsed.value.date||"",t=U.parsed.value.period||"",o=Zr(t,e),i=this.getCurrentContextKey();if(ts(o,i,this.nextHashUiSelection,this.lastKnownHashSelectionKey,this.lastKnownHashContextKey))return;const n=os(o,i,this.nextHashUiSelection,this.nextHashSelectionKey);this.nextHashUiSelection=n.nextHashUiSelection,this.nextHashSelectionKey=n.nextHashSelectionKey,this.lastInteractionSource=n.lastInteractionSource,this.lastKnownHashSelectionKey=n.lastKnownHashSelectionKey,this.lastKnownHashContextKey=n.lastKnownHashContextKey,this.applyUiSelectionFromHash(t,e,n.syncedUiSelection),this.committedPeriod=t,this.selectedPeriod=t,this.resetSelectedDateValues();try{c.parse(t,e)}catch(a){return void(this.isRangeValid=t!==Vi&&null)}this.applyDateValuesFromHash(t,e),this.isRangeValid=t===Vi||null,this.pendingPresetSelection=null,ns(this),this.compareAppliedSignature=this.compareCurrentSignature},setRangeStartEndFromPeriod(e,t){const o=c.parse(e,t).getDateRange();this.appliedRangeStartDate=d(o[0]this.maxAllowedDate?this.maxAllowedDate:o[1])},canInteractWithRangeCalendar(){return"range"===this.calendarViewport&&this.selectedPeriod===Vi},onRangeChange(e,t){this.canInteractWithRangeCalendar()&&(e&&t?(this.isRangeValid=!0,this.appliedRangeStartDate=e,this.appliedRangeEndDate=t,this.setUiSelection({type:"period",id:Vi},"range"),this.clearPresetSelection()):this.isRangeValid=!1)},isApplyEnabled(){return $a({uiSelectionType:this.uiSelection.type,uiSelectedPeriod:this.selectedPeriod,hasPendingNonRangePeriodChange:this.hasPendingNonRangePeriodChange,hasPendingPresetSelection:!!this.pendingPresetSelection,isRangeValid:this.isRangeValid,isCompareDirty:this.isCompareDirty,isComparing:this.isComparing,comparePeriodType:this.comparePeriodType,isCompareRangeValid:this.isCompareRangeValid()})},shouldDisplayInvalidComparisonMessage(){return this.shouldShowInvalidComparisonMessage&&this.hasInvalidCustomComparison()},hasInvalidCustomComparison(){return!!this.isComparing&&"custom"===this.comparePeriodType&&!this.isCompareRangeValid()},showInvalidComparisonMessage(){this.hasInvalidCustomComparison()&&(this.shouldShowInvalidComparisonMessage=!0)},dismissInvalidComparisonMessage(){this.shouldShowInvalidComparisonMessage=!1},onDisabledApplyInteraction(){this.showInvalidComparisonMessage()},onCompareToggleUpdated(e){this.isComparing=e,this.dismissInvalidComparisonMessage()},onComparePeriodTypeUpdated(e){this.comparePeriodType=e,this.dismissInvalidComparisonMessage()},onCompareStartDateUpdated(e){this.compareStartDate=e,this.dismissInvalidComparisonMessage()},onCompareEndDateUpdated(e){this.compareEndDate=e,this.dismissInvalidComparisonMessage()},closePeriodSelector(){this.$refs.root.classList.remove("expanded")},isCompareRangeValid(){try{p(this.compareStartDate)}catch(e){return!1}try{p(this.compareEndDate)}catch(e){return!1}return!0},movePeriod(e){if(!this.canMovePeriod(e))return;const t=this.committedAnchorDate||new Date,o=za(t,this.committedPeriod,e),i=Ga(o,this.minAllowedDate,this.maxAllowedDate);this.setPiwikPeriodAndDate(this.committedPeriod,i)},isPeriodMoveDisabled(e){return null===this.committedAnchorDate?this.isRangeSelection:this.isRangeSelection||!this.canMovePeriod(e)},canMovePeriod(e){if(null===this.committedAnchorDate)return!1;const t=-1===e?this.minAllowedDate:this.maxAllowedDate;return!g(this.committedAnchorDate,t,this.committedPeriod)}}});as.render=Ha;var rs=as;const ss={class:"reportingMenu"},ls=["aria-label"],cs=["data-category-id"],ds=["onClick"],us={class:"hidden"},ps={key:2,role:"menu"},ms=["href","onClick","title"],hs=["href","onClick"],gs=["onClick"],bs=Object(D["createElementVNode"])("span",{class:"icon-help"},null,-1),fs=[bs],vs={id:"mobile-left-menu",class:"sidenav sidenav--reporting-menu-mobile hide-on-large-only"},Os=["data-category-id"],ys={key:1,class:"collapsible collapsible-accordion"},js={class:"collapsible-header"},ws={class:"collapsible-body"},Ss=["onClick","href"],Cs=["onClick","href"];function ks(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("MenuItemsDropdown"),s=Object(D["resolveDirective"])("side-nav");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",ss,[Object(D["createElementVNode"])("ul",{class:"navbar hide-on-med-and-down collapsible",role:"menu","aria-label":e.translate("CoreHome_MainNavigation")},[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.menu,t=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{class:Object(D["normalizeClass"])(["menuTab",{active:t.id===e.activeCategory}]),role:"menuitem",key:t.id,"data-category-id":t.id},[t.component?(Object(D["openBlock"])(),Object(D["createBlock"])(Object(D["resolveDynamicComponent"])(t.component),{key:0,onAction:o=>e.loadCategory(t)},null,40,["onAction"])):Object(D["createCommentVNode"])("",!0),t.component?Object(D["createCommentVNode"])("",!0):(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:1,class:"item",tabindex:"5",href:"",onClick:Object(D["withModifiers"])(o=>e.loadCategory(t),["prevent"])},[Object(D["createElementVNode"])("span",{class:Object(D["normalizeClass"])("menu-icon "+(t.icon?t.icon:t.subcategories&&t.id===e.activeCategory?"icon-chevron-down":"icon-chevron-right"))},null,2),Object(D["createTextVNode"])(Object(D["toDisplayString"])(t.name)+" ",1),Object(D["createElementVNode"])("span",us,Object(D["toDisplayString"])(e.translate("CoreHome_Menu")),1)],8,ds)),t.component?Object(D["createCommentVNode"])("",!0):(Object(D["openBlock"])(),Object(D["createElementBlock"])("ul",ps,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(t.subcategories,o=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{role:"menuitem",class:Object(D["normalizeClass"])({active:(o.id===e.displayedSubcategory||o.isGroup&&e.activeSubsubcategory===e.displayedSubcategory)&&t.id===e.displayedCategory}),key:o.id},[o.isGroup?(Object(D["openBlock"])(),Object(D["createBlock"])(r,{key:0,"show-search":!0,"menu-title":e.htmlEntities(o.name)},{default:Object(D["withCtx"])(()=>[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(o.subcategories,i=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{class:Object(D["normalizeClass"])(["item",{active:i.id===e.activeSubsubcategory&&o.id===e.displayedSubcategory&&t.id===e.displayedCategory}]),tabindex:"5",href:"#?"+e.makeUrl(t,i),onClick:o=>e.loadSubcategory(t,i,o),title:i.tooltip,key:i.id},Object(D["toDisplayString"])(i.name),11,ms))),128))]),_:2},1032,["menu-title"])):Object(D["createCommentVNode"])("",!0),o.isGroup?Object(D["createCommentVNode"])("",!0):(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:1,href:"#?"+e.makeUrl(t,o),class:"item",onClick:i=>e.loadSubcategory(t,o,i),tabindex:"5"},Object(D["toDisplayString"])(o.name),9,hs)),o.help?(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:2,class:Object(D["normalizeClass"])(["item-help-icon",{active:e.helpShownCategory&&e.helpShownCategory.subcategory===o.id&&e.helpShownCategory.category===t.id&&o.help}]),tabindex:"5",href:"javascript:",onClick:i=>e.showHelp(t,o,i)},fs,10,gs)):Object(D["createCommentVNode"])("",!0)],2))),128))]))],10,cs))),128))],8,ls),Object(D["createElementVNode"])("ul",vs,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.menu,t=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{class:"no-padding",key:t.id,"data-category-id":t.id},[t.component?(Object(D["openBlock"])(),Object(D["createBlock"])(Object(D["resolveDynamicComponent"])(t.component),{key:0,onAction:o=>e.loadCategory(t)},null,40,["onAction"])):Object(D["createCommentVNode"])("",!0),t.component?Object(D["createCommentVNode"])("",!0):Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("ul",ys,[Object(D["createElementVNode"])("li",null,[Object(D["createElementVNode"])("a",js,[Object(D["createElementVNode"])("i",{class:Object(D["normalizeClass"])(t.icon?t.icon:"icon-chevron-down")},null,2),Object(D["createTextVNode"])(Object(D["toDisplayString"])(t.name),1)]),Object(D["createElementVNode"])("div",ws,[Object(D["createElementVNode"])("ul",null,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(t.subcategories,o=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{key:o.id},[o.isGroup?(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],{key:0},Object(D["renderList"])(o.subcategories,o=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{onClick:i=>e.loadSubcategory(t,o),href:"#?"+e.makeUrl(t,o),key:o.id},Object(D["toDisplayString"])(o.name),9,Ss))),128)):Object(D["createCommentVNode"])("",!0),o.isGroup?Object(D["createCommentVNode"])("",!0):(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:1,onClick:i=>e.loadSubcategory(t,o),href:"#?"+e.makeUrl(t,o)},Object(D["toDisplayString"])(o.name),9,Cs))]))),128))])])])])),[[s,{activator:e.sideNavActivator}]])],8,Os))),128))])])}function Ds(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} + */const Zr=["date","period","comparePeriods","comparePeriodType","compareDates","compareSegments"];function es(e,t){return`${e}|${t}`}function ts(e){const t={};return Object.keys(e).filter(e=>!Zr.includes(e)).sort().forEach(o=>{t[o]=e[o]}),JSON.stringify(t)}function os(e,t,o,i,n){return!o&&e===i&&t===n}function is(e,t,o,i){const n=!!o&&i===e,a=n&&o?Object.assign({},o):null;return{syncedUiSelection:a,lastKnownHashSelectionKey:e,lastKnownHashContextKey:t,nextHashUiSelection:null,nextHashSelectionKey:null,lastInteractionSource:null}}function ns(e){if(e.pendingPresetSelection)return e.pendingPresetSelection.id;let t=null;return e.selectedPeriod===Bi?e.appliedRangeStartDate&&e.appliedRangeEndDate&&(t=`${e.appliedRangeStartDate},${e.appliedRangeEndDate}`):e.committedAnchorDate&&(t=d(e.committedAnchorDate)),t?vr(e.selectedPeriod,t,u()):null}function as(e){e.calendarViewport=e.selectedPeriod===Bi?"range":"single",Mi(e.selectedPeriod)?e.singleCalendarPeriod=e.selectedPeriod:Mi(e.singleCalendarPeriod)||(e.singleCalendarPeriod="day"),e.selectedPeriod!==Bi?e.pendingPresetSelection&&Mi(e.pendingPresetSelection.period)?e.singleCalendarSelectedDate=e.pendingPresetSelection.selectedDate:e.singleCalendarSelectedDate=e.committedPeriod===e.selectedPeriod?e.committedAnchorDate:null:e.singleCalendarSelectedDate=null}var rs=Object(D["defineComponent"])({name:"PeriodSelector",props:{periods:Array},components:{PeriodSelectorOptionsColumn:Br,PeriodSelectorCalendarColumn:Xr,ActivityIndicator:ze},directives:{ExpandOnClick:Pt,Tooltips:dt},data(){const e=U.parsed.value.period,t=Mi(e)?e:"day",o=xi(),i=Vi();return{uiSelection:{type:"period",id:e},lastInteractionSource:null,nextHashUiSelection:null,nextHashSelectionKey:null,lastKnownHashSelectionKey:null,lastKnownHashContextKey:null,minAllowedDate:o,maxAllowedDate:i,pendingPresetSelection:null,committedPeriod:e,committedAnchorDate:null,selectedPeriod:e,calendarViewport:e===Bi?"range":"single",singleCalendarPeriod:t,singleCalendarSelectedDate:null,appliedRangeStartDate:null,appliedRangeEndDate:null,isRangeValid:null,isLoadingNewPage:!1,isComparing:null,comparePeriodType:"previousPeriod",compareStartDate:"",compareEndDate:"",compareAppliedSignature:"",shouldShowInvalidComparisonMessage:!1}},mounted(){I.on("hidePeriodSelector",()=>{window.$(this.$refs.root).parent("#periodString").hide()}),I.on("matomoPageChange",()=>{window.$(this.$refs.root).parent("#periodString").show()}),window.initTopControls(),this.handleZIndexPositionRelativeCompareDropdownIssue()},computed:{activePresetId(){return ns(this)},matomoParsed(){return U.parsed.value},isComparingStoreValue(){return Go.isComparingPeriods()},periodComparisonsStoreValue(){return Go.getPeriodComparisons()},comparePeriodDropdownOptions(){return Ti},currentlyViewingText(){let e;if("range"===this.committedPeriod){if(!this.appliedRangeStartDate||!this.appliedRangeEndDate)return a("General_Error");e=`${this.appliedRangeStartDate},${this.appliedRangeEndDate}`}else{if(!this.committedAnchorDate)return a("General_Error");e=d(this.committedAnchorDate)}try{return c.parse(this.committedPeriod,e).getPrettyString()}catch(t){return a("General_Error")}},isComparisonEnabled(){return Go.isComparisonEnabled()},periodsFiltered(){return(this.periods||[]).filter(e=>c.isRecognizedPeriod(e))},selectedComparisonParams(){if(!this.isComparing)return{};if("custom"===this.comparePeriodType)return{comparePeriods:["range"],comparePeriodType:"custom",compareDates:[`${this.compareStartDate},${this.compareEndDate}`]};if("previousPeriod"===this.comparePeriodType)return{comparePeriods:[this.selectedPeriod],comparePeriodType:"previousPeriod",compareDates:[this.previousPeriodDateToSelectedPeriod]};if("previousYear"===this.comparePeriodType){const e="range"===this.selectedPeriod?`${this.appliedRangeStartDate},${this.appliedRangeEndDate}`:d(this.committedAnchorDate),t=c.parse(this.selectedPeriod,e).getDateRange();return t[0].setFullYear(t[0].getFullYear()-1),t[1].setFullYear(t[1].getFullYear()-1),"range"===this.selectedPeriod?{comparePeriods:["range"],comparePeriodType:"previousYear",compareDates:[`${d(t[0])},${d(t[1])}`]}:{comparePeriods:[this.selectedPeriod],comparePeriodType:"previousYear",compareDates:[d(t[0])]}}return console.warn("Unknown compare period type: "+this.comparePeriodType),{}},previousPeriodDateToSelectedPeriod(){if("range"===this.selectedPeriod){const e=m(this.appliedRangeStartDate),t=m(this.appliedRangeEndDate),o=k.getLastNRange("day",2,e).startDate,i=Math.floor((t.valueOf()-e.valueOf())/864e5),n=k.getLastNRange("day",1+i,o);return`${d(n.startDate)},${d(n.endDate)}`}const e=k.getLastNRange(this.selectedPeriod,2,this.committedAnchorDate).startDate;return d(e)},selectedDateString(){if("range"===this.selectedPeriod){const e=this.appliedRangeStartDate,t=this.appliedRangeEndDate,o=m(e),i=m(t);return!Ni(o)||!Ni(i)||o>i?(window.$("#alert").find("h2").text(a("General_InvalidDateRange")),I.helper.modalConfirm("#alert",{}),null):`${e},${t}`}return d(this.committedAnchorDate)},isErrorDisplayed(){return this.currentlyViewingText===a("General_Error")},isRangeSelection(){return"range"===this.committedPeriod},canShowMovePeriod(){return!this.isRangeSelection&&!this.isErrorDisplayed},compareCurrentSignature(){return JSON.stringify({isComparing:!!this.isComparing,comparePeriodType:this.comparePeriodType||"",compareStartDate:this.compareStartDate||"",compareEndDate:this.compareEndDate||""})},isCompareDirty(){return this.compareCurrentSignature!==this.compareAppliedSignature},hasPendingNonRangePeriodChange(){return"period"===this.uiSelection.type&&"period"===this.lastInteractionSource&&this.selectedPeriod!==Bi&&this.selectedPeriod!==this.committedPeriod},isRangePresetSelection(){return"preset"===this.uiSelection.type&&this.selectedPeriod===Bi},displayRangeStartDate(){return this.isRangePresetSelection&&this.pendingPresetSelection?d(this.pendingPresetSelection.startDate):this.appliedRangeStartDate},displayRangeEndDate(){return this.isRangePresetSelection&&this.pendingPresetSelection?d(this.pendingPresetSelection.endDate):this.appliedRangeEndDate}},watch:{isComparingStoreValue:{immediate:!0,handler(e){this.isComparing=e}},matomoParsed:{immediate:!0,handler(){this.updateSelectedValuesFromHash()}},periodComparisonsStoreValue:{immediate:!0,handler(){this.updateComparisonValuesFromStore(),this.compareAppliedSignature=this.compareCurrentSignature}}},methods:{onExpand(e){if(Wa(e)){const e=this.$refs.root,t="preset"===this.uiSelection.type?"#preset_date_"+this.uiSelection.id:"#period_id_"+this.uiSelection.id,o=e.querySelector(t)||e.querySelector("#preset_date_today");o instanceof HTMLElement&&o.focus()}},onClosed(e){Wa(e)&&window.$(this.$refs.title).focus()},handleZIndexPositionRelativeCompareDropdownIssue(){const e=window.$(this.$refs.root);e.on("focus","#comparePeriodToDropdown .select-dropdown",()=>{e.addClass("compare-dropdown-open")}).on("blur","#comparePeriodToDropdown .select-dropdown",()=>{e.removeClass("compare-dropdown-open")})},setUiSelection(e,t){this.uiSelection=e,this.lastInteractionSource=t},clearPresetSelection(){this.pendingPresetSelection=null},setPendingPeriodAndDate(e,t){this.committedPeriod=e,this.selectedPeriod=e,this.committedAnchorDate=t,this.setRangeStartEndFromPeriod(e,d(t)),as(this)},setPiwikPeriodAndDate(e,t){this.setPendingPeriodAndDate(e,t),this.setUiSelection({type:"period",id:e},"period");const o=d(t);this.clearPresetSelection(),this.commitSelectionToUrl(o,this.selectedPeriod)},commitSelectionToUrl(e,t){this.nextHashUiSelection=Object.assign({},this.uiSelection),this.nextHashSelectionKey=es(t,e),this.compareAppliedSignature=this.compareCurrentSignature,this.propagateNewUrlParams(e,t),window.initTopControls()},onPeriodOptionSelected(e){this.setUiSelection({type:"period",id:e.period},"period"),this.selectedPeriod=e.period,this.clearPresetSelection(),as(this),e.period===Bi&&(this.isRangeValid=!0)},onPeriodOptionDblClick(e){this.onPeriodOptionSelected(e),this.hasInvalidCustomComparison()?this.showInvalidComparisonMessage():e.period!==Bi&&e.period!==this.committedPeriod&&this.committedAnchorDate&&this.setPiwikPeriodAndDate(e.period,this.committedAnchorDate)},canInteractWithSingleCalendar(){return"single"===this.calendarViewport&&this.selectedPeriod!==Bi},onDatePickerSelected(e){this.canInteractWithSingleCalendar()&&(this.setUiSelection({type:"period",id:this.selectedPeriod},"calendar"),this.setPendingPeriodAndDate(this.selectedPeriod,e),this.clearPresetSelection(),as(this),this.commitSelectionToUrl(d(e),this.selectedPeriod))},onPresetDateRangeSelected(e){this.periodsFiltered.includes(e.period)&&(this.selectedPeriod=e.period,this.pendingPresetSelection=e,this.isRangeValid=e.period===Bi||this.isRangeValid,this.setUiSelection({type:"preset",id:e.id},"preset"),as(this))},onPresetDateRangeDblClick(e){this.onPresetDateRangeSelected(e),this.hasInvalidCustomComparison()?this.showInvalidComparisonMessage():this.onApplyClicked()},propagateNewUrlParams(e,t){const o=this.selectedComparisonParams;let i;I.helper.isReportingPage()?(this.closePeriodSelector(),i=U.hashParsed.value):(this.isLoadingNewPage=!0,i=U.parsed.value),U.updateLocation(Object.assign(Object.assign({},za(i)),{},{date:e,period:t},o))},hasPendingPresetSelectionOwnedByUi(){return!!this.pendingPresetSelection&&"preset"===this.uiSelection.type&&this.pendingPresetSelection.id===this.uiSelection.id},shouldCloseSelectorWithoutApplying(){return this.selectedPeriod!==Bi&&!this.hasPendingNonRangePeriodChange},hasCommittedRangeBounds(){return!!this.appliedRangeStartDate&&!!this.appliedRangeEndDate},applyPendingPresetSelection(){if(!this.hasPendingPresetSelectionOwnedByUi())return!1;const e=this.pendingPresetSelection;return this.committedPeriod=e.period,this.committedAnchorDate=e.selectedDate,this.appliedRangeStartDate=d(e.startDate),this.appliedRangeEndDate=d(e.endDate),this.setUiSelection({type:"period",id:e.period},"preset"),this.pendingPresetSelection=null,as(this),this.commitSelectionToUrl(e.urlDate,e.period),!0},applyRangeSelection(){if(this.selectedPeriod!==Bi)return!1;const e=this.selectedDateString;return!e||(this.committedPeriod=Bi,this.commitSelectionToUrl(this.getCurrentRollingDateParamIfOwnedByPreset()||e,Bi),!0)},applyNonRangeOrCompareChanges(){const e=qa({hasPendingNonRangePeriodChange:this.hasPendingNonRangePeriodChange,isCompareDirty:this.isCompareDirty,shouldCloseSelectorWithoutApplying:this.shouldCloseSelectorWithoutApplying(),appliedPeriod:this.committedPeriod,hasCommittedRangeBounds:this.hasCommittedRangeBounds(),rollingDateParam:this.getCurrentRollingDateParamIfOwnedByPreset(),appliedRangeStartDate:this.appliedRangeStartDate,appliedRangeEndDate:this.appliedRangeEndDate,formattedAppliedAnchorDate:this.committedAnchorDate?d(this.committedAnchorDate):null});"stop"!==e.type&&("close"!==e.type?this.commitSelectionToUrl(e.date,e.period):this.closePeriodSelector())},onApplyClicked(){this.applyPendingPresetSelection()||this.applyRangeSelection()||this.applyNonRangeOrCompareChanges()},updateComparisonValuesFromStore(){this.comparePeriodType="previousPeriod",this.compareStartDate="",this.compareEndDate="";const e=Go.getPeriodComparisons();if(e.length<2)return;const t=U.parsed.value.comparePeriodType;if(!Pi.includes(t))return;if(this.comparePeriodType=t,"custom"!==this.comparePeriodType||"range"!==e[1].params.period)return;let o;try{o=c.parse(e[1].params.period,e[1].params.date)}catch(a){return}const[i,n]=o.getDateRange();this.compareStartDate=d(i),this.compareEndDate=d(n)},getCurrentContextKey(){return ts(U.parsed.value)},applyUiSelectionFromHash(e,t,o){if(o){if("preset"===o.type)return void(this.uiSelection=o);const i=lr(e,t);return i&&this.periodsFiltered.includes(e)?void(this.uiSelection={type:"preset",id:i}):void(this.uiSelection=o)}const i=lr(e,t);if(i&&this.periodsFiltered.includes(e))return this.uiSelection={type:"preset",id:i},void(this.pendingPresetSelection=null);this.setUiSelection({type:"period",id:e},null),this.clearPresetSelection()},getCurrentRollingDateParamIfOwnedByPreset(){if("preset"!==this.uiSelection.type)return null;const e=U.parsed.value.period||"",t=U.parsed.value.date||"";if(e!==this.committedPeriod||!t)return null;const o=lr(e,t);return o!==this.uiSelection.id?null:t},resetSelectedDateValues(){this.committedAnchorDate=null,this.appliedRangeStartDate=null,this.appliedRangeEndDate=null},applyDateValuesFromHash(e,t){if(e===Bi){const o=c.get(e).parse(t),[i,n]=o.getDateRange();return this.committedAnchorDate=i,this.appliedRangeStartDate=d(i),void(this.appliedRangeEndDate=d(n))}this.committedAnchorDate=m(t),this.setRangeStartEndFromPeriod(e,t),Mi(e)&&(this.singleCalendarPeriod=e),this.singleCalendarSelectedDate=this.committedAnchorDate},updateSelectedValuesFromHash(){const e=U.parsed.value.date||"",t=U.parsed.value.period||"",o=es(t,e),i=this.getCurrentContextKey();if(os(o,i,this.nextHashUiSelection,this.lastKnownHashSelectionKey,this.lastKnownHashContextKey))return;const n=is(o,i,this.nextHashUiSelection,this.nextHashSelectionKey);this.nextHashUiSelection=n.nextHashUiSelection,this.nextHashSelectionKey=n.nextHashSelectionKey,this.lastInteractionSource=n.lastInteractionSource,this.lastKnownHashSelectionKey=n.lastKnownHashSelectionKey,this.lastKnownHashContextKey=n.lastKnownHashContextKey,this.applyUiSelectionFromHash(t,e,n.syncedUiSelection),this.committedPeriod=t,this.selectedPeriod=t,this.resetSelectedDateValues();try{c.parse(t,e)}catch(a){return void(this.isRangeValid=t!==Bi&&null)}this.applyDateValuesFromHash(t,e),this.isRangeValid=t===Bi||null,this.pendingPresetSelection=null,as(this),this.compareAppliedSignature=this.compareCurrentSignature},setRangeStartEndFromPeriod(e,t){const o=c.parse(e,t).getDateRange();this.appliedRangeStartDate=d(o[0]this.maxAllowedDate?this.maxAllowedDate:o[1])},canInteractWithRangeCalendar(){return"range"===this.calendarViewport&&this.selectedPeriod===Bi},onRangeChange(e,t){this.canInteractWithRangeCalendar()&&(e&&t?(this.isRangeValid=!0,this.appliedRangeStartDate=e,this.appliedRangeEndDate=t,this.setUiSelection({type:"period",id:Bi},"range"),this.clearPresetSelection()):this.isRangeValid=!1)},isApplyEnabled(){return Ua({uiSelectionType:this.uiSelection.type,uiSelectedPeriod:this.selectedPeriod,hasPendingNonRangePeriodChange:this.hasPendingNonRangePeriodChange,hasPendingPresetSelection:!!this.pendingPresetSelection,isRangeValid:this.isRangeValid,isCompareDirty:this.isCompareDirty,isComparing:this.isComparing,comparePeriodType:this.comparePeriodType,isCompareRangeValid:this.isCompareRangeValid()})},shouldDisplayInvalidComparisonMessage(){return this.shouldShowInvalidComparisonMessage&&this.hasInvalidCustomComparison()},hasInvalidCustomComparison(){return!!this.isComparing&&"custom"===this.comparePeriodType&&!this.isCompareRangeValid()},showInvalidComparisonMessage(){this.hasInvalidCustomComparison()&&(this.shouldShowInvalidComparisonMessage=!0)},dismissInvalidComparisonMessage(){this.shouldShowInvalidComparisonMessage=!1},onDisabledApplyInteraction(){this.showInvalidComparisonMessage()},onCompareToggleUpdated(e){this.isComparing=e,this.dismissInvalidComparisonMessage()},onComparePeriodTypeUpdated(e){this.comparePeriodType=e,this.dismissInvalidComparisonMessage()},onCompareStartDateUpdated(e){this.compareStartDate=e,this.dismissInvalidComparisonMessage()},onCompareEndDateUpdated(e){this.compareEndDate=e,this.dismissInvalidComparisonMessage()},closePeriodSelector(){this.$refs.root.classList.remove("expanded")},isCompareRangeValid(){try{m(this.compareStartDate)}catch(e){return!1}try{m(this.compareEndDate)}catch(e){return!1}return!0},movePeriod(e){if(!this.canMovePeriod(e))return;const t=this.committedAnchorDate||new Date,o=Ga(t,this.committedPeriod,e),i=Ka(o,this.minAllowedDate,this.maxAllowedDate);this.setPiwikPeriodAndDate(this.committedPeriod,i)},isPeriodMoveDisabled(e){return null===this.committedAnchorDate?this.isRangeSelection:this.isRangeSelection||!this.canMovePeriod(e)},canMovePeriod(e){if(null===this.committedAnchorDate)return!1;const t=-1===e?this.minAllowedDate:this.maxAllowedDate;return!g(this.committedAnchorDate,t,this.committedPeriod)}}});rs.render=$a;var ss=rs;const ls={class:"reportingMenu"},cs=["aria-label"],ds=["data-category-id"],us=["onClick"],ms={class:"hidden"},ps={key:2,role:"menu"},hs=["href","onClick","title"],gs=["href","onClick"],bs=["onClick"],fs=Object(D["createElementVNode"])("span",{class:"icon-help"},null,-1),vs=[fs],Os={id:"mobile-left-menu",class:"sidenav sidenav--reporting-menu-mobile hide-on-large-only"},ys=["data-category-id"],js={key:1,class:"collapsible collapsible-accordion"},ws={class:"collapsible-header"},Ss={class:"collapsible-body"},Cs=["onClick","href"],ks=["onClick","href"];function Ds(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("MenuItemsDropdown"),s=Object(D["resolveDirective"])("side-nav");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",ls,[Object(D["createElementVNode"])("ul",{class:"navbar hide-on-med-and-down collapsible",role:"menu","aria-label":e.translate("CoreHome_MainNavigation")},[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.menu,t=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{class:Object(D["normalizeClass"])(["menuTab",{active:t.id===e.activeCategory}]),role:"menuitem",key:t.id,"data-category-id":t.id},[t.component?(Object(D["openBlock"])(),Object(D["createBlock"])(Object(D["resolveDynamicComponent"])(t.component),{key:0,onAction:o=>e.loadCategory(t)},null,40,["onAction"])):Object(D["createCommentVNode"])("",!0),t.component?Object(D["createCommentVNode"])("",!0):(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:1,class:"item",tabindex:"5",href:"",onClick:Object(D["withModifiers"])(o=>e.loadCategory(t),["prevent"])},[Object(D["createElementVNode"])("span",{class:Object(D["normalizeClass"])("menu-icon "+(t.icon?t.icon:t.subcategories&&t.id===e.activeCategory?"icon-chevron-down":"icon-chevron-right"))},null,2),Object(D["createTextVNode"])(Object(D["toDisplayString"])(t.name)+" ",1),Object(D["createElementVNode"])("span",ms,Object(D["toDisplayString"])(e.translate("CoreHome_Menu")),1)],8,us)),t.component?Object(D["createCommentVNode"])("",!0):(Object(D["openBlock"])(),Object(D["createElementBlock"])("ul",ps,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(t.subcategories,o=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{role:"menuitem",class:Object(D["normalizeClass"])({active:(o.id===e.displayedSubcategory||o.isGroup&&e.activeSubsubcategory===e.displayedSubcategory)&&t.id===e.displayedCategory}),key:o.id},[o.isGroup?(Object(D["openBlock"])(),Object(D["createBlock"])(r,{key:0,"show-search":!0,"menu-title":e.htmlEntities(o.name)},{default:Object(D["withCtx"])(()=>[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(o.subcategories,i=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{class:Object(D["normalizeClass"])(["item",{active:i.id===e.activeSubsubcategory&&o.id===e.displayedSubcategory&&t.id===e.displayedCategory}]),tabindex:"5",href:"#?"+e.makeUrl(t,i),onClick:o=>e.loadSubcategory(t,i,o),title:i.tooltip,key:i.id},Object(D["toDisplayString"])(i.name),11,hs))),128))]),_:2},1032,["menu-title"])):Object(D["createCommentVNode"])("",!0),o.isGroup?Object(D["createCommentVNode"])("",!0):(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:1,href:"#?"+e.makeUrl(t,o),class:"item",onClick:i=>e.loadSubcategory(t,o,i),tabindex:"5"},Object(D["toDisplayString"])(o.name),9,gs)),o.help?(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:2,class:Object(D["normalizeClass"])(["item-help-icon",{active:e.helpShownCategory&&e.helpShownCategory.subcategory===o.id&&e.helpShownCategory.category===t.id&&o.help}]),tabindex:"5",href:"javascript:",onClick:i=>e.showHelp(t,o,i)},vs,10,bs)):Object(D["createCommentVNode"])("",!0)],2))),128))]))],10,ds))),128))],8,cs),Object(D["createElementVNode"])("ul",Os,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.menu,t=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{class:"no-padding",key:t.id,"data-category-id":t.id},[t.component?(Object(D["openBlock"])(),Object(D["createBlock"])(Object(D["resolveDynamicComponent"])(t.component),{key:0,onAction:o=>e.loadCategory(t)},null,40,["onAction"])):Object(D["createCommentVNode"])("",!0),t.component?Object(D["createCommentVNode"])("",!0):Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("ul",js,[Object(D["createElementVNode"])("li",null,[Object(D["createElementVNode"])("a",ws,[Object(D["createElementVNode"])("i",{class:Object(D["normalizeClass"])(t.icon?t.icon:"icon-chevron-down")},null,2),Object(D["createTextVNode"])(Object(D["toDisplayString"])(t.name),1)]),Object(D["createElementVNode"])("div",Ss,[Object(D["createElementVNode"])("ul",null,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(t.subcategories,o=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{key:o.id},[o.isGroup?(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],{key:0},Object(D["renderList"])(o.subcategories,o=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{onClick:i=>e.loadSubcategory(t,o),href:"#?"+e.makeUrl(t,o),key:o.id},Object(D["toDisplayString"])(o.name),9,Cs))),128)):Object(D["createCommentVNode"])("",!0),o.isGroup?Object(D["createCommentVNode"])("",!0):(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:1,onClick:i=>e.loadSubcategory(t,o),href:"#?"+e.makeUrl(t,o)},Object(D["toDisplayString"])(o.name),9,ks))]))),128))])])])])),[[s,{activator:e.sideNavActivator}]])],8,ys))),128))])])}function Es(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */function Es(e){const t=e;return t.widgets?t.widgets:[]}class Ps{constructor(){Ds(this,"privateState",Object(D["reactive"])({isFetchedFirstTime:!1,categorizedWidgets:{}})),Ds(this,"state",Object(D["computed"])(()=>(this.privateState.isFetchedFirstTime||this.fetchAvailableWidgets(),Object(D["readonly"])(this.privateState)))),Ds(this,"widgets",Object(D["computed"])(()=>this.state.value.categorizedWidgets))}fetchAvailableWidgets(){return U.parsed.value.idSite?(this.privateState.isFetchedFirstTime=!0,new Promise((e,t)=>{try{window.widgetsHelper.getAvailableWidgets(t=>{const o=t;this.privateState.categorizedWidgets=o,e(this.widgets.value)})}catch(o){t(o)}})):Promise.resolve(this.widgets.value)}reloadAvailableWidgets(){window.widgetsHelper.clearAvailableWidgets();const e=this.fetchAvailableWidgets();return e.then(()=>{M.postEvent("WidgetsStore.reloaded")}),e}}var Ts=new Ps;const xs="reportingmenu-help";var Vs=Object(D["defineComponent"])({components:{MenuItemsDropdown:si},directives:{SideNav:Jt},props:{},data(){return{showSubcategoryHelpOnLoad:null,initialLoad:!0,helpShownCategory:null}},computed:{sideNavActivator(){return document.querySelector("nav .activateLeftMenu")},menu(){const e=oa.menu.value;return e.forEach(e=>{if(e.widget&&e.widget.indexOf(".")>0){const[t,o]=e.widget.split(".");e.component=Ce(t,o)}}),e},activeCategory(){return oa.activeCategory.value},activeSubcategory(){return oa.activeSubcategory.value},activeSubsubcategory(){return oa.activeSubsubcategory.value},displayedCategory(){return U.parsed.value.category},displayedSubcategory(){return U.parsed.value.subcategory}},created(){oa.fetchMenuItems().then(()=>{U.parsed.value.subcategory||this.loadFirstPageOfActiveSection()}),this.updateTopMenuActiveState(),Object(D["watch"])(()=>U.parsed.value,e=>{if(!e.subcategory)return this.loadFirstPageOfActiveSection(),void this.updateTopMenuActiveState();const t=oa.findSubcategory(e.category,e.subcategory);oa.enterSubcategory(t.category,t.subcategory,t.subsubcategory),this.updateTopMenuActiveState()}),M.on("matomoPageChange",()=>{this.initialLoad||window.globalAjaxQueue.abort(),this.helpShownCategory=null,this.showSubcategoryHelpOnLoad&&(this.showHelp(this.showSubcategoryHelpOnLoad.category,this.showSubcategoryHelpOnLoad.subcategory),this.showSubcategoryHelpOnLoad=null),window.$("#loadingError,#loadingRateLimitError").hide(),this.initialLoad=!1}),M.on("updateReportingMenu",()=>{oa.reloadMenuItems().then(()=>{const e=U.parsed.value.category,t=U.parsed.value.subcategory;if(e&&t){const o=oa.findSubcategory(e,t);o.category&&oa.enterSubcategory(o.category,o.subcategory,o.subsubcategory)}}),Ts.reloadAvailableWidgets()})},methods:{loadFirstPageOfActiveSection(){const e=oa.menu.value,t=e[0];if(!t)return;const o=t.subcategories[0];o&&(oa.enterSubcategory(t,o),this.propagateUrlChange(t,o))},updateTopMenuActiveState(){const e=U.parsed.value.group||"";document.querySelectorAll("[data-reporting-group]").forEach(t=>{const o=t.closest("li");if(!o)return;const i=t.getAttribute("data-reporting-group")||"";o.classList.toggle("active",i===e)})},propagateUrlChange(e,t){const o=U.parsed.value;o.category===e.id&&o.subcategory===t.id?this.loadSubcategory(e,t):U.updateHash(Object.assign(Object.assign({},U.hashParsed.value),{},{category:e.id,subcategory:t.id}))},loadCategory(e){Zi.remove(xs);const t=oa.toggleCategory(e),{subcategories:o}=e,i=o&&1===o.length||e.widget&&o&&o.length;if(t&&i){this.helpShownCategory=null;const t=e.subcategories[0];this.propagateUrlChange(e,t)}},loadSubcategory(e,t,o){o&&(o.shiftKey||o.ctrlKey||o.metaKey)||(Zi.remove(xs),t&&t.id===U.parsed.value.subcategory&&e.id===U.parsed.value.category&&(this.helpShownCategory=null,setTimeout(()=>{M.postEvent("loadPage",e.id,t.id)})))},makeUrl(e,t){const{idSite:o,period:i,date:n,segment:a,comparePeriods:r,compareDates:s,compareSegments:l,group:c}=U.parsed.value,d={idSite:o,period:i,date:n,segment:a,comparePeriods:r,compareDates:s,compareSegments:l,category:e.id,subcategory:t.id};return c&&(d.group=c),U.stringify(d)},htmlEntities(e){return M.helper.htmlEntities(e)},showHelp(e,t,o){const i=U.parsed.value,n=i.category,r=i.subcategory;if((n!==e.id||r!==t.id)&&o)return this.showSubcategoryHelpOnLoad={category:e,subcategory:t},void U.updateHash(Object.assign(Object.assign({},U.hashParsed.value),{},{category:e.id,subcategory:t.id}));if(this.helpShownCategory&&e.id===this.helpShownCategory.category&&t.id===this.helpShownCategory.subcategory)return Zi.remove(xs),void(this.helpShownCategory=null);const s=a("CoreHome_ReportingCategoryHelpPrefix",e.name,t.name),l=`${s}
`;Zi.show({context:"info",id:xs,type:"help",noclear:!0,class:"help-notification",message:l+t.help,placeat:"#notificationContainer",prepend:!0}),this.helpShownCategory={category:e.id,subcategory:t.id}}}});Vs.render=ks;var Bs=Vs;function Ns(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} + */function Ps(e){const t=e;return t.widgets?t.widgets:[]}class Ts{constructor(){Es(this,"privateState",Object(D["reactive"])({isFetchedFirstTime:!1,categorizedWidgets:{}})),Es(this,"state",Object(D["computed"])(()=>(this.privateState.isFetchedFirstTime||this.fetchAvailableWidgets(),Object(D["readonly"])(this.privateState)))),Es(this,"widgets",Object(D["computed"])(()=>this.state.value.categorizedWidgets))}fetchAvailableWidgets(){return U.parsed.value.idSite?(this.privateState.isFetchedFirstTime=!0,new Promise((e,t)=>{try{window.widgetsHelper.getAvailableWidgets(t=>{const o=t;this.privateState.categorizedWidgets=o,e(this.widgets.value)})}catch(o){t(o)}})):Promise.resolve(this.widgets.value)}reloadAvailableWidgets(){window.widgetsHelper.clearAvailableWidgets();const e=this.fetchAvailableWidgets();return e.then(()=>{I.postEvent("WidgetsStore.reloaded")}),e}}var xs=new Ts;const Vs="reportingmenu-help";var Bs=Object(D["defineComponent"])({components:{MenuItemsDropdown:li},directives:{SideNav:Xt},props:{},data(){return{showSubcategoryHelpOnLoad:null,initialLoad:!0,helpShownCategory:null}},computed:{sideNavActivator(){return document.querySelector("nav .activateLeftMenu")},menu(){const e=ia.menu.value;return e.forEach(e=>{if(e.widget&&e.widget.indexOf(".")>0){const[t,o]=e.widget.split(".");e.component=Ce(t,o)}}),e},activeCategory(){return ia.activeCategory.value},activeSubcategory(){return ia.activeSubcategory.value},activeSubsubcategory(){return ia.activeSubsubcategory.value},displayedCategory(){return U.parsed.value.category},displayedSubcategory(){return U.parsed.value.subcategory}},created(){ia.fetchMenuItems().then(()=>{U.parsed.value.subcategory||this.loadFirstPageOfActiveSection()}),this.updateTopMenuActiveState(),Object(D["watch"])(()=>U.parsed.value,e=>{if(!e.subcategory)return this.loadFirstPageOfActiveSection(),void this.updateTopMenuActiveState();const t=ia.findSubcategory(e.category,e.subcategory);ia.enterSubcategory(t.category,t.subcategory,t.subsubcategory),this.updateTopMenuActiveState()}),I.on("matomoPageChange",()=>{this.initialLoad||window.globalAjaxQueue.abort(),this.helpShownCategory=null,this.showSubcategoryHelpOnLoad&&(this.showHelp(this.showSubcategoryHelpOnLoad.category,this.showSubcategoryHelpOnLoad.subcategory),this.showSubcategoryHelpOnLoad=null),window.$("#loadingError,#loadingRateLimitError").hide(),this.initialLoad=!1}),I.on("updateReportingMenu",()=>{ia.reloadMenuItems().then(()=>{const e=U.parsed.value.category,t=U.parsed.value.subcategory;if(e&&t){const o=ia.findSubcategory(e,t);o.category&&ia.enterSubcategory(o.category,o.subcategory,o.subsubcategory)}}),xs.reloadAvailableWidgets()})},methods:{loadFirstPageOfActiveSection(){const e=ia.menu.value,t=e[0];if(!t)return;const o=t.subcategories[0];o&&(ia.enterSubcategory(t,o),this.propagateUrlChange(t,o))},updateTopMenuActiveState(){const e=U.parsed.value.group||"";document.querySelectorAll("[data-reporting-group]").forEach(t=>{const o=t.closest("li");if(!o)return;const i=t.getAttribute("data-reporting-group")||"";o.classList.toggle("active",i===e)})},propagateUrlChange(e,t){const o=U.parsed.value;o.category===e.id&&o.subcategory===t.id?this.loadSubcategory(e,t):U.updateHash(Object.assign(Object.assign({},U.hashParsed.value),{},{category:e.id,subcategory:t.id}))},loadCategory(e){en.remove(Vs);const t=ia.toggleCategory(e),{subcategories:o}=e,i=o&&1===o.length||e.widget&&o&&o.length;if(t&&i){this.helpShownCategory=null;const t=e.subcategories[0];this.propagateUrlChange(e,t)}},loadSubcategory(e,t,o){o&&(o.shiftKey||o.ctrlKey||o.metaKey)||(en.remove(Vs),t&&t.id===U.parsed.value.subcategory&&e.id===U.parsed.value.category&&(this.helpShownCategory=null,setTimeout(()=>{I.postEvent("loadPage",e.id,t.id)})))},makeUrl(e,t){const{idSite:o,period:i,date:n,segment:a,comparePeriods:r,compareDates:s,compareSegments:l,group:c}=U.parsed.value,d={idSite:o,period:i,date:n,segment:a,comparePeriods:r,compareDates:s,compareSegments:l,category:e.id,subcategory:t.id};return c&&(d.group=c),U.stringify(d)},htmlEntities(e){return I.helper.htmlEntities(e)},showHelp(e,t,o){const i=U.parsed.value,n=i.category,r=i.subcategory;if((n!==e.id||r!==t.id)&&o)return this.showSubcategoryHelpOnLoad={category:e,subcategory:t},void U.updateHash(Object.assign(Object.assign({},U.hashParsed.value),{},{category:e.id,subcategory:t.id}));if(this.helpShownCategory&&e.id===this.helpShownCategory.category&&t.id===this.helpShownCategory.subcategory)return en.remove(Vs),void(this.helpShownCategory=null);const s=a("CoreHome_ReportingCategoryHelpPrefix",e.name,t.name),l=`${s}
`;en.show({context:"info",id:Vs,type:"help",noclear:!0,class:"help-notification",message:l+t.help,placeat:"#notificationContainer",prepend:!0}),this.helpShownCategory={category:e.id,subcategory:t.id}}}});Bs.render=Ds;var Ns=Bs;function Ms(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */class Is{constructor(){Ns(this,"privateState",Object(D["reactive"])({reports:[]})),Ns(this,"state",Object(D["readonly"])(this.privateState)),Ns(this,"reports",Object(D["computed"])(()=>this.state.reports)),Ns(this,"reportsPromise",void 0)}findReport(e,t){return this.reports.value.find(o=>o.module===e&&o.action===t)}fetchReportMetadata(){return this.reportsPromise||(this.reportsPromise=te.fetch({method:"API.getReportMetadata",filter_limit:"-1",idSite:M.idSite||U.parsed.value.idSite}).then(e=>(this.privateState.reports=e,e))),this.reportsPromise.then(()=>this.reports.value)}}var Ms=new Is;const Fs={class:"widgetLoader"},Rs={key:0},Ls={key:1,class:"notification system notification-error"},As=["href"],_s={key:2,class:"notification system notification-error"},Hs={class:"theWidgetContent",ref:"widgetContent"};function $s(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("ActivityIndicator");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Fs,[Object(D["createVNode"])(r,{"loading-message":e.finalLoadingMessage,loading:e.loading},null,8,["loading-message","loading"]),Object(D["withDirectives"])(Object(D["createElementVNode"])("div",null,[e.widgetName?(Object(D["openBlock"])(),Object(D["createElementBlock"])("h2",Rs,Object(D["toDisplayString"])(e.widgetName),1)):Object(D["createCommentVNode"])("",!0),e.loadingFailedRateLimit?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",_s,Object(D["toDisplayString"])(e.translate("General_ErrorRateLimit")),1)):(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Ls,[Object(D["createTextVNode"])(Object(D["toDisplayString"])(e.translate("General_ErrorRequest","",""))+" ",1),e.hasErrorFaqLink?(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:0,rel:"noreferrer noopener",target:"_blank",href:e.externalRawLink("https://matomo.org/faq/troubleshooting/faq_19489/")},Object(D["toDisplayString"])(e.translate("General_ErrorRequestFaqLink")),9,As)):Object(D["createCommentVNode"])("",!0)]))],512),[[D["vShow"],e.loadingFailed]]),Object(D["createElementVNode"])("div",Hs,null,512)])}function Us(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} + */class Is{constructor(){Ms(this,"privateState",Object(D["reactive"])({reports:[]})),Ms(this,"state",Object(D["readonly"])(this.privateState)),Ms(this,"reports",Object(D["computed"])(()=>this.state.reports)),Ms(this,"reportsPromise",void 0)}findReport(e,t){return this.reports.value.find(o=>o.module===e&&o.action===t)}fetchReportMetadata(){return this.reportsPromise||(this.reportsPromise=te.fetch({method:"API.getReportMetadata",filter_limit:"-1",idSite:I.idSite||U.parsed.value.idSite}).then(e=>(this.privateState.reports=e,e))),this.reportsPromise.then(()=>this.reports.value)}}var Fs=new Is;const Rs={class:"reportHeader"},Ls={class:"reportHeader__main"},As=["role","tabindex","title"],_s={class:"u-visuallyHidden"},Hs={class:"reportHeader__widgetControls"},$s=Object(D["createElementVNode"])("div",{class:"reportHeader__actions"},null,-1);function Us(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("WidgetControls");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Rs,[Object(D["createElementVNode"])("div",Ls,[Object(D["createElementVNode"])("h3",{class:Object(D["normalizeClass"])(["reportHeader__title widgetName",{"reportHeader__title--clickable":e.titleClickable}]),role:e.titleClickable?"button":void 0,tabindex:e.titleClickable?0:void 0,title:e.titleClickable?e.titleClickHint:void 0,onClick:t[0]||(t[0]=(...t)=>e.onTitleClick&&e.onTitleClick(...t)),onKeydown:[t[1]||(t[1]=Object(D["withKeys"])(Object(D["withModifiers"])((...t)=>e.onTitleClick&&e.onTitleClick(...t),["prevent"]),["enter"])),t[2]||(t[2]=Object(D["withKeys"])(Object(D["withModifiers"])((...t)=>e.onTitleClick&&e.onTitleClick(...t),["prevent"]),["space"]))]},[Object(D["createElementVNode"])("span",null,Object(D["toDisplayString"])(e.title),1)],42,As),Object(D["createElementVNode"])("span",_s,Object(D["toDisplayString"])(e.translate("General_Widget")),1)]),Object(D["createElementVNode"])("div",Hs,[e.hasControls?(Object(D["openBlock"])(),Object(D["createBlock"])(r,{key:0,"can-minimise":e.controls.minimise,"can-maximise":e.controls.maximise,"can-refresh":e.controls.refresh,"can-close":e.controls.close,onMinimise:t[3]||(t[3]=t=>e.onControl("minimise")),onMaximise:t[4]||(t[4]=t=>e.onControl("maximise")),onRefresh:t[5]||(t[5]=t=>e.onControl("refresh")),onClose:t[6]||(t[6]=t=>e.onControl("close"))},null,8,["can-minimise","can-maximise","can-refresh","can-close"])):Object(D["createCommentVNode"])("",!0)]),$s])}const qs={class:"widgetControls"},Ws=["title","aria-label","onClick"];function zs(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",qs,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.visibleControls,t=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("button",{key:t.id,type:"button",class:Object(D["normalizeClass"])(["widgetControls__action","widgetControls__action--"+t.id]),title:t.label,"aria-label":t.label,onClick:o=>e.$emit(t.id)},[Object(D["createElementVNode"])("span",{class:Object(D["normalizeClass"])(["widgetControls__icon",t.icon])},null,2)],10,Ws))),128))])}var Gs=Object(D["defineComponent"])({props:{canMinimise:Boolean,canMaximise:Boolean,canRefresh:Boolean,canClose:Boolean},emits:["minimise","maximise","refresh","close"],computed:{visibleControls(){const e=[{id:"refresh",icon:"icon-reload",label:a("General_Refresh"),visible:this.canRefresh},{id:"minimise",icon:"icon-minimise",label:a("Dashboard_Minimise"),visible:this.canMinimise},{id:"maximise",icon:"icon-fullscreen",label:a("Dashboard_Maximise"),visible:this.canMaximise},{id:"close",icon:"icon-close",label:a("General_Close"),visible:this.canClose}];return e.filter(e=>e.visible)}}});Gs.render=zs;var Ks=Gs;const Ys={dashboard:{minimise:!0,maximise:!0,refresh:!0,close:!0},maximised:{minimise:!0,maximise:!1,refresh:!0,close:!1},collapsed:{minimise:!1,maximise:!0,refresh:!1,close:!0},widgetized:{minimise:!1,maximise:!1,refresh:!1,close:!1},preview:{minimise:!1,maximise:!1,refresh:!1,close:!1}};var Qs=Object(D["defineComponent"])({props:{context:{type:String,default:"dashboard"},title:{type:String,default:""},titleClickable:Boolean,titleClickHint:{type:String,default:""}},components:{WidgetControls:Ks},emits:["minimise","maximise","refresh","close","titleClick"],computed:{controls(){return Ys[this.context]||Ys.widgetized},hasControls(){const e=this.controls;return e.minimise||e.maximise||e.refresh||e.close}},methods:{translate:a,onTitleClick(){this.titleClickable&&this.$emit("titleClick")},onControl(e){this.$emit(e),this.$el.dispatchEvent(new CustomEvent("widgetcontrol:"+e,{bubbles:!0}))}}});Qs.render=Us;var Js=Qs;const Xs={class:"widgetLoader"},Zs={key:0},el={key:1,class:"notification system notification-error"},tl=["href"],ol={key:2,class:"notification system notification-error"},il={class:"theWidgetContent",ref:"widgetContent"};function nl(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("ActivityIndicator");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Xs,[Object(D["createVNode"])(r,{"loading-message":e.finalLoadingMessage,loading:e.loading},null,8,["loading-message","loading"]),Object(D["withDirectives"])(Object(D["createElementVNode"])("div",null,[e.widgetName?(Object(D["openBlock"])(),Object(D["createElementBlock"])("h2",Zs,Object(D["toDisplayString"])(e.widgetName),1)):Object(D["createCommentVNode"])("",!0),e.loadingFailedRateLimit?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",ol,Object(D["toDisplayString"])(e.translate("General_ErrorRateLimit")),1)):(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",el,[Object(D["createTextVNode"])(Object(D["toDisplayString"])(e.translate("General_ErrorRequest","",""))+" ",1),e.hasErrorFaqLink?(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:0,rel:"noreferrer noopener",target:"_blank",href:e.externalRawLink("https://matomo.org/faq/troubleshooting/faq_19489/")},Object(D["toDisplayString"])(e.translate("General_ErrorRequestFaqLink")),9,tl)):Object(D["createCommentVNode"])("",!0)]))],512),[[D["vShow"],e.loadingFailed]]),Object(D["createElementVNode"])("div",il,null,512)])}function al(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */class qs{constructor(){Us(this,"privateState",Object(D["reactive"])({module:"",action:"",category:"",subcategory:"",idSite:"",widgetSearchFilters:{}})),Us(this,"state",Object(D["computed"])(()=>Object(D["readonly"])(this.privateState))),M.on("matomoPageChange",()=>{this.isCurrentPage()||this.resetSearchFilters(),this.updateCurrentRoutingFromUrl()})}resetSearchFilters(){this.privateState.widgetSearchFilters={}}getSearchFilters(e){return this.state.value.widgetSearchFilters[e]||{}}setSearchFilters(e,t){e&&(this.privateState.widgetSearchFilters[e]=t)}updateCurrentRoutingFromUrl(){const e=U.parsed.value;this.privateState.module=e.module,this.privateState.action=e.action,this.privateState.category=e.category,this.privateState.subcategory=e.subcategory,this.privateState.idSite=e.idSite}isCurrentPage(){const e=U.parsed.value;return this.state.value.module===e.module&&this.state.value.action===e.action&&this.state.value.category===e.category&&this.state.value.subcategory===e.subcategory&&this.state.value.idSite===e.idSite}}var Ws=new qs,zs=Object(D["defineComponent"])({props:{widgetParams:Object,widgetName:String,loadingMessage:String,suppressNotifications:Boolean},components:{ActivityIndicator:We},data(){return{loading:!1,loadingFailed:!1,loadingFailedRateLimit:!1,changeCounter:0,lastWidgetAbortController:null}},watch:{widgetParams(e){e&&this.loadWidgetUrl(e,this.changeCounter+=1)}},computed:{finalLoadingMessage(){return this.loadingMessage?this.loadingMessage:this.widgetName?a("General_LoadingPopover",this.widgetName):a("General_LoadingData")},hasErrorFaqLink(){const e=M.config.enable_general_settings_admin,t=M.config.enable_plugins_admin;return M.hasSuperUserAccess&&(e||t)}},mounted(){this.widgetParams&&this.loadWidgetUrl(this.widgetParams,this.changeCounter+=1)},beforeUnmount(){this.cleanupLastWidgetContent()},methods:{abortHttpRequestIfNeeded(){this.lastWidgetAbortController&&(this.lastWidgetAbortController.abort(),this.lastWidgetAbortController=null)},cleanupLastWidgetContent(){const e=this.$refs.widgetContent;M.helper.destroyVueComponent(e),e&&(e.innerHTML="")},getWidgetUrl(e){const t=U.parsed.value;let o=Object.assign({},e||{});const i=Object.keys(Object.assign(Object.assign({},U.hashParsed.value),{},{idSite:"",period:"",date:"",segment:"",widget:""}));return i.forEach(e=>{"category"!==e&&"subcategory"!==e&&(e in o||(o[e]=t[e]))}),zo.isComparisonEnabled()&&(o=Object.assign(Object.assign({},o),{},{comparePeriods:t.comparePeriods,compareDates:t.compareDates,compareSegments:t.compareSegments})),e&&"showtitle"in e||(o.showtitle="1"),M.shouldPropagateTokenAuth&&t.token_auth&&(M.broadcast.isWidgetizeRequestWithoutSession()||(o.force_api_session="1"),o.token_auth=t.token_auth),o.random=Math.floor(1e4*Math.random()),o},loadWidgetUrl(e,t){this.loading=!0,this.abortHttpRequestIfNeeded(),this.cleanupLastWidgetContent(),this.lastWidgetAbortController=new AbortController;let o={};e.uniqueId&&(o=Ws.getSearchFilters(e.uniqueId)),te.fetch(this.getWidgetUrl(Object.assign(e,o)),{format:"html",abortController:this.lastWidgetAbortController}).then(o=>{if(t!==this.changeCounter||"string"!==typeof o)return;this.lastWidgetAbortController=null,this.loading=!1,this.loadingFailed=!1;const i=this.$refs.widgetContent;window.$(i).html(o);const n=window.$(i).children();if(this.widgetName){let e=n.find("> .card-content .card-title");e.length||(e=n.find("> h2")),e.length&&e.html(M.helper.htmlEntities(this.widgetName))}M.helper.compileVueEntryComponents(n),this.suppressNotifications||Zi.parseNotificationDivs(),setTimeout(()=>{M.postEvent("widget:loaded",{parameters:e,element:n})})}).catch(e=>{t===this.changeCounter&&(this.lastWidgetAbortController=null,this.cleanupLastWidgetContent(),this.loading=!1,"abort"!==e.xhrStatus&&(429===e.status&&(this.loadingFailedRateLimit=!0),this.loadingFailed=!0))})}}});zs.render=$s;var Gs=zs;function Ks(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("ActivityIndicator"),s=Object(D["resolveComponent"])("Alert");return e.loading?(Object(D["openBlock"])(),Object(D["createBlock"])(r,{key:0,loading:!0,"loading-message":e.translate("General_LoadingData")},null,8,["loading-message"])):e.loadingFailed?(Object(D["openBlock"])(),Object(D["createBlock"])(s,{key:1,severity:"danger"},{default:Object(D["withCtx"])(()=>[Object(D["createTextVNode"])(Object(D["toDisplayString"])(e.translate("General_ErrorRequest","","")),1)]),_:1})):e.componentToRender?(Object(D["openBlock"])(),Object(D["createBlock"])(Object(D["resolveDynamicComponent"])(e.componentToRender),Object(D["normalizeProps"])(Object(D["mergeProps"])({key:2},e.componentProps)),null,16)):Object(D["createCommentVNode"])("",!0)}var Ys=Object(D["defineComponent"])({props:{widget:{type:Object,required:!0},widgetized:Boolean},components:{ActivityIndicator:We,Alert:Ke},data(){return{componentToRender:null,loading:!1,loadingFailed:!1}},watch:{widget:{handler(){this.loadComponent()},immediate:!0}},computed:{componentProps(){var e;const t=this.widget;return Object.assign(Object.assign({},(null===(e=t.clientComponent)||void 0===e?void 0:e.props)||{}),{},{uniqueId:t.uniqueId,widgetName:t.name,widgetized:this.widgetized,isWidget:this.widgetized,isWide:t.isWide})}},methods:{async loadComponent(){const e=this.widget,{clientComponent:t}=e;this.loading=!0,this.loadingFailed=!1,this.componentToRender=null;try{if(!t)throw new Error("Missing client-rendered widget metadata");const e=await Se(t.plugin),o=null===e||void 0===e?void 0:e[t.name];if(!o)throw new Error(`Unknown widget component ${t.plugin}.${t.name}`);this.componentToRender=Object(D["markRaw"])(o)}catch(o){console.error(o),this.loadingFailed=!0}finally{this.loading=!1}}}});Ys.render=Ks;var Qs=Ys;const Js={class:"widget-container"};function Xs(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("Widget");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Js,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.actualContainer,(e,t)=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{key:t},[Object(D["createElementVNode"])("div",null,[Object(D["createVNode"])(r,{widget:e,"prevent-recursion":!0},null,8,["widget"])])]))),128))])}const Zs=Ce("CoreHome","Widget");var el=Object(D["defineComponent"])({props:{container:{type:Array,required:!0}},components:{Widget:Zs},computed:{actualContainer(){var e,t,o;const i=this.container;if(null===i||void 0===i||null===(e=i[0])||void 0===e||!e.parameters)return i;const[n]=i,a="1"===(null===(t=n.parameters)||void 0===t?void 0:t.widget)||1===(null===(o=n.parameters)||void 0===o?void 0:o.widget),r=a&&"graphEvolution"===n.viewDataTable,s=r?Object.assign(Object.assign({},n),{},{parameters:Object.assign(Object.assign({},n.parameters),{},{showtitle:"0"})}):n;return[s,...i.slice(1)]}}});el.render=Xs;var tl=el;const ol={class:"reportsByDimensionView"},il={class:"entityList"},nl={class:"listCircle"},al=["onClick"],rl={class:"dimension"},sl={class:"reportContainer"},ll=Object(D["createElementVNode"])("div",{class:"clear"},null,-1);function cl(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("WidgetLoader");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",ol,[Object(D["createElementVNode"])("div",il,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.widgetsByCategory,t=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{class:"dimensionCategory",key:t.name},[Object(D["createTextVNode"])(Object(D["toDisplayString"])(t.name)+" ",1),Object(D["createElementVNode"])("ul",nl,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(t.widgets,t=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{class:Object(D["normalizeClass"])(["reportDimension",{activeDimension:e.selectedWidget.uniqueId===t.uniqueId}]),key:t.uniqueId,onClick:o=>e.selectWidget(t)},[Object(D["createElementVNode"])("span",rl,Object(D["toDisplayString"])(t.name),1)],10,al))),128))])]))),128))]),Object(D["createElementVNode"])("div",sl,[e.selectedWidget.parameters?(Object(D["openBlock"])(),Object(D["createBlock"])(r,{key:0,"widget-params":e.selectedWidget.parameters,class:"dimensionReport"},null,8,["widget-params"])):Object(D["createCommentVNode"])("",!0)]),ll])}var dl=Object(D["defineComponent"])({props:{widgets:Array},components:{WidgetLoader:Gs},data(){return{selectedWidget:null}},created(){[this.selectedWidget]=this.widgetsSorted},computed:{widgetsSorted(){return Kn(this.widgets)},widgetsByCategory(){const e={};return this.widgetsSorted.forEach(t=>{var o;const i=null===(o=t.subcategory)||void 0===o?void 0:o.name;i&&(e[i]||(e[i]={name:i,order:t.order,widgets:[]}),e[i].widgets.push(t))}),Kn(Object.values(e))}},methods:{selectWidget(e){this.selectedWidget=Object.assign({},e)}}});dl.render=cl;var ul=dl;const pl=["id"],ml={key:2},hl={key:3};function gl(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("WidgetLoader"),s=Object(D["resolveComponent"])("ClientWidgetRenderer"),l=Object(D["resolveComponent"])("WidgetContainer"),c=Object(D["resolveComponent"])("WidgetByDimensionContainer"),d=Object(D["resolveDirective"])("tooltips");return e.actualWidget&&e.showWidget?Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{key:0,class:Object(D["normalizeClass"])(["matomo-widget",{isFirstWidgetInPage:e.actualWidget.isFirstInPage}]),id:e.actualWidget.uniqueId},[e.actualWidget.isContainer||!e.actualWidget.parameters||e.actualWidget.clientComponent?Object(D["createCommentVNode"])("",!0):(Object(D["openBlock"])(),Object(D["createBlock"])(r,{key:0,"widget-params":e.actualWidget.parameters,"widget-name":e.actualWidget.name,"suppress-notifications":e.suppressNotifications},null,8,["widget-params","widget-name","suppress-notifications"])),!e.actualWidget.isContainer&&e.actualWidget.clientComponent?(Object(D["openBlock"])(),Object(D["createBlock"])(s,{key:1,widget:e.actualWidget,widgetized:e.widgetized},null,8,["widget","widgetized"])):Object(D["createCommentVNode"])("",!0),e.actualWidget.isContainer&&"ByDimension"!==e.actualWidget.layout&&!this.preventRecursion?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",ml,[Object(D["createElementVNode"])("div",null,[Object(D["createVNode"])(l,{container:e.actualWidget.widgets},null,8,["container"])])])):Object(D["createCommentVNode"])("",!0),e.actualWidget.isContainer&&"ByDimension"===e.actualWidget.layout?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",hl,[Object(D["createElementVNode"])("div",null,[Object(D["createVNode"])(c,{widgets:e.actualWidget.widgets},null,8,["widgets"])])])):Object(D["createCommentVNode"])("",!0)],10,pl)),[[d,{content:e.tooltipContent}]]):Object(D["createCommentVNode"])("",!0)}function bl(e,t){let o=void 0;return Object.values(e||{}).some(e=>(o=e.find(e=>{var o;return e&&e.isContainer&&(null===(o=e.parameters)||void 0===o?void 0:o.containerId)===t}),o)),o}var fl=Object(D["defineComponent"])({props:{widget:Object,widgetized:Boolean,containerid:String,preventRecursion:Boolean,suppressNotifications:Boolean},components:{WidgetLoader:Gs,WidgetContainer:tl,WidgetByDimensionContainer:ul,ClientWidgetRenderer:Qs},directives:{Tooltips:ct},data(){return{showWidget:!1}},setup(){function e(){const e=window.$(this);if(e.hasClass("matomo-form-field"))return"";const t=window.$(this).attr("title")||"";return window.vueSanitize(t.replace(/\n/g,"
"))}return{tooltipContent:e}},created(){const{actualWidget:e}=this;if(e&&e.middlewareParameters){const t=e.middlewareParameters;te.fetch(t).then(e=>{this.showWidget=!!e})}else this.showWidget=!0},computed:{allWidgets(){return Ts.widgets.value},actualWidget(){const e=this.widget;if(e){const t=Object.assign({},e);if(e&&e.isReport&&!e.documentation){const o=Ms.findReport(e.module,e.action);o&&o.documentation&&(t.documentation=o.documentation)}return e.uniqueId&&(t.parameters=Object.assign(Object.assign({},t.parameters),{},{uniqueId:e.uniqueId})),t}if(this.containerid){const e=bl(this.allWidgets,this.containerid);if(e){const t=Object.assign({},e);if(this.widgetized){t.isFirstInPage=!0,t.parameters=Object.assign(Object.assign({},t.parameters),{},{widget:"1"});const e=Es(t);e&&(t.widgets=e.map(e=>Object.assign(Object.assign({},e),{},{parameters:Object.assign(Object.assign({},e.parameters),{},{widget:"1",containerId:this.containerid})})))}return t}}return null}}});fl.render=gl;var vl=fl;const Ol={class:"reporting-page"},yl={key:1,class:"col s12 l6 leftWidgetColumn"},jl={key:2,class:"col s12 l6 rightWidgetColumn"};function wl(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("SiteWithoutData"),s=Object(D["resolveComponent"])("ActivityIndicator"),l=Object(D["resolveComponent"])("Widget");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Ol,[e.showEmptySiteScreen?(Object(D["openBlock"])(),Object(D["createBlock"])(r,{key:0,"embedded-in-reporting":!0,onDismissed:e.onNoDataDismissed},null,8,["onDismissed"])):(Object(D["openBlock"])(),Object(D["createElementBlock"])(D["Fragment"],{key:1},[Object(D["createVNode"])(s,{loading:e.loading},null,8,["loading"]),Object(D["withDirectives"])(Object(D["createElementVNode"])("div",null,Object(D["toDisplayString"])(e.translate("CoreHome_NoSuchPage")),513),[[D["vShow"],e.hasNoPage]]),(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.widgets,e=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{class:"row",key:e.uniqueId},[e.group?Object(D["createCommentVNode"])("",!0):(Object(D["openBlock"])(),Object(D["createBlock"])(l,{key:0,class:"col s12 fullWidgetColumn",widget:e},null,8,["widget"])),e.group?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",yl,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.left,e=>(Object(D["openBlock"])(),Object(D["createBlock"])(l,{widget:e,key:e.uniqueId},null,8,["widget"]))),128))])):Object(D["createCommentVNode"])("",!0),e.group?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",jl,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.right,e=>(Object(D["openBlock"])(),Object(D["createBlock"])(l,{widget:e,key:e.uniqueId},null,8,["widget"]))),128))])):Object(D["createCommentVNode"])("",!0)]))),128))],64))])}function Sl(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} + */class rl{constructor(){al(this,"privateState",Object(D["reactive"])({module:"",action:"",category:"",subcategory:"",idSite:"",widgetSearchFilters:{}})),al(this,"state",Object(D["computed"])(()=>Object(D["readonly"])(this.privateState))),I.on("matomoPageChange",()=>{this.isCurrentPage()||this.resetSearchFilters(),this.updateCurrentRoutingFromUrl()})}resetSearchFilters(){this.privateState.widgetSearchFilters={}}getSearchFilters(e){return this.state.value.widgetSearchFilters[e]||{}}setSearchFilters(e,t){e&&(this.privateState.widgetSearchFilters[e]=t)}updateCurrentRoutingFromUrl(){const e=U.parsed.value;this.privateState.module=e.module,this.privateState.action=e.action,this.privateState.category=e.category,this.privateState.subcategory=e.subcategory,this.privateState.idSite=e.idSite}isCurrentPage(){const e=U.parsed.value;return this.state.value.module===e.module&&this.state.value.action===e.action&&this.state.value.category===e.category&&this.state.value.subcategory===e.subcategory&&this.state.value.idSite===e.idSite}}var sl=new rl,ll=Object(D["defineComponent"])({props:{widgetParams:Object,widgetName:String,loadingMessage:String,suppressNotifications:Boolean},components:{ActivityIndicator:ze},data(){return{loading:!1,loadingFailed:!1,loadingFailedRateLimit:!1,changeCounter:0,lastWidgetAbortController:null}},watch:{widgetParams(e){e&&this.loadWidgetUrl(e,this.changeCounter+=1)}},computed:{finalLoadingMessage(){return this.loadingMessage?this.loadingMessage:this.widgetName?a("General_LoadingPopover",this.widgetName):a("General_LoadingData")},hasErrorFaqLink(){const e=I.config.enable_general_settings_admin,t=I.config.enable_plugins_admin;return I.hasSuperUserAccess&&(e||t)}},mounted(){this.widgetParams&&this.loadWidgetUrl(this.widgetParams,this.changeCounter+=1)},beforeUnmount(){this.cleanupLastWidgetContent()},methods:{abortHttpRequestIfNeeded(){this.lastWidgetAbortController&&(this.lastWidgetAbortController.abort(),this.lastWidgetAbortController=null)},cleanupLastWidgetContent(){const e=this.$refs.widgetContent;I.helper.destroyVueComponent(e),e&&(e.innerHTML="")},getWidgetUrl(e){const t=U.parsed.value;let o=Object.assign({},e||{});const i=Object.keys(Object.assign(Object.assign({},U.hashParsed.value),{},{idSite:"",period:"",date:"",segment:"",widget:""}));return i.forEach(e=>{"category"!==e&&"subcategory"!==e&&(e in o||(o[e]=t[e]))}),Go.isComparisonEnabled()&&(o=Object.assign(Object.assign({},o),{},{comparePeriods:t.comparePeriods,compareDates:t.compareDates,compareSegments:t.compareSegments})),e&&"showtitle"in e||(o.showtitle="1"),I.shouldPropagateTokenAuth&&t.token_auth&&(I.broadcast.isWidgetizeRequestWithoutSession()||(o.force_api_session="1"),o.token_auth=t.token_auth),o.random=Math.floor(1e4*Math.random()),o},loadWidgetUrl(e,t){this.loading=!0,this.abortHttpRequestIfNeeded(),this.cleanupLastWidgetContent(),this.lastWidgetAbortController=new AbortController;let o={};e.uniqueId&&(o=sl.getSearchFilters(e.uniqueId)),te.fetch(this.getWidgetUrl(Object.assign(e,o)),{format:"html",abortController:this.lastWidgetAbortController}).then(o=>{if(t!==this.changeCounter||"string"!==typeof o)return;this.lastWidgetAbortController=null,this.loading=!1,this.loadingFailed=!1;const i=this.$refs.widgetContent;window.$(i).html(o);const n=window.$(i).children();if(this.widgetName){let e=n.find("> .card-content .card-title");e.length||(e=n.find("> h2")),e.length&&e.html(I.helper.htmlEntities(this.widgetName))}I.helper.compileVueEntryComponents(n),this.suppressNotifications||en.parseNotificationDivs(),setTimeout(()=>{I.postEvent("widget:loaded",{parameters:e,element:n})})}).catch(e=>{t===this.changeCounter&&(this.lastWidgetAbortController=null,this.cleanupLastWidgetContent(),this.loading=!1,"abort"!==e.xhrStatus&&(429===e.status&&(this.loadingFailedRateLimit=!0),this.loadingFailed=!0))})}}});ll.render=nl;var cl=ll;function dl(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("ActivityIndicator"),s=Object(D["resolveComponent"])("Alert");return e.loading?(Object(D["openBlock"])(),Object(D["createBlock"])(r,{key:0,loading:!0,"loading-message":e.translate("General_LoadingData")},null,8,["loading-message"])):e.loadingFailed?(Object(D["openBlock"])(),Object(D["createBlock"])(s,{key:1,severity:"danger"},{default:Object(D["withCtx"])(()=>[Object(D["createTextVNode"])(Object(D["toDisplayString"])(e.translate("General_ErrorRequest","","")),1)]),_:1})):e.componentToRender?(Object(D["openBlock"])(),Object(D["createBlock"])(Object(D["resolveDynamicComponent"])(e.componentToRender),Object(D["normalizeProps"])(Object(D["mergeProps"])({key:2},e.componentProps)),null,16)):Object(D["createCommentVNode"])("",!0)}var ul=Object(D["defineComponent"])({props:{widget:{type:Object,required:!0},widgetized:Boolean},components:{ActivityIndicator:ze,Alert:Ye},data(){return{componentToRender:null,loading:!1,loadingFailed:!1}},watch:{widget:{handler(){this.loadComponent()},immediate:!0}},computed:{componentProps(){var e;const t=this.widget;return Object.assign(Object.assign({},(null===(e=t.clientComponent)||void 0===e?void 0:e.props)||{}),{},{uniqueId:t.uniqueId,widgetName:t.name,widgetized:this.widgetized,isWidget:this.widgetized,isWide:t.isWide})}},methods:{async loadComponent(){const e=this.widget,{clientComponent:t}=e;this.loading=!0,this.loadingFailed=!1,this.componentToRender=null;try{if(!t)throw new Error("Missing client-rendered widget metadata");const e=await Se(t.plugin),o=null===e||void 0===e?void 0:e[t.name];if(!o)throw new Error(`Unknown widget component ${t.plugin}.${t.name}`);this.componentToRender=Object(D["markRaw"])(o)}catch(o){console.error(o),this.loadingFailed=!0}finally{this.loading=!1}}}});ul.render=dl;var ml=ul;const pl={class:"widget-container"};function hl(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("Widget");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",pl,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.actualContainer,(e,t)=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{key:t},[Object(D["createElementVNode"])("div",null,[Object(D["createVNode"])(r,{widget:e,"prevent-recursion":!0},null,8,["widget"])])]))),128))])}const gl=Ce("CoreHome","Widget");var bl=Object(D["defineComponent"])({props:{container:{type:Array,required:!0}},components:{Widget:gl},computed:{actualContainer(){var e,t,o;const i=this.container;if(null===i||void 0===i||null===(e=i[0])||void 0===e||!e.parameters)return i;const[n]=i,a="1"===(null===(t=n.parameters)||void 0===t?void 0:t.widget)||1===(null===(o=n.parameters)||void 0===o?void 0:o.widget),r=a&&"graphEvolution"===n.viewDataTable,s=r?Object.assign(Object.assign({},n),{},{parameters:Object.assign(Object.assign({},n.parameters),{},{showtitle:"0"})}):n;return[s,...i.slice(1)]}}});bl.render=hl;var fl=bl;const vl={class:"reportsByDimensionView"},Ol={class:"entityList"},yl={class:"listCircle"},jl=["onClick"],wl={class:"dimension"},Sl={class:"reportContainer"},Cl=Object(D["createElementVNode"])("div",{class:"clear"},null,-1);function kl(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("WidgetLoader");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",vl,[Object(D["createElementVNode"])("div",Ol,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.widgetsByCategory,t=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{class:"dimensionCategory",key:t.name},[Object(D["createTextVNode"])(Object(D["toDisplayString"])(t.name)+" ",1),Object(D["createElementVNode"])("ul",yl,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(t.widgets,t=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{class:Object(D["normalizeClass"])(["reportDimension",{activeDimension:e.selectedWidget.uniqueId===t.uniqueId}]),key:t.uniqueId,onClick:o=>e.selectWidget(t)},[Object(D["createElementVNode"])("span",wl,Object(D["toDisplayString"])(t.name),1)],10,jl))),128))])]))),128))]),Object(D["createElementVNode"])("div",Sl,[e.selectedWidget.parameters?(Object(D["openBlock"])(),Object(D["createBlock"])(r,{key:0,"widget-params":e.selectedWidget.parameters,class:"dimensionReport"},null,8,["widget-params"])):Object(D["createCommentVNode"])("",!0)]),Cl])}var Dl=Object(D["defineComponent"])({props:{widgets:Array},components:{WidgetLoader:cl},data(){return{selectedWidget:null}},created(){[this.selectedWidget]=this.widgetsSorted},computed:{widgetsSorted(){return Yn(this.widgets)},widgetsByCategory(){const e={};return this.widgetsSorted.forEach(t=>{var o;const i=null===(o=t.subcategory)||void 0===o?void 0:o.name;i&&(e[i]||(e[i]={name:i,order:t.order,widgets:[]}),e[i].widgets.push(t))}),Yn(Object.values(e))}},methods:{selectWidget(e){this.selectedWidget=Object.assign({},e)}}});Dl.render=kl;var El=Dl;const Pl=["id"],Tl={key:2},xl={key:3};function Vl(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("WidgetLoader"),s=Object(D["resolveComponent"])("ClientWidgetRenderer"),l=Object(D["resolveComponent"])("WidgetContainer"),c=Object(D["resolveComponent"])("WidgetByDimensionContainer"),d=Object(D["resolveDirective"])("tooltips");return e.actualWidget&&e.showWidget?Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{key:0,class:Object(D["normalizeClass"])(["matomo-widget",{isFirstWidgetInPage:e.actualWidget.isFirstInPage}]),id:e.actualWidget.uniqueId},[e.actualWidget.isContainer||!e.actualWidget.parameters||e.actualWidget.clientComponent?Object(D["createCommentVNode"])("",!0):(Object(D["openBlock"])(),Object(D["createBlock"])(r,{key:0,"widget-params":e.actualWidget.parameters,"widget-name":e.actualWidget.name,"suppress-notifications":e.suppressNotifications},null,8,["widget-params","widget-name","suppress-notifications"])),!e.actualWidget.isContainer&&e.actualWidget.clientComponent?(Object(D["openBlock"])(),Object(D["createBlock"])(s,{key:1,widget:e.actualWidget,widgetized:e.widgetized},null,8,["widget","widgetized"])):Object(D["createCommentVNode"])("",!0),e.actualWidget.isContainer&&"ByDimension"!==e.actualWidget.layout&&!this.preventRecursion?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Tl,[Object(D["createElementVNode"])("div",null,[Object(D["createVNode"])(l,{container:e.actualWidget.widgets},null,8,["container"])])])):Object(D["createCommentVNode"])("",!0),e.actualWidget.isContainer&&"ByDimension"===e.actualWidget.layout?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",xl,[Object(D["createElementVNode"])("div",null,[Object(D["createVNode"])(c,{widgets:e.actualWidget.widgets},null,8,["widgets"])])])):Object(D["createCommentVNode"])("",!0)],10,Pl)),[[d,{content:e.tooltipContent}]]):Object(D["createCommentVNode"])("",!0)}function Bl(e,t){let o=void 0;return Object.values(e||{}).some(e=>(o=e.find(e=>{var o;return e&&e.isContainer&&(null===(o=e.parameters)||void 0===o?void 0:o.containerId)===t}),o)),o}var Nl=Object(D["defineComponent"])({props:{widget:Object,widgetized:Boolean,containerid:String,preventRecursion:Boolean,suppressNotifications:Boolean},components:{WidgetLoader:cl,WidgetContainer:fl,WidgetByDimensionContainer:El,ClientWidgetRenderer:ml},directives:{Tooltips:dt},data(){return{showWidget:!1}},setup(){function e(){const e=window.$(this);if(e.hasClass("matomo-form-field"))return"";const t=window.$(this).attr("title")||"";return window.vueSanitize(t.replace(/\n/g,"
"))}return{tooltipContent:e}},created(){const{actualWidget:e}=this;if(e&&e.middlewareParameters){const t=e.middlewareParameters;te.fetch(t).then(e=>{this.showWidget=!!e})}else this.showWidget=!0},computed:{allWidgets(){return xs.widgets.value},actualWidget(){const e=this.widget;if(e){const t=Object.assign({},e);if(e&&e.isReport&&!e.documentation){const o=Fs.findReport(e.module,e.action);o&&o.documentation&&(t.documentation=o.documentation)}return e.uniqueId&&(t.parameters=Object.assign(Object.assign({},t.parameters),{},{uniqueId:e.uniqueId})),t}if(this.containerid){const e=Bl(this.allWidgets,this.containerid);if(e){const t=Object.assign({},e);if(this.widgetized){t.isFirstInPage=!0,t.parameters=Object.assign(Object.assign({},t.parameters),{},{widget:"1"});const e=Ps(t);e&&(t.widgets=e.map(e=>Object.assign(Object.assign({},e),{},{parameters:Object.assign(Object.assign({},e.parameters),{},{widget:"1",containerId:this.containerid})})))}return t}}return null}}});Nl.render=Vl;var Ml=Nl;const Il={class:"reporting-page"},Fl={key:1,class:"col s12 l6 leftWidgetColumn"},Rl={key:2,class:"col s12 l6 rightWidgetColumn"};function Ll(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("SiteWithoutData"),s=Object(D["resolveComponent"])("ActivityIndicator"),l=Object(D["resolveComponent"])("Widget");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Il,[e.showEmptySiteScreen?(Object(D["openBlock"])(),Object(D["createBlock"])(r,{key:0,"embedded-in-reporting":!0,onDismissed:e.onNoDataDismissed},null,8,["onDismissed"])):(Object(D["openBlock"])(),Object(D["createElementBlock"])(D["Fragment"],{key:1},[Object(D["createVNode"])(s,{loading:e.loading},null,8,["loading"]),Object(D["withDirectives"])(Object(D["createElementVNode"])("div",null,Object(D["toDisplayString"])(e.translate("CoreHome_NoSuchPage")),513),[[D["vShow"],e.hasNoPage]]),(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.widgets,e=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{class:"row",key:e.uniqueId},[e.group?Object(D["createCommentVNode"])("",!0):(Object(D["openBlock"])(),Object(D["createBlock"])(l,{key:0,class:"col s12 fullWidgetColumn",widget:e},null,8,["widget"])),e.group?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Fl,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.left,e=>(Object(D["openBlock"])(),Object(D["createBlock"])(l,{widget:e,key:e.uniqueId},null,8,["widget"]))),128))])):Object(D["createCommentVNode"])("",!0),e.group?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Rl,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.right,e=>(Object(D["openBlock"])(),Object(D["createBlock"])(l,{widget:e,key:e.uniqueId},null,8,["widget"]))),128))])):Object(D["createCommentVNode"])("",!0)]))),128))],64))])}function Al(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */function Cl(e){return!!(e.isContainer&&e.layout&&"ByDimension"===e.layout||"bydimension"===e.viewDataTable)||(!!e.isWide||e.viewDataTable&&("tableAllColumns"===e.viewDataTable||"sparklines"===e.viewDataTable||"graphEvolution"===e.viewDataTable))}function kl(e){if(e&&e[0]){const t=[...e],o=e[0];return o.group?t[0]=Object.assign(Object.assign({},t[0]),{},{left:kl(o.left||[]),right:kl(o.right||[])}):t[0]=Object.assign(Object.assign({},t[0]),{},{isFirstInPage:!0}),t}return e}class Dl{constructor(){Sl(this,"privateState",Object(D["reactive"])({})),Sl(this,"state",Object(D["computed"])(()=>Object(D["readonly"])(this.privateState))),Sl(this,"page",Object(D["computed"])(()=>this.state.value.page)),Sl(this,"widgets",Object(D["computed"])(()=>{const e=this.page.value;if(!e)return[];let t=[];const o={},i=e=>e.isReport&&o[`${e.module}.${e.action}`],n=e=>{if(!e.isReport)return[];const t=Ms.findReport(e.module,e.action);return t&&t.relatedReports?t.relatedReports:[]};if((e.widgets||[]).forEach(e=>{i(e)||(n(e).forEach(e=>{o[`${e.module}.${e.action}`]=!0}),t.push(e))}),t=Kn(t),1===t.length)return kl(t);const a=[];for(let s=0;s(this.privateState.page=Gn.findPage(e,t),this.page.value))}resetPage(){this.privateState.page=void 0}}var El=new Dl;const Pl=Ce("SitesManager","SiteWithoutData"),Tl="site-without-data";function xl(){const e="category=General_Visitors&subcategory=Live_VisitorLog",t=window.broadcast.buildReportingUrl(e);let o=a("CoreHome_PeriodHasOnlyRawData",`
`,"");M.visitorLogEnabled||(o=a("CoreHome_PeriodHasOnlyRawDataNoVisitsLog")),Zi.show({id:"onlyRawData",animate:!1,context:"info",message:o,type:"transient"})}function Vl(){Zi.remove("onlyRawData")}var Bl=Object(D["defineComponent"])({components:{ActivityIndicator:We,Widget:vl,SiteWithoutData:Pl},props:{groupsWithoutTrackingRequirement:{type:Array,default:()=>[]}},data(){return{loading:!1,hasRawData:!1,hasNoVisits:!1,dateLastChecked:null,hasNoPage:!1,siteHasNoData:!1,noDataDismissed:!1}},created(){El.resetPage(),this.loading=!0,this.renderInitialPage(),this.fetchSiteEmptyState(),Object(D["watch"])(()=>this.showEmptySiteScreen,e=>{this.updateSiteWithoutDataBodyId(e)}),Object(D["watch"])(()=>U.parsed.value,(e,t)=>{e.category===t.category&&e.subcategory===t.subcategory&&e.period===t.period&&e.date===t.date&&e.segment===t.segment&&JSON.stringify(e.compareDates)===JSON.stringify(t.compareDates)&&JSON.stringify(e.comparePeriods)===JSON.stringify(t.comparePeriods)&&JSON.stringify(e.compareSegments)===JSON.stringify(t.compareSegments)&&JSON.stringify(e.columns||"")===JSON.stringify(t.columns||"")||(e.date===t.date&&e.period===t.period||(Vl(),this.dateLastChecked=null,this.hasRawData=!1,this.hasNoVisits=!1),this.renderPage(e.category,e.subcategory,e.period,e.date,e.segment))}),M.on("loadPage",(e,t)=>{const o=U.parsed.value;this.renderPage(e,t,o.period,o.date,o.segment)})},unmounted(){this.updateSiteWithoutDataBodyId(!1)},computed:{widgets(){return El.widgets.value},showEmptySiteScreen(){if(!this.siteHasNoData||this.noDataDismissed)return!1;const e=U.parsed.value.group||Zn;return!this.groupsWithoutTrackingRequirement.includes(e)}},methods:{fetchSiteEmptyState(){te.fetch({module:"SitesManager",action:"getSiteEmptyState",idSite:M.idSite},{createErrorNotification:!1}).then(e=>{this.siteHasNoData=!0===e}).catch(()=>{this.siteHasNoData=!1})},onNoDataDismissed(){this.noDataDismissed=!0,this.renderInitialPage()},updateSiteWithoutDataBodyId(e){e?document.body.id=Tl:document.body.id===Tl&&(document.body.id="")},renderPage(e,t,o,i,n){if(this.showEmptySiteScreen)return Zi.clearTransientNotifications(),void(this.loading=!1);if(!e||!t)return El.resetPage(),void(this.loading=!1);try{c.parse(o,i)}catch(s){return Zi.show({id:"invalidDate",animate:!1,context:"error",message:a("CoreHome_DateInvalid"),type:"transient"}),El.resetPage(),void(this.loading=!1)}Zi.remove("invalidDate"),M.postEvent("matomoPageChange",{}),Zi.clearTransientNotifications(),c.parse(o,i).containsToday()&&this.showOnlyRawDataMessageIfRequired(e,t,o,i,n);const r={category:e,subcategory:t};if(M.postEvent("ReportingPage.loadPage",r),r.promise)return this.loading=!0,void Promise.resolve(r.promise).finally(()=>{this.loading=!1});El.fetchPage(e,t).then(()=>{const t=!El.page.value;if(t){const t=Gn.findPageInCategory(e);if(t&&t.subcategory)return void U.updateHash(Object.assign(Object.assign({},U.hashParsed.value),{},{subcategory:t.subcategory.id}))}this.hasNoPage=t,this.loading=!1})},renderInitialPage(){const e=U.parsed.value;this.renderPage(e.category,e.subcategory,e.period,e.date,e.segment)},showOnlyRawDataMessageIfRequired(e,t,o,i,n){if(this.hasRawData&&this.hasNoVisits&&xl(),n)return void Vl();const a=["Live_VisitorLog","General_RealTime","UserCountryMap_RealTimeMap","MediaAnalytics_TypeAudienceLog","MediaAnalytics_TypeRealTime","FormAnalytics_TypeRealTime","Goals_AddNewGoal"],r=["HeatmapSessionRecording_Heatmaps","HeatmapSessionRecording_SessionRecordings","Marketplace_Marketplace"];if(-1!==a.indexOf(t)||-1!==r.indexOf(e)||-1!==t.toLowerCase().indexOf("manage"))return void Vl();const s=6e4;this.dateLastChecked&&(new Date).valueOf()-this.dateLastChecked.valueOf()(this.dateLastChecked=new Date,e.value>0?(this.hasNoVisits=!1,void Vl()):(this.hasNoVisits=!0,this.hasRawData?void xl():te.fetch({method:"Live.getMostRecentVisitsDateTime",date:i,period:o}).then(e=>{if(!e||""===e.value)return this.hasRawData=!1,void Vl();this.hasRawData=!0,xl()}))))}}});Bl.render=wl;var Nl=Bl;const Il={class:"report-export-popover row",id:"reportExport"},Ml={class:"col l6"},Fl={name:"format"},Rl={name:"option_flat"},Ll={name:"option_show_dimensions"},Al={name:"option_expanded"},_l={name:"option_format_metrics"},Hl={class:"col l6"},$l={name:"filter_type"},Ul={class:"filter_limit"},ql={name:"filter_limit_all"},Wl={key:0,name:"filter_limit"},zl={key:1,name:"filter_limit"},Gl={class:"col l12"},Kl=["value"],Yl=["innerHTML"],Ql={class:"col l12"},Jl=["href","title"],Xl=["innerHTML"];function Zl(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("Field"),s=Object(D["resolveDirective"])("select-on-focus");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Il,[Object(D["createElementVNode"])("div",Ml,[Object(D["createElementVNode"])("div",Fl,[Object(D["createVNode"])(r,{uicontrol:"radio",name:"format",title:e.translate("CoreHome_ExportFormat"),modelValue:e.reportFormat,"onUpdate:modelValue":t[0]||(t[0]=t=>e.reportFormat=t),"full-width":!0,options:e.availableReportFormats[e.reportType]},null,8,["title","modelValue","options"])]),Object(D["createElementVNode"])("div",null,[Object(D["createElementVNode"])("div",Rl,[Object(D["withDirectives"])(Object(D["createVNode"])(r,{uicontrol:"checkbox",name:"option_flat",title:e.translate("CoreHome_FlattenReport"),modelValue:e.optionFlatModel,"onUpdate:modelValue":t[1]||(t[1]=t=>e.optionFlatModel=t)},null,8,["title","modelValue"]),[[D["vShow"],e.canExportFlat]])])]),Object(D["createElementVNode"])("div",null,[Object(D["createElementVNode"])("div",Ll,[Object(D["withDirectives"])(Object(D["createVNode"])(r,{uicontrol:"checkbox",name:"option_show_dimensions",title:e.translate("CoreHome_IncludeDimensionsSeparately"),modelValue:e.optionShowDimensions,"onUpdate:modelValue":t[2]||(t[2]=t=>e.optionShowDimensions=t)},null,8,["title","modelValue"]),[[D["vShow"],e.canExportFlat&&e.hasMultipleDimensions&&e.optionFlatModel]])])]),Object(D["createElementVNode"])("div",null,[Object(D["createElementVNode"])("div",Al,[Object(D["withDirectives"])(Object(D["createVNode"])(r,{uicontrol:"checkbox",name:"option_expanded",title:e.translate("CoreHome_ExpandSubtables"),modelValue:e.optionExpandedModel,"onUpdate:modelValue":t[3]||(t[3]=t=>e.optionExpandedModel=t)},null,8,["title","modelValue"]),[[D["vShow"],e.hasSubtables&&e.canExpand]])])]),Object(D["createElementVNode"])("div",null,[Object(D["createElementVNode"])("div",_l,[Object(D["createVNode"])(r,{uicontrol:"checkbox",name:"option_format_metrics",title:e.translate("CoreHome_FormatMetrics"),modelValue:e.optionFormatMetrics,"onUpdate:modelValue":t[4]||(t[4]=t=>e.optionFormatMetrics=t)},null,8,["title","modelValue"])])])]),Object(D["createElementVNode"])("div",Hl,[Object(D["createElementVNode"])("div",null,[Object(D["createElementVNode"])("div",$l,[Object(D["createVNode"])(r,{uicontrol:"radio",name:"filter_type",title:e.translate("CoreHome_ReportType"),modelValue:e.reportType,"onUpdate:modelValue":t[5]||(t[5]=t=>e.reportType=t),"full-width":!0,options:e.availableReportTypes},null,8,["title","modelValue","options"])])]),Object(D["createElementVNode"])("div",Ul,[Object(D["withDirectives"])(Object(D["createElementVNode"])("div",ql,[Object(D["createVNode"])(r,{uicontrol:"radio",name:"filter_limit_all",title:e.translate("CoreHome_RowLimit"),modelValue:e.reportLimitAll,"onUpdate:modelValue":t[6]||(t[6]=t=>e.reportLimitAll=t),"full-width":!0,options:e.limitAllOptions},null,8,["title","modelValue","options"])],512),[[D["vShow"],!e.maxFilterLimit||e.maxFilterLimit<=0]]),"no"===e.reportLimitAll&&e.maxFilterLimit<=0?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Wl,[Object(D["createVNode"])(r,{uicontrol:"number",name:"filter_limit",min:1,modelValue:e.reportLimit,"onUpdate:modelValue":t[7]||(t[7]=t=>e.reportLimit=t),"full-width":!0},null,8,["modelValue"])])):Object(D["createCommentVNode"])("",!0),"no"===e.reportLimitAll&&e.maxFilterLimit>0?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",zl,[Object(D["createVNode"])(r,{uicontrol:"number",name:"filter_limit",min:1,max:e.maxFilterLimit,modelValue:e.reportLimit,"onUpdate:modelValue":t[8]||(t[8]=t=>e.reportLimit=t),value:e.reportLimit,"full-width":!0,title:e.filterLimitTooltip},null,8,["max","modelValue","value","title"])])):Object(D["createCommentVNode"])("",!0)])]),Object(D["withDirectives"])(Object(D["createElementVNode"])("div",Gl,[Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("textarea",{readonly:"",class:"exportFullUrl",value:e.exportLinkWithoutToken},[Object(D["createTextVNode"])("\n ")],8,Kl)),[[s,{}]]),Object(D["createElementVNode"])("div",{class:"tooltip",innerHTML:e.$sanitize(e.translate("CoreHome_ExportTooltipWithLink","","","ENTER_YOUR_TOKEN_AUTH_HERE"))},null,8,Yl)],512),[[D["vShow"],e.showUrl]]),Object(D["createElementVNode"])("div",Ql,[Object(D["createElementVNode"])("a",{class:"btn",href:e.exportLink,target:"_new",title:e.translate("CoreHome_ExportTooltip")},Object(D["toDisplayString"])(e.translate("General_Export")),9,Jl),Object(D["createElementVNode"])("a",{href:"javascript:",onClick:t[9]||(t[9]=t=>e.showUrl=!e.showUrl),class:"toggle-export-url"},[Object(D["withDirectives"])(Object(D["createElementVNode"])("span",null,Object(D["toDisplayString"])(e.translate("CoreHome_ShowExportUrl")),513),[[D["vShow"],!e.showUrl]]),Object(D["withDirectives"])(Object(D["createElementVNode"])("span",null,Object(D["toDisplayString"])(e.translate("CoreHome_HideExportUrl")),513),[[D["vShow"],e.showUrl]])])]),e.additionalContent?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{key:0,class:"col l12 report-export-popover-footer",innerHTML:e.$sanitize(e.additionalContent)},null,8,Xl)):Object(D["createCommentVNode"])("",!0)])} + */function _l(e){return!!(e.isContainer&&e.layout&&"ByDimension"===e.layout||"bydimension"===e.viewDataTable)||(!!e.isWide||e.viewDataTable&&("tableAllColumns"===e.viewDataTable||"sparklines"===e.viewDataTable||"graphEvolution"===e.viewDataTable))}function Hl(e){if(e&&e[0]){const t=[...e],o=e[0];return o.group?t[0]=Object.assign(Object.assign({},t[0]),{},{left:Hl(o.left||[]),right:Hl(o.right||[])}):t[0]=Object.assign(Object.assign({},t[0]),{},{isFirstInPage:!0}),t}return e}class $l{constructor(){Al(this,"privateState",Object(D["reactive"])({})),Al(this,"state",Object(D["computed"])(()=>Object(D["readonly"])(this.privateState))),Al(this,"page",Object(D["computed"])(()=>this.state.value.page)),Al(this,"widgets",Object(D["computed"])(()=>{const e=this.page.value;if(!e)return[];let t=[];const o={},i=e=>e.isReport&&o[`${e.module}.${e.action}`],n=e=>{if(!e.isReport)return[];const t=Fs.findReport(e.module,e.action);return t&&t.relatedReports?t.relatedReports:[]};if((e.widgets||[]).forEach(e=>{i(e)||(n(e).forEach(e=>{o[`${e.module}.${e.action}`]=!0}),t.push(e))}),t=Yn(t),1===t.length)return Hl(t);const a=[];for(let s=0;s(this.privateState.page=Kn.findPage(e,t),this.page.value))}resetPage(){this.privateState.page=void 0}}var Ul=new $l;const ql=Ce("SitesManager","SiteWithoutData"),Wl="site-without-data";function zl(){const e="category=General_Visitors&subcategory=Live_VisitorLog",t=window.broadcast.buildReportingUrl(e);let o=a("CoreHome_PeriodHasOnlyRawData",``,"");I.visitorLogEnabled||(o=a("CoreHome_PeriodHasOnlyRawDataNoVisitsLog")),en.show({id:"onlyRawData",animate:!1,context:"info",message:o,type:"transient"})}function Gl(){en.remove("onlyRawData")}var Kl=Object(D["defineComponent"])({components:{ActivityIndicator:ze,Widget:Ml,SiteWithoutData:ql},props:{groupsWithoutTrackingRequirement:{type:Array,default:()=>[]}},data(){return{loading:!1,hasRawData:!1,hasNoVisits:!1,dateLastChecked:null,hasNoPage:!1,siteHasNoData:!1,noDataDismissed:!1}},created(){Ul.resetPage(),this.loading=!0,this.renderInitialPage(),this.fetchSiteEmptyState(),Object(D["watch"])(()=>this.showEmptySiteScreen,e=>{this.updateSiteWithoutDataBodyId(e)}),Object(D["watch"])(()=>U.parsed.value,(e,t)=>{e.category===t.category&&e.subcategory===t.subcategory&&e.period===t.period&&e.date===t.date&&e.segment===t.segment&&JSON.stringify(e.compareDates)===JSON.stringify(t.compareDates)&&JSON.stringify(e.comparePeriods)===JSON.stringify(t.comparePeriods)&&JSON.stringify(e.compareSegments)===JSON.stringify(t.compareSegments)&&JSON.stringify(e.columns||"")===JSON.stringify(t.columns||"")||(e.date===t.date&&e.period===t.period||(Gl(),this.dateLastChecked=null,this.hasRawData=!1,this.hasNoVisits=!1),this.renderPage(e.category,e.subcategory,e.period,e.date,e.segment))}),I.on("loadPage",(e,t)=>{const o=U.parsed.value;this.renderPage(e,t,o.period,o.date,o.segment)})},unmounted(){this.updateSiteWithoutDataBodyId(!1)},computed:{widgets(){return Ul.widgets.value},showEmptySiteScreen(){if(!this.siteHasNoData||this.noDataDismissed)return!1;const e=U.parsed.value.group||ea;return!this.groupsWithoutTrackingRequirement.includes(e)}},methods:{fetchSiteEmptyState(){te.fetch({module:"SitesManager",action:"getSiteEmptyState",idSite:I.idSite},{createErrorNotification:!1}).then(e=>{this.siteHasNoData=!0===e}).catch(()=>{this.siteHasNoData=!1})},onNoDataDismissed(){this.noDataDismissed=!0,this.renderInitialPage()},updateSiteWithoutDataBodyId(e){e?document.body.id=Wl:document.body.id===Wl&&(document.body.id="")},renderPage(e,t,o,i,n){if(this.showEmptySiteScreen)return en.clearTransientNotifications(),void(this.loading=!1);if(!e||!t)return Ul.resetPage(),void(this.loading=!1);try{c.parse(o,i)}catch(s){return en.show({id:"invalidDate",animate:!1,context:"error",message:a("CoreHome_DateInvalid"),type:"transient"}),Ul.resetPage(),void(this.loading=!1)}en.remove("invalidDate"),I.postEvent("matomoPageChange",{}),en.clearTransientNotifications(),c.parse(o,i).containsToday()&&this.showOnlyRawDataMessageIfRequired(e,t,o,i,n);const r={category:e,subcategory:t};if(I.postEvent("ReportingPage.loadPage",r),r.promise)return this.loading=!0,void Promise.resolve(r.promise).finally(()=>{this.loading=!1});Ul.fetchPage(e,t).then(()=>{const t=!Ul.page.value;if(t){const t=Kn.findPageInCategory(e);if(t&&t.subcategory)return void U.updateHash(Object.assign(Object.assign({},U.hashParsed.value),{},{subcategory:t.subcategory.id}))}this.hasNoPage=t,this.loading=!1})},renderInitialPage(){const e=U.parsed.value;this.renderPage(e.category,e.subcategory,e.period,e.date,e.segment)},showOnlyRawDataMessageIfRequired(e,t,o,i,n){if(this.hasRawData&&this.hasNoVisits&&zl(),n)return void Gl();const a=["Live_VisitorLog","General_RealTime","UserCountryMap_RealTimeMap","MediaAnalytics_TypeAudienceLog","MediaAnalytics_TypeRealTime","FormAnalytics_TypeRealTime","Goals_AddNewGoal"],r=["HeatmapSessionRecording_Heatmaps","HeatmapSessionRecording_SessionRecordings","Marketplace_Marketplace"];if(-1!==a.indexOf(t)||-1!==r.indexOf(e)||-1!==t.toLowerCase().indexOf("manage"))return void Gl();const s=6e4;this.dateLastChecked&&(new Date).valueOf()-this.dateLastChecked.valueOf()(this.dateLastChecked=new Date,e.value>0?(this.hasNoVisits=!1,void Gl()):(this.hasNoVisits=!0,this.hasRawData?void zl():te.fetch({method:"Live.getMostRecentVisitsDateTime",date:i,period:o}).then(e=>{if(!e||""===e.value)return this.hasRawData=!1,void Gl();this.hasRawData=!0,zl()}))))}}});Kl.render=Ll;var Yl=Kl;const Ql={class:"report-export-popover row",id:"reportExport"},Jl={class:"col l6"},Xl={name:"format"},Zl={name:"option_flat"},ec={name:"option_show_dimensions"},tc={name:"option_expanded"},oc={name:"option_format_metrics"},ic={class:"col l6"},nc={name:"filter_type"},ac={class:"filter_limit"},rc={name:"filter_limit_all"},sc={key:0,name:"filter_limit"},lc={key:1,name:"filter_limit"},cc={class:"col l12"},dc=["value"],uc=["innerHTML"],mc={class:"col l12"},pc=["href","title"],hc=["innerHTML"];function gc(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("Field"),s=Object(D["resolveDirective"])("select-on-focus");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Ql,[Object(D["createElementVNode"])("div",Jl,[Object(D["createElementVNode"])("div",Xl,[Object(D["createVNode"])(r,{uicontrol:"radio",name:"format",title:e.translate("CoreHome_ExportFormat"),modelValue:e.reportFormat,"onUpdate:modelValue":t[0]||(t[0]=t=>e.reportFormat=t),"full-width":!0,options:e.availableReportFormats[e.reportType]},null,8,["title","modelValue","options"])]),Object(D["createElementVNode"])("div",null,[Object(D["createElementVNode"])("div",Zl,[Object(D["withDirectives"])(Object(D["createVNode"])(r,{uicontrol:"checkbox",name:"option_flat",title:e.translate("CoreHome_FlattenReport"),modelValue:e.optionFlatModel,"onUpdate:modelValue":t[1]||(t[1]=t=>e.optionFlatModel=t)},null,8,["title","modelValue"]),[[D["vShow"],e.canExportFlat]])])]),Object(D["createElementVNode"])("div",null,[Object(D["createElementVNode"])("div",ec,[Object(D["withDirectives"])(Object(D["createVNode"])(r,{uicontrol:"checkbox",name:"option_show_dimensions",title:e.translate("CoreHome_IncludeDimensionsSeparately"),modelValue:e.optionShowDimensions,"onUpdate:modelValue":t[2]||(t[2]=t=>e.optionShowDimensions=t)},null,8,["title","modelValue"]),[[D["vShow"],e.canExportFlat&&e.hasMultipleDimensions&&e.optionFlatModel]])])]),Object(D["createElementVNode"])("div",null,[Object(D["createElementVNode"])("div",tc,[Object(D["withDirectives"])(Object(D["createVNode"])(r,{uicontrol:"checkbox",name:"option_expanded",title:e.translate("CoreHome_ExpandSubtables"),modelValue:e.optionExpandedModel,"onUpdate:modelValue":t[3]||(t[3]=t=>e.optionExpandedModel=t)},null,8,["title","modelValue"]),[[D["vShow"],e.hasSubtables&&e.canExpand]])])]),Object(D["createElementVNode"])("div",null,[Object(D["createElementVNode"])("div",oc,[Object(D["createVNode"])(r,{uicontrol:"checkbox",name:"option_format_metrics",title:e.translate("CoreHome_FormatMetrics"),modelValue:e.optionFormatMetrics,"onUpdate:modelValue":t[4]||(t[4]=t=>e.optionFormatMetrics=t)},null,8,["title","modelValue"])])])]),Object(D["createElementVNode"])("div",ic,[Object(D["createElementVNode"])("div",null,[Object(D["createElementVNode"])("div",nc,[Object(D["createVNode"])(r,{uicontrol:"radio",name:"filter_type",title:e.translate("CoreHome_ReportType"),modelValue:e.reportType,"onUpdate:modelValue":t[5]||(t[5]=t=>e.reportType=t),"full-width":!0,options:e.availableReportTypes},null,8,["title","modelValue","options"])])]),Object(D["createElementVNode"])("div",ac,[Object(D["withDirectives"])(Object(D["createElementVNode"])("div",rc,[Object(D["createVNode"])(r,{uicontrol:"radio",name:"filter_limit_all",title:e.translate("CoreHome_RowLimit"),modelValue:e.reportLimitAll,"onUpdate:modelValue":t[6]||(t[6]=t=>e.reportLimitAll=t),"full-width":!0,options:e.limitAllOptions},null,8,["title","modelValue","options"])],512),[[D["vShow"],!e.maxFilterLimit||e.maxFilterLimit<=0]]),"no"===e.reportLimitAll&&e.maxFilterLimit<=0?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",sc,[Object(D["createVNode"])(r,{uicontrol:"number",name:"filter_limit",min:1,modelValue:e.reportLimit,"onUpdate:modelValue":t[7]||(t[7]=t=>e.reportLimit=t),"full-width":!0},null,8,["modelValue"])])):Object(D["createCommentVNode"])("",!0),"no"===e.reportLimitAll&&e.maxFilterLimit>0?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",lc,[Object(D["createVNode"])(r,{uicontrol:"number",name:"filter_limit",min:1,max:e.maxFilterLimit,modelValue:e.reportLimit,"onUpdate:modelValue":t[8]||(t[8]=t=>e.reportLimit=t),value:e.reportLimit,"full-width":!0,title:e.filterLimitTooltip},null,8,["max","modelValue","value","title"])])):Object(D["createCommentVNode"])("",!0)])]),Object(D["withDirectives"])(Object(D["createElementVNode"])("div",cc,[Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("textarea",{readonly:"",class:"exportFullUrl",value:e.exportLinkWithoutToken},[Object(D["createTextVNode"])("\n ")],8,dc)),[[s,{}]]),Object(D["createElementVNode"])("div",{class:"tooltip",innerHTML:e.$sanitize(e.translate("CoreHome_ExportTooltipWithLink","","","ENTER_YOUR_TOKEN_AUTH_HERE"))},null,8,uc)],512),[[D["vShow"],e.showUrl]]),Object(D["createElementVNode"])("div",mc,[Object(D["createElementVNode"])("a",{class:"btn",href:e.exportLink,target:"_new",title:e.translate("CoreHome_ExportTooltip")},Object(D["toDisplayString"])(e.translate("General_Export")),9,pc),Object(D["createElementVNode"])("a",{href:"javascript:",onClick:t[9]||(t[9]=t=>e.showUrl=!e.showUrl),class:"toggle-export-url"},[Object(D["withDirectives"])(Object(D["createElementVNode"])("span",null,Object(D["toDisplayString"])(e.translate("CoreHome_ShowExportUrl")),513),[[D["vShow"],!e.showUrl]]),Object(D["withDirectives"])(Object(D["createElementVNode"])("span",null,Object(D["toDisplayString"])(e.translate("CoreHome_HideExportUrl")),513),[[D["vShow"],e.showUrl]])])]),e.additionalContent?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{key:0,class:"col l12 report-export-popover-footer",innerHTML:e.$sanitize(e.additionalContent)},null,8,hc)):Object(D["createCommentVNode"])("",!0)])} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */const ec=["CSV","TSV","HTML"];function tc(e){return ec.includes(e)}function oc(e,t,o){return e?tc(o)?{hasUserPreference:!1,preferredMode:null}:{hasUserPreference:!0,preferredMode:"flat"}:t?{hasUserPreference:!0,preferredMode:"expanded"}:{hasUserPreference:!0,preferredMode:null}}function ic(e,t,o,i){const{hasUserPreference:n,preferredMode:a}=i;return e||t?tc(o)?t?{optionFlat:!n||"flat"===a,optionExpanded:!1}:{optionFlat:!1,optionExpanded:!1}:e?n?"flat"===a?t?{optionFlat:!0,optionExpanded:!1}:{optionFlat:!1,optionExpanded:!0}:"expanded"===a?{optionFlat:!1,optionExpanded:!0}:{optionFlat:!1,optionExpanded:!1}:{optionFlat:!1,optionExpanded:!0}:t?{optionFlat:"flat"===a,optionExpanded:!1}:{optionFlat:!1,optionExpanded:!1}:{optionFlat:!1,optionExpanded:!1}}const nc=Ce("CorePluginsAdmin","Field");var ac=Object(D["defineComponent"])({components:{Field:nc},directives:{SelectOnFocus:Wt},props:{hasSubtables:Boolean,canExportFlat:{type:Boolean,default:!1},availableReportTypes:Object,availableReportFormats:{type:Object,required:!0},maxFilterLimit:Number,limitAllOptions:Object,dataTable:{type:Object,required:!0},requestParams:[Object,String],apiMethod:{type:String,required:!0},initialReportType:{type:String,default:"default"},initialReportLimit:{type:[String,Number],default:100},initialReportLimitAll:{type:String,default:"yes"},initialOptionFlat:{type:Boolean,default:!1},initialOptionShowDimensions:{type:Boolean,default:!1},initialOptionExpanded:{type:Boolean,default:!0},initialOptionFormatMetrics:{type:Boolean,default:!1},initialReportFormat:{type:String,default:"TSV"}},mounted(){const e={content:this.additionalContent,dataTable:this.dataTable};M.postEvent("ReportExportPopover.additionalContent",e),this.additionalContent=e.content},data(){return{showUrl:!1,reportFormat:this.initialReportFormat,optionShowDimensions:this.initialOptionShowDimensions,subtablePreference:oc(this.initialOptionFlat,this.initialOptionExpanded,this.initialReportFormat),optionFormatMetrics:this.initialOptionFormatMetrics,reportType:this.initialReportType,reportLimitAll:this.initialReportLimitAll,reportLimit:"string"===typeof this.initialReportLimit?parseInt(this.initialReportLimit,10):this.initialReportLimit,additionalContent:""}},watch:{reportType(e){this.availableReportFormats[e][this.reportFormat]||(this.reportFormat="JSON")},reportLimit(e,t){this.maxFilterLimit&&this.maxFilterLimit>0&&e>this.maxFilterLimit&&(this.reportLimit=t)}},computed:{hasMultipleDimensions(){var e,t;return"function"===typeof(null===(e=this.dataTable)||void 0===e?void 0:e.getReportMetadata)&&Object.keys((null===(t=this.dataTable)||void 0===t?void 0:t.getReportMetadata().dimensions)||{}).length>1},filterLimitTooltip(){const e=a("CoreHome_RowLimit"),t=this.maxFilterLimit?a("General_ComputedMetricMax",this.maxFilterLimit.toString()):"";return`${e} (${t})`},canExpand(){return!tc(this.reportFormat)},effectiveSubtableOptions(){return ic(this.hasSubtables,this.canExportFlat,this.reportFormat,this.subtablePreference)},optionFlatModel:{get(){return this.effectiveSubtableOptions.optionFlat},set(e){this.canExportFlat&&(e?this.subtablePreference={hasUserPreference:!0,preferredMode:"flat"}:this.optionExpandedModel||(this.subtablePreference={hasUserPreference:!0,preferredMode:null}))}},optionExpandedModel:{get(){return this.effectiveSubtableOptions.optionExpanded},set(e){this.hasSubtables&&!tc(this.reportFormat)&&(e?this.subtablePreference={hasUserPreference:!0,preferredMode:"expanded"}:this.optionFlatModel||(this.subtablePreference={hasUserPreference:!0,preferredMode:null}))}},exportLink(){return this.getExportLink(!0)},exportLinkWithoutToken(){return this.getExportLink(!1)}},methods:{getExportLink(e=!0){const{reportFormat:t,apiMethod:o,reportType:i}=this,n=this.dataTable;if(!t)return;let a={};const r="yes"===this.reportLimitAll?-1:this.reportLimit;this.requestParams&&"string"===typeof this.requestParams?a=JSON.parse(this.requestParams):this.requestParams&&"object"===typeof this.requestParams&&(a=this.requestParams);const{segment:s,label:l,idGoal:c,idDimension:d,idSite:u}=n.param;let{date:p,period:m}=n.param;"RSS"===t&&(p="last10"),"undefined"!==typeof n.param.dateUsedInGraph&&(p=n.param.dateUsedInGraph);const h=M.config.datatable_export_range_as_day.toLowerCase();-1!==h.indexOf(t.toLowerCase())&&"range"===n.param.period&&(m="day"),"range"===n.param.period&&"graphEvolution"===n.param.viewDataTable&&(m="day");const g={module:"API",format:t,idSite:u,period:m,date:p};"processed"===i?(g.method="API.getProcessedReport",[g.apiModule,g.apiAction]=o.split(".")):g.method=o,n.param.compareDates&&n.param.compareDates.length&&(g.compareDates=n.param.compareDates,g.compare="1"),n.param.comparePeriods&&n.param.comparePeriods.length&&(g.comparePeriods=n.param.comparePeriods,g.compare="1"),n.param.compareSegments&&n.param.compareSegments.length&&(g.compareSegments=n.param.compareSegments,g.compare="1"),"undefined"!==typeof n.param.filter_pattern&&(g.filter_pattern=n.param.filter_pattern),"undefined"!==typeof n.param.filter_pattern_recursive&&(g.filter_pattern_recursive=n.param.filter_pattern_recursive),window.$.isPlainObject(a)&&Object.entries(a).forEach(([e,t])=>{let o=t;!0===o?o=1:!1===o&&(o=0),g[e]=o});const{optionFlat:b,optionExpanded:f}=this.effectiveSubtableOptions;if(b&&(g.flat=1,this.optionShowDimensions&&(g.show_dimensions=1),"undefined"!==typeof n.param.include_aggregate_rows&&"1"===n.param.include_aggregate_rows&&(g.include_aggregate_rows=1)),this.hasSubtables&&!b&&f&&(g.expanded=1),this.optionFormatMetrics&&(g.format_metrics=1),n.param.pivotBy&&(g.pivotBy=n.param.pivotBy,g.pivotByColumnLimit=20,n.props.pivot_by_column&&(g.pivotByColumn=n.props.pivot_by_column)),"CSV"!==t&&"TSV"!==t&&"RSS"!==t||(g.translateColumnNames=1,g.language=M.language),"undefined"!==typeof s&&(g.segment=decodeURIComponent(s)),"undefined"!==typeof c&&"-1"!==c&&(g.idGoal=c),"undefined"!==typeof d&&"-1"!==d&&(g.idDimension=d),l){const e=l.split(",");e.length>1?g.label=e:[g.label]=e}g.showMetadata=0,g.token_auth="ENTER_YOUR_TOKEN_AUTH_HERE",!0===e&&(g.token_auth=M.token_auth,g.force_api_session=1),g.filter_limit=r;const v=window.location.href.split("?")[0];return`${v}?${U.stringify(g)}`}}});ac.render=Zl;var rc=ac; + */const bc=["CSV","TSV","HTML"];function fc(e){return bc.includes(e)}function vc(e,t,o){return e?fc(o)?{hasUserPreference:!1,preferredMode:null}:{hasUserPreference:!0,preferredMode:"flat"}:t?{hasUserPreference:!0,preferredMode:"expanded"}:{hasUserPreference:!0,preferredMode:null}}function Oc(e,t,o,i){const{hasUserPreference:n,preferredMode:a}=i;return e||t?fc(o)?t?{optionFlat:!n||"flat"===a,optionExpanded:!1}:{optionFlat:!1,optionExpanded:!1}:e?n?"flat"===a?t?{optionFlat:!0,optionExpanded:!1}:{optionFlat:!1,optionExpanded:!0}:"expanded"===a?{optionFlat:!1,optionExpanded:!0}:{optionFlat:!1,optionExpanded:!1}:{optionFlat:!1,optionExpanded:!0}:t?{optionFlat:"flat"===a,optionExpanded:!1}:{optionFlat:!1,optionExpanded:!1}:{optionFlat:!1,optionExpanded:!1}}const yc=Ce("CorePluginsAdmin","Field");var jc=Object(D["defineComponent"])({components:{Field:yc},directives:{SelectOnFocus:zt},props:{hasSubtables:Boolean,canExportFlat:{type:Boolean,default:!1},availableReportTypes:Object,availableReportFormats:{type:Object,required:!0},maxFilterLimit:Number,limitAllOptions:Object,dataTable:{type:Object,required:!0},requestParams:[Object,String],apiMethod:{type:String,required:!0},initialReportType:{type:String,default:"default"},initialReportLimit:{type:[String,Number],default:100},initialReportLimitAll:{type:String,default:"yes"},initialOptionFlat:{type:Boolean,default:!1},initialOptionShowDimensions:{type:Boolean,default:!1},initialOptionExpanded:{type:Boolean,default:!0},initialOptionFormatMetrics:{type:Boolean,default:!1},initialReportFormat:{type:String,default:"TSV"}},mounted(){const e={content:this.additionalContent,dataTable:this.dataTable};I.postEvent("ReportExportPopover.additionalContent",e),this.additionalContent=e.content},data(){return{showUrl:!1,reportFormat:this.initialReportFormat,optionShowDimensions:this.initialOptionShowDimensions,subtablePreference:vc(this.initialOptionFlat,this.initialOptionExpanded,this.initialReportFormat),optionFormatMetrics:this.initialOptionFormatMetrics,reportType:this.initialReportType,reportLimitAll:this.initialReportLimitAll,reportLimit:"string"===typeof this.initialReportLimit?parseInt(this.initialReportLimit,10):this.initialReportLimit,additionalContent:""}},watch:{reportType(e){this.availableReportFormats[e][this.reportFormat]||(this.reportFormat="JSON")},reportLimit(e,t){this.maxFilterLimit&&this.maxFilterLimit>0&&e>this.maxFilterLimit&&(this.reportLimit=t)}},computed:{hasMultipleDimensions(){var e,t;return"function"===typeof(null===(e=this.dataTable)||void 0===e?void 0:e.getReportMetadata)&&Object.keys((null===(t=this.dataTable)||void 0===t?void 0:t.getReportMetadata().dimensions)||{}).length>1},filterLimitTooltip(){const e=a("CoreHome_RowLimit"),t=this.maxFilterLimit?a("General_ComputedMetricMax",this.maxFilterLimit.toString()):"";return`${e} (${t})`},canExpand(){return!fc(this.reportFormat)},effectiveSubtableOptions(){return Oc(this.hasSubtables,this.canExportFlat,this.reportFormat,this.subtablePreference)},optionFlatModel:{get(){return this.effectiveSubtableOptions.optionFlat},set(e){this.canExportFlat&&(e?this.subtablePreference={hasUserPreference:!0,preferredMode:"flat"}:this.optionExpandedModel||(this.subtablePreference={hasUserPreference:!0,preferredMode:null}))}},optionExpandedModel:{get(){return this.effectiveSubtableOptions.optionExpanded},set(e){this.hasSubtables&&!fc(this.reportFormat)&&(e?this.subtablePreference={hasUserPreference:!0,preferredMode:"expanded"}:this.optionFlatModel||(this.subtablePreference={hasUserPreference:!0,preferredMode:null}))}},exportLink(){return this.getExportLink(!0)},exportLinkWithoutToken(){return this.getExportLink(!1)}},methods:{getExportLink(e=!0){const{reportFormat:t,apiMethod:o,reportType:i}=this,n=this.dataTable;if(!t)return;let a={};const r="yes"===this.reportLimitAll?-1:this.reportLimit;this.requestParams&&"string"===typeof this.requestParams?a=JSON.parse(this.requestParams):this.requestParams&&"object"===typeof this.requestParams&&(a=this.requestParams);const{segment:s,label:l,idGoal:c,idDimension:d,idSite:u}=n.param;let{date:m,period:p}=n.param;"RSS"===t&&(m="last10"),"undefined"!==typeof n.param.dateUsedInGraph&&(m=n.param.dateUsedInGraph);const h=I.config.datatable_export_range_as_day.toLowerCase();-1!==h.indexOf(t.toLowerCase())&&"range"===n.param.period&&(p="day"),"range"===n.param.period&&"graphEvolution"===n.param.viewDataTable&&(p="day");const g={module:"API",format:t,idSite:u,period:p,date:m};"processed"===i?(g.method="API.getProcessedReport",[g.apiModule,g.apiAction]=o.split(".")):g.method=o,n.param.compareDates&&n.param.compareDates.length&&(g.compareDates=n.param.compareDates,g.compare="1"),n.param.comparePeriods&&n.param.comparePeriods.length&&(g.comparePeriods=n.param.comparePeriods,g.compare="1"),n.param.compareSegments&&n.param.compareSegments.length&&(g.compareSegments=n.param.compareSegments,g.compare="1"),"undefined"!==typeof n.param.filter_pattern&&(g.filter_pattern=n.param.filter_pattern),"undefined"!==typeof n.param.filter_pattern_recursive&&(g.filter_pattern_recursive=n.param.filter_pattern_recursive),window.$.isPlainObject(a)&&Object.entries(a).forEach(([e,t])=>{let o=t;!0===o?o=1:!1===o&&(o=0),g[e]=o});const{optionFlat:b,optionExpanded:f}=this.effectiveSubtableOptions;if(b&&(g.flat=1,this.optionShowDimensions&&(g.show_dimensions=1),"undefined"!==typeof n.param.include_aggregate_rows&&"1"===n.param.include_aggregate_rows&&(g.include_aggregate_rows=1)),this.hasSubtables&&!b&&f&&(g.expanded=1),this.optionFormatMetrics&&(g.format_metrics=1),n.param.pivotBy&&(g.pivotBy=n.param.pivotBy,g.pivotByColumnLimit=20,n.props.pivot_by_column&&(g.pivotByColumn=n.props.pivot_by_column)),"CSV"!==t&&"TSV"!==t&&"RSS"!==t||(g.translateColumnNames=1,g.language=I.language),"undefined"!==typeof s&&(g.segment=decodeURIComponent(s)),"undefined"!==typeof c&&"-1"!==c&&(g.idGoal=c),"undefined"!==typeof d&&"-1"!==d&&(g.idDimension=d),l){const e=l.split(",");e.length>1?g.label=e:[g.label]=e}g.showMetadata=0,g.token_auth="ENTER_YOUR_TOKEN_AUTH_HERE",!0===e&&(g.token_auth=I.token_auth,g.force_api_session=1),g.filter_limit=r;const v=window.location.href.split("?")[0];return`${v}?${U.stringify(g)}`}}});jc.render=gc;var wc=jc; /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */const{$:sc}=window;var lc={mounted(e,t){e.addEventListener("click",()=>{var o;const i=U.hashParsed.value.popover,n=sc(e).closest("[data-report]").data("uiControlObject"),r=window.Piwik_Popover.showLoading("Export"),s=t.value.reportFormats;let l=n.param.filter_limit;t.value.maxFilterLimit>0&&(l=Math.min(l,t.value.maxFilterLimit));const c=!0===n.param.flat||1===n.param.flat||"1"===n.param.flat,d=!0===n.param.show_dimensions||1===n.param.show_dimensions||"1"===n.param.show_dimensions,u=c||n.numberOfSubtables>0,p=null!==(o=t.value.canExportFlat)&&void 0!==o?o:u,m=p,h=!1,g={initialReportType:"default",initialReportFormat:"TSV",initialReportLimit:l>0?l:100,initialReportLimitAll:-1===l?"yes":"no",initialOptionFlat:m,initialOptionShowDimensions:d,initialOptionExpanded:h,initialOptionFormatMetrics:!1,hasSubtables:u,canExportFlat:p,availableReportFormats:{default:s,processed:{JSON:s.JSON,XML:s.XML}},availableReportTypes:{default:a("CoreHome_StandardReport"),processed:a("CoreHome_ReportWithMetadata")},limitAllOptions:{yes:a("General_All"),no:a("CoreHome_CustomLimit")},maxFilterLimit:t.value.maxFilterLimit,dataTable:n,requestParams:t.value.requestParams,apiMethod:t.value.apiMethod},b=ve({template:'\n ',data(){return{bind:g}}});b.component("popover",rc);const f=document.createElement("div");b.mount(f);const{reportTitle:v}=t.value;window.Piwik_Popover.setTitle(`${a("General_Export")} ${M.helper.htmlEntities(v)}`),window.Piwik_Popover.setContent(f),window.Piwik_Popover.onClose(()=>{b.unmount(),""!==i&&setTimeout(()=>{U.updateHash(Object.assign(Object.assign({},U.hashParsed.value),{},{popover:i})),t.value.onClose&&t.value.onClose()},100)}),setTimeout(()=>{r.dialog(),sc(".exportFullUrl, .btn",r).tooltip({track:!0,show:!1,hide:!1})},100)})}};const cc=["src","width","height"];function dc(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createElementBlock"])("img",{class:"sparklineImg",loading:"lazy",alt:"",src:e.sparklineUrl,width:e.width,height:e.height},null,8,cc)}var uc=Object(D["defineComponent"])({name:"Sparkline",props:{seriesIndices:Array,params:[Object,String],width:Number,height:Number},data(){return{isWidget:!1,themeMode:M.getThemeMode()}},mounted(){this.isWidget=!!this.$el.closest("[widgetId]"),window.addEventListener("themeModeChange",this.onThemeModeChange)},beforeUnmount(){window.removeEventListener("themeModeChange",this.onThemeModeChange)},computed:{sparklineUrl(){const{seriesIndices:e,params:t,themeMode:o}=this,i=M.getSparklineColors();e&&(i.lineColor=i.lineColor.filter((t,o)=>-1!==e.indexOf(o)));const n=JSON.stringify(i),a=document.body.classList.contains("sparklines-redesign-enabled"),r=a?Object.assign(Object.assign({},"number"===typeof this.width?{width:2*this.width}:{}),"number"===typeof this.height?{height:2*this.height}:{}):{},s=Object.assign(Object.assign({forceView:"1",viewDataTable:"sparkline",widget:this.isWidget?"1":"0",showtitle:"1",colors:n,random:Date.now(),date:this.defaultDate},r),{},{segment:U.parsed.value.segment}),l="object"===typeof t?t:U.parse(t.substring(t.indexOf("?")+1)),c=new te,d=c.mixinDefaultGetParams(Object.assign(Object.assign({},s),l)),u=U.parsed.value.token_auth;return u&&u.length&&M.shouldPropagateTokenAuth&&(d.token_auth=u),d.themeMode=o,"?"+U.stringify(d)},defaultDate(){if("range"===M.period)return`${M.startDateString},${M.endDateString}`;const e=k.getLastNRange(M.period,30,M.currentDateString).getDateRange(),t=new Date(M.minDateYear,M.minDateMonth-1,M.minDateDay);e[0]100?100:this.progress<0?0:this.progress}}});fc.render=bc;var vc=fc,Oc={mounted(e){e.classList.add("piwik-content-intro")},updated(e){Object(D["nextTick"])(()=>{e.classList.add("piwik-content-intro")})}}; + */const{$:Sc}=window;var Cc={mounted(e,t){e.addEventListener("click",()=>{var o;const i=U.hashParsed.value.popover,n=Sc(e).closest("[data-report]").data("uiControlObject"),r=window.Piwik_Popover.showLoading("Export"),s=t.value.reportFormats;let l=n.param.filter_limit;t.value.maxFilterLimit>0&&(l=Math.min(l,t.value.maxFilterLimit));const c=!0===n.param.flat||1===n.param.flat||"1"===n.param.flat,d=!0===n.param.show_dimensions||1===n.param.show_dimensions||"1"===n.param.show_dimensions,u=c||n.numberOfSubtables>0,m=null!==(o=t.value.canExportFlat)&&void 0!==o?o:u,p=m,h=!1,g={initialReportType:"default",initialReportFormat:"TSV",initialReportLimit:l>0?l:100,initialReportLimitAll:-1===l?"yes":"no",initialOptionFlat:p,initialOptionShowDimensions:d,initialOptionExpanded:h,initialOptionFormatMetrics:!1,hasSubtables:u,canExportFlat:m,availableReportFormats:{default:s,processed:{JSON:s.JSON,XML:s.XML}},availableReportTypes:{default:a("CoreHome_StandardReport"),processed:a("CoreHome_ReportWithMetadata")},limitAllOptions:{yes:a("General_All"),no:a("CoreHome_CustomLimit")},maxFilterLimit:t.value.maxFilterLimit,dataTable:n,requestParams:t.value.requestParams,apiMethod:t.value.apiMethod},b=ve({template:'\n ',data(){return{bind:g}}});b.component("popover",wc);const f=document.createElement("div");b.mount(f);const{reportTitle:v}=t.value;window.Piwik_Popover.setTitle(`${a("General_Export")} ${I.helper.htmlEntities(v)}`),window.Piwik_Popover.setContent(f),window.Piwik_Popover.onClose(()=>{b.unmount(),""!==i&&setTimeout(()=>{U.updateHash(Object.assign(Object.assign({},U.hashParsed.value),{},{popover:i})),t.value.onClose&&t.value.onClose()},100)}),setTimeout(()=>{r.dialog(),Sc(".exportFullUrl, .btn",r).tooltip({track:!0,show:!1,hide:!1})},100)})}};const kc=["src","width","height"];function Dc(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createElementBlock"])("img",{class:"sparklineImg",loading:"lazy",alt:"",src:e.sparklineUrl,width:e.width,height:e.height},null,8,kc)}var Ec=Object(D["defineComponent"])({name:"Sparkline",props:{seriesIndices:Array,params:[Object,String],width:Number,height:Number},data(){return{isWidget:!1,themeMode:I.getThemeMode()}},mounted(){this.isWidget=!!this.$el.closest("[widgetId]"),window.addEventListener("themeModeChange",this.onThemeModeChange)},beforeUnmount(){window.removeEventListener("themeModeChange",this.onThemeModeChange)},computed:{sparklineUrl(){const{seriesIndices:e,params:t,themeMode:o}=this,i=I.getSparklineColors();e&&(i.lineColor=i.lineColor.filter((t,o)=>-1!==e.indexOf(o)));const n=JSON.stringify(i),a=Object.assign(Object.assign({},"number"===typeof this.width?{width:2*this.width}:{}),"number"===typeof this.height?{height:2*this.height}:{}),r=Object.assign(Object.assign({forceView:"1",viewDataTable:"sparkline",widget:this.isWidget?"1":"0",showtitle:"1",colors:n,random:Date.now(),date:this.defaultDate},a),{},{segment:U.parsed.value.segment}),s="object"===typeof t?t:U.parse(t.substring(t.indexOf("?")+1)),l=new te,c=l.mixinDefaultGetParams(Object.assign(Object.assign({},r),s)),d=U.parsed.value.token_auth;return d&&d.length&&I.shouldPropagateTokenAuth&&(c.token_auth=d),c.themeMode=o,"?"+U.stringify(c)},defaultDate(){if("range"===I.period)return`${I.startDateString},${I.endDateString}`;const e=k.getLastNRange(I.period,30,I.currentDateString).getDateRange(),t=new Date(I.minDateYear,I.minDateMonth-1,I.minDateDay);e[0]100?100:this.progress<0?0:this.progress}}});Nc.render=Bc;var Mc=Nc,Ic={mounted(e){e.classList.add("piwik-content-intro")},updated(e){Object(D["nextTick"])(()=>{e.classList.add("piwik-content-intro")})}}; /*! * Matomo - free/libre analytics platform * @@ -352,49 +358,49 @@ const{$:Ht}=window;function $t(e,t){e.value.focusedElement!==t.target&&(e.value. * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later */ -const yc="(max-width: 767px)",jc=new WeakMap;function wc(e){const t=e.parentElement;if(!t||t.classList.contains("contentTableWrapper"))return;const o=document.createElement("div");o.className="contentTableWrapper",t.insertBefore(o,e),o.appendChild(e)}function Sc(e){const t=e.parentElement;if(!t||!t.classList.contains("contentTableWrapper"))return;const o=t.parentElement;o&&(o.insertBefore(e,t),t.remove())}function Cc(e){return(e||window.matchMedia(yc)).matches}function kc(e,t){e.addEventListener("change",t)}function Dc(e,t){e.removeEventListener("change",t)}function Ec(e,t){e.classList.add("card","card-table","entityTable"),Cc(t)?wc(e):Sc(e)}function Pc(e){const t=jc.get(e);t&&(Dc(t.mediaQuery,t.listener),jc.delete(e)),Sc(e)}function Tc(e){const t=jc.get(e);if(t)return void Ec(e,t.mediaQuery);const o=window.matchMedia(yc),i=()=>{e.isConnected?Ec(e,o):Pc(e)};kc(o,i),jc.set(e,{mediaQuery:o,listener:i}),Ec(e,o)} +const Fc="(max-width: 767px)",Rc=new WeakMap;function Lc(e){const t=e.parentElement;if(!t||t.classList.contains("contentTableWrapper"))return;const o=document.createElement("div");o.className="contentTableWrapper",t.insertBefore(o,e),o.appendChild(e)}function Ac(e){const t=e.parentElement;if(!t||!t.classList.contains("contentTableWrapper"))return;const o=t.parentElement;o&&(o.insertBefore(e,t),t.remove())}function _c(e){return(e||window.matchMedia(Fc)).matches}function Hc(e,t){e.addEventListener("change",t)}function $c(e,t){e.removeEventListener("change",t)}function Uc(e,t){e.classList.add("card","card-table","entityTable"),_c(t)?Lc(e):Ac(e)}function qc(e){const t=Rc.get(e);t&&($c(t.mediaQuery,t.listener),Rc.delete(e)),Ac(e)}function Wc(e){const t=Rc.get(e);if(t)return void Uc(e,t.mediaQuery);const o=window.matchMedia(Fc),i=()=>{e.isConnected?Uc(e,o):qc(e)};Hc(o,i),Rc.set(e,{mediaQuery:o,listener:i}),Uc(e,o)} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */var xc={mounted(e,t){var o;null!==t&&void 0!==t&&null!==(o=t.value)&&void 0!==o&&o.off||Tc(e)},updated(e,t){var o;null!==t&&void 0!==t&&null!==(o=t.value)&&void 0!==o&&o.off?Pc(e):Object(D["nextTick"])(()=>{Tc(e)})},beforeUnmount(e){Pc(e)}};const Vc={ref:"root"};function Bc(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Vc,[Object(D["renderSlot"])(e.$slots,"default",{formData:e.formData,submitApiMethod:e.submitApiMethod,sendJsonPayload:e.sendJsonPayload,noErrorNotification:e.noErrorNotification,noSuccessNotification:e.noSuccessNotification,submitForm:e.submitForm,isSubmitting:e.isSubmitting,successfulPostResponse:e.successfulPostResponse,errorPostResponse:e.errorPostResponse})],512)}const{$:Nc}=window;var Ic=Object(D["defineComponent"])({props:{formData:{type:Object,required:!0},submitApiMethod:{type:String,required:!0},sendJsonPayload:Boolean,noErrorNotification:Boolean,noSuccessNotification:Boolean},data(){return{isSubmitting:!1,successfulPostResponse:null,errorPostResponse:null}},emits:["update:modelValue"],mounted(){Nc(this.$refs.root).on("click","input[type=submit]",()=>{this.submitForm()})},methods:{submitForm(){this.successfulPostResponse=null,this.errorPostResponse=null;let e=this.formData;this.sendJsonPayload&&(e={data:JSON.stringify(this.formData)}),this.isSubmitting=!0,te.post({module:"API",method:this.submitApiMethod},e,{createErrorNotification:!this.noErrorNotification}).then(e=>{if(this.successfulPostResponse=e,!this.noSuccessNotification){const e=Zi.show({message:a("General_YourChangesHaveBeenSaved"),context:"success",type:"toast",id:"ajaxHelper"});Zi.scrollToNotification(e)}}).catch(e=>{this.errorPostResponse=e.message}).finally(()=>{this.isSubmitting=!1})}}});Ic.render=Bc;var Mc=Ic;function Fc(e,t,o,i,n,a){return Object(D["renderSlot"])(e.$slots,"default")}var Rc=Object(D["defineComponent"])({});Rc.render=Fc;var Lc=Rc;const Ac={key:0},_c=["data-target","title"],Hc=Object(D["createElementVNode"])("span",{class:"icon-configure"},null,-1),$c={class:"visually-hidden"},Uc=["data-target"],qc=["title"],Wc=["title","src"],zc=["id"],Gc=["data-footer-icon-id"],Kc=["title"],Yc=["title","src"],Qc={key:2},Jc=Object(D["createElementVNode"])("li",{class:"divider"},null,-1),Xc=Object(D["createElementVNode"])("li",{class:"divider"},null,-1),Zc=["title"],ed=Object(D["createElementVNode"])("span",{class:"icon-export"},null,-1),td={class:"visually-hidden"},od=["title"],id=Object(D["createElementVNode"])("span",{class:"icon-image"},null,-1),nd=[id],ad=["title"],rd=Object(D["createElementVNode"])("span",{class:"icon-annotation"},null,-1),sd=[rd],ld=["title"],cd=Object(D["createElementVNode"])("span",{class:"icon-search",draggable:"false"},null,-1),dd=["title"],ud=["id","title"],pd=["title"],md=["title","src"],hd=["id"],gd={key:0},bd=["innerHTML"],fd={key:1},vd=["innerHTML"],Od={key:2},yd=["innerHTML"],jd={key:3},wd=["innerHTML"],Sd={key:4},Cd=["innerHTML"],kd={key:5},Dd=["innerHTML"],Ed=["title","data-target"],Pd=Object(D["createElementVNode"])("span",{class:"icon-calendar"},null,-1),Td={class:"periodName"},xd=["id"],Vd=["data-period"];function Bd(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("Passthrough"),s=Object(D["resolveDirective"])("dropdown-button"),l=Object(D["resolveDirective"])("report-export");return e.showFooter&&e.showFooterIcons?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Ac,[e.hasConfigItems&&(e.isAnyConfigureIconHighlighted||e.isTableView)?Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:0,class:Object(D["normalizeClass"])(["dropdown-button dropdownConfigureIcon dataTableAction",{highlighted:e.isAnyConfigureIconHighlighted}]),href:"",onClick:t[0]||(t[0]=Object(D["withModifiers"])(()=>{},["prevent"])),"data-target":"dropdownConfigure"+e.randomIdForDropdown,title:e.translate("CoreHome_ReportConfigure"),style:{"margin-right":"3.5px"}},[Hc,Object(D["createElementVNode"])("span",$c,Object(D["toDisplayString"])(e.translate("CoreHome_ReportConfiguration")),1)],10,_c)),[[s]]):Object(D["createCommentVNode"])("",!0),e.hasFooterIconsToShow?Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:1,class:"dropdown-button dataTableAction activateVisualizationSelection",href:"","data-target":"dropdownVisualizations"+e.randomIdForDropdown,style:{"margin-right":"3.5px"},onClick:t[1]||(t[1]=Object(D["withModifiers"])(()=>{},["prevent"]))},[/^icon-/.test(e.activeFooterIcon||"")?(Object(D["openBlock"])(),Object(D["createElementBlock"])("span",{key:0,title:e.translate("CoreHome_ChangeVisualization"),class:Object(D["normalizeClass"])(e.activeFooterIcon)},null,10,qc)):(Object(D["openBlock"])(),Object(D["createElementBlock"])("img",{key:1,title:e.translate("CoreHome_ChangeVisualization"),width:"16",height:"16",src:e.activeFooterIcon},null,8,Wc))],8,Uc)),[[s]]):Object(D["createCommentVNode"])("",!0),e.showFooterIcons?(Object(D["openBlock"])(),Object(D["createElementBlock"])("ul",{key:2,id:"dropdownVisualizations"+e.randomIdForDropdown,class:"dropdown-content dataTableFooterIcons"},[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.footerIcons,(t,o)=>(Object(D["openBlock"])(),Object(D["createBlock"])(r,{key:o},{default:Object(D["withCtx"])(()=>[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(t.buttons.filter(e=>!!e.icon),o=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{key:o.id},[Object(D["createElementVNode"])("a",{class:Object(D["normalizeClass"])(`${t.class} tableIcon\n ${-1!==e.activeFooterIconIds.indexOf(o.id)?"activeIcon":""}`),"data-footer-icon-id":o.id},[/^icon-/.test(o.icon||"")?(Object(D["openBlock"])(),Object(D["createElementBlock"])("span",{key:0,title:o.title,class:Object(D["normalizeClass"])(o.icon),style:{"margin-right":"5.5px"}},null,10,Kc)):(Object(D["openBlock"])(),Object(D["createElementBlock"])("img",{key:1,width:"16",height:"16",title:o.title,src:o.icon,style:{"margin-right":"5.5px"}},null,8,Yc)),o.title?(Object(D["openBlock"])(),Object(D["createElementBlock"])("span",Qc,Object(D["toDisplayString"])(o.title),1)):Object(D["createCommentVNode"])("",!0)],10,Gc)]))),128)),Jc]),_:2},1024))),128)),Xc],8,zc)):Object(D["createCommentVNode"])("",!0),e.showExport?Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:3,class:"dataTableAction activateExportSelection",title:e.translate("General_ExportThisReport"),href:"",style:{"margin-right":"3.5px"},onClick:t[2]||(t[2]=Object(D["withModifiers"])(()=>{},["prevent"]))},[ed,Object(D["createElementVNode"])("span",td,Object(D["toDisplayString"])(e.translate("General_ExportThisReport")),1)],8,Zc)),[[l,{reportTitle:e.reportTitle,requestParams:e.requestParams,apiMethod:e.apiMethodToRequestDataTable,reportFormats:e.reportFormats,maxFilterLimit:e.maxFilterLimit,canExportFlat:e.exportSupportsFlat}]]):Object(D["createCommentVNode"])("",!0),e.showExportAsImageIcon?(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:4,class:"dataTableAction tableIcon",href:"",id:"dataTableFooterExportAsImageIcon",onClick:t[3]||(t[3]=Object(D["withModifiers"])(t=>e.showExportImage(t),["prevent"])),title:e.translate("General_ExportAsImage"),style:{"margin-right":"3.5px"}},nd,8,od)):Object(D["createCommentVNode"])("",!0),e.showAnnotations?(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:5,class:"dataTableAction annotationView",href:"",title:e.translate("Annotations_Annotations"),onClick:t[4]||(t[4]=Object(D["withModifiers"])(()=>{},["prevent"])),style:{"margin-right":"3.5px"}},sd,8,ad)):Object(D["createCommentVNode"])("",!0),e.showSearch?(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:6,class:"dropdown-button dataTableAction searchAction",href:"",title:e.translate("General_Search"),style:{"margin-right":"3.5px"},draggable:"false",onClick:t[5]||(t[5]=Object(D["withModifiers"])(()=>{},["prevent"]))},[cd,Object(D["createElementVNode"])("span",{class:"icon-close",draggable:"false",title:e.translate("CoreHome_CloseSearch")},null,8,dd),Object(D["createElementVNode"])("input",{id:`widgetSearch_${e.reportId}_${e.placement}`,title:e.translate("CoreHome_DataTableHowToSearch"),type:"text",class:"dataTableSearchInput"},null,8,ud)],8,ld)):Object(D["createCommentVNode"])("",!0),(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.dataTableActions,e=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:e.id,class:Object(D["normalizeClass"])("dataTableAction "+e.id),href:"",onClick:t[6]||(t[6]=Object(D["withModifiers"])(()=>{},["prevent"])),title:e.title,style:{"margin-right":"3.5px"}},[/^icon-/.test(e.icon||"")?(Object(D["openBlock"])(),Object(D["createElementBlock"])("span",{key:0,class:Object(D["normalizeClass"])(e.icon)},null,2)):(Object(D["openBlock"])(),Object(D["createElementBlock"])("img",{key:1,width:"16",height:"16",title:e.title,src:e.icon},null,8,md))],10,pd))),128)),Object(D["createElementVNode"])("ul",{id:"dropdownConfigure"+e.randomIdForDropdown,class:"dropdown-content tableConfiguration"},[e.showFlattenTable?(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",gd,[Object(D["createElementVNode"])("div",{class:"configItem dataTableFlatten",innerHTML:e.$sanitize(e.flattenItemText)},null,8,bd)])):Object(D["createCommentVNode"])("",!0),e.showDimensionsConfigItem?(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",fd,[Object(D["createElementVNode"])("div",{class:"configItem dataTableShowDimensions",innerHTML:e.$sanitize(e.showDimensionsText)},null,8,vd)])):Object(D["createCommentVNode"])("",!0),e.showFlatConfigItem?(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",Od,[Object(D["createElementVNode"])("div",{class:"configItem dataTableIncludeAggregateRows",innerHTML:e.$sanitize(e.includeAggregateRowsText)},null,8,yd)])):Object(D["createCommentVNode"])("",!0),e.showTotalsConfigItem?(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",jd,[Object(D["createElementVNode"])("div",{class:"configItem dataTableShowTotalsRow",innerHTML:e.$sanitize(e.keepTotalsRowText)},null,8,wd)])):Object(D["createCommentVNode"])("",!0),e.showExcludeLowPopulation?(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",Sd,[Object(D["createElementVNode"])("div",{class:"configItem dataTableExcludeLowPopulation",innerHTML:e.$sanitize(e.excludeLowPopText)},null,8,Cd)])):Object(D["createCommentVNode"])("",!0),e.showPivotBySubtable?(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",kd,[Object(D["createElementVNode"])("div",{class:"configItem dataTablePivotBySubtable",innerHTML:e.$sanitize(e.pivotByText)},null,8,Dd)])):Object(D["createCommentVNode"])("",!0)],8,hd),e.showPeriods?Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:7,class:"dropdown-button dataTableAction activatePeriodsSelection",href:"",onClick:t[7]||(t[7]=Object(D["withModifiers"])(()=>{},["prevent"])),title:e.translate("CoreHome_ChangePeriod"),"data-target":"dropdownPeriods"+e.randomIdForDropdown},[Object(D["createElementVNode"])("div",null,[Pd,Object(D["createElementVNode"])("span",Td,Object(D["toDisplayString"])(e.translations[e.clientSideParameters.period]||e.clientSideParameters.period),1)])],8,Ed)),[[s]]):Object(D["createCommentVNode"])("",!0),e.showPeriods?(Object(D["openBlock"])(),Object(D["createElementBlock"])("ul",{key:8,id:"dropdownPeriods"+e.randomIdForDropdown,class:"dropdown-content dataTablePeriods"},[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.selectablePeriods,t=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{key:t},[Object(D["createElementVNode"])("a",{"data-period":t,class:Object(D["normalizeClass"])("tableIcon "+(e.clientSideParameters.period===t?"activeIcon":""))},[Object(D["createElementVNode"])("span",null,Object(D["toDisplayString"])(e.translations[t]||t),1)],10,Vd)]))),128))],8,xd)):Object(D["createCommentVNode"])("",!0)])):Object(D["createCommentVNode"])("",!0)} + */var zc={mounted(e,t){var o;null!==t&&void 0!==t&&null!==(o=t.value)&&void 0!==o&&o.off||Wc(e)},updated(e,t){var o;null!==t&&void 0!==t&&null!==(o=t.value)&&void 0!==o&&o.off?qc(e):Object(D["nextTick"])(()=>{Wc(e)})},beforeUnmount(e){qc(e)}};const Gc={ref:"root"};function Kc(e,t,o,i,n,a){return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Gc,[Object(D["renderSlot"])(e.$slots,"default",{formData:e.formData,submitApiMethod:e.submitApiMethod,sendJsonPayload:e.sendJsonPayload,noErrorNotification:e.noErrorNotification,noSuccessNotification:e.noSuccessNotification,submitForm:e.submitForm,isSubmitting:e.isSubmitting,successfulPostResponse:e.successfulPostResponse,errorPostResponse:e.errorPostResponse})],512)}const{$:Yc}=window;var Qc=Object(D["defineComponent"])({props:{formData:{type:Object,required:!0},submitApiMethod:{type:String,required:!0},sendJsonPayload:Boolean,noErrorNotification:Boolean,noSuccessNotification:Boolean},data(){return{isSubmitting:!1,successfulPostResponse:null,errorPostResponse:null}},emits:["update:modelValue"],mounted(){Yc(this.$refs.root).on("click","input[type=submit]",()=>{this.submitForm()})},methods:{submitForm(){this.successfulPostResponse=null,this.errorPostResponse=null;let e=this.formData;this.sendJsonPayload&&(e={data:JSON.stringify(this.formData)}),this.isSubmitting=!0,te.post({module:"API",method:this.submitApiMethod},e,{createErrorNotification:!this.noErrorNotification}).then(e=>{if(this.successfulPostResponse=e,!this.noSuccessNotification){const e=en.show({message:a("General_YourChangesHaveBeenSaved"),context:"success",type:"toast",id:"ajaxHelper"});en.scrollToNotification(e)}}).catch(e=>{this.errorPostResponse=e.message}).finally(()=>{this.isSubmitting=!1})}}});Qc.render=Kc;var Jc=Qc;function Xc(e,t,o,i,n,a){return Object(D["renderSlot"])(e.$slots,"default")}var Zc=Object(D["defineComponent"])({});Zc.render=Xc;var ed=Zc;const td={key:0},od=["data-target","title"],id=Object(D["createElementVNode"])("span",{class:"icon-configure"},null,-1),nd={class:"visually-hidden"},ad=["data-target"],rd=["title"],sd=["title","src"],ld=["id"],cd=["data-footer-icon-id"],dd=["title"],ud=["title","src"],md={key:2},pd=Object(D["createElementVNode"])("li",{class:"divider"},null,-1),hd=Object(D["createElementVNode"])("li",{class:"divider"},null,-1),gd=["title"],bd=Object(D["createElementVNode"])("span",{class:"icon-export"},null,-1),fd={class:"visually-hidden"},vd=["title"],Od=Object(D["createElementVNode"])("span",{class:"icon-image"},null,-1),yd=[Od],jd=["title"],wd=Object(D["createElementVNode"])("span",{class:"icon-annotation"},null,-1),Sd=[wd],Cd=["title"],kd=Object(D["createElementVNode"])("span",{class:"icon-search",draggable:"false"},null,-1),Dd=["title"],Ed=["id","title"],Pd=["title"],Td=["title","src"],xd=["id"],Vd={key:0},Bd=["innerHTML"],Nd={key:1},Md=["innerHTML"],Id={key:2},Fd=["innerHTML"],Rd={key:3},Ld=["innerHTML"],Ad={key:4},_d=["aria-label","innerHTML"],Hd={key:5},$d=["innerHTML"],Ud={key:6},qd=["innerHTML"],Wd=["title","data-target"],zd=Object(D["createElementVNode"])("span",{class:"icon-calendar"},null,-1),Gd={class:"periodName"},Kd=["id"],Yd=["data-period"];function Qd(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("Passthrough"),s=Object(D["resolveDirective"])("dropdown-button"),l=Object(D["resolveDirective"])("report-export");return e.showFooter&&e.showFooterIcons?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",td,[e.hasConfigItems&&(e.isAnyConfigureIconHighlighted||e.isTableView)?Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:0,class:Object(D["normalizeClass"])(["dropdown-button dropdownConfigureIcon dataTableAction",{highlighted:e.isAnyConfigureIconHighlighted}]),href:"",onClick:t[0]||(t[0]=Object(D["withModifiers"])(()=>{},["prevent"])),"data-target":"dropdownConfigure"+e.randomIdForDropdown,title:e.translate("CoreHome_ReportConfigure"),style:{"margin-right":"3.5px"}},[id,Object(D["createElementVNode"])("span",nd,Object(D["toDisplayString"])(e.translate("CoreHome_ReportConfiguration")),1)],10,od)),[[s]]):Object(D["createCommentVNode"])("",!0),e.hasFooterIconsToShow?Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:1,class:"dropdown-button dataTableAction activateVisualizationSelection",href:"","data-target":"dropdownVisualizations"+e.randomIdForDropdown,style:{"margin-right":"3.5px"},onClick:t[1]||(t[1]=Object(D["withModifiers"])(()=>{},["prevent"]))},[/^icon-/.test(e.activeFooterIcon||"")?(Object(D["openBlock"])(),Object(D["createElementBlock"])("span",{key:0,title:e.translate("CoreHome_ChangeVisualization"),class:Object(D["normalizeClass"])(e.activeFooterIcon)},null,10,rd)):(Object(D["openBlock"])(),Object(D["createElementBlock"])("img",{key:1,title:e.translate("CoreHome_ChangeVisualization"),width:"16",height:"16",src:e.activeFooterIcon},null,8,sd))],8,ad)),[[s]]):Object(D["createCommentVNode"])("",!0),e.showFooterIcons?(Object(D["openBlock"])(),Object(D["createElementBlock"])("ul",{key:2,id:"dropdownVisualizations"+e.randomIdForDropdown,class:"dropdown-content dataTableFooterIcons"},[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.footerIcons,(t,o)=>(Object(D["openBlock"])(),Object(D["createBlock"])(r,{key:o},{default:Object(D["withCtx"])(()=>[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(t.buttons.filter(e=>!!e.icon),o=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{key:o.id},[Object(D["createElementVNode"])("a",{class:Object(D["normalizeClass"])(`${t.class} tableIcon\n ${-1!==e.activeFooterIconIds.indexOf(o.id)?"activeIcon":""}`),"data-footer-icon-id":o.id},[/^icon-/.test(o.icon||"")?(Object(D["openBlock"])(),Object(D["createElementBlock"])("span",{key:0,title:o.title,class:Object(D["normalizeClass"])(o.icon),style:{"margin-right":"5.5px"}},null,10,dd)):(Object(D["openBlock"])(),Object(D["createElementBlock"])("img",{key:1,width:"16",height:"16",title:o.title,src:o.icon,style:{"margin-right":"5.5px"}},null,8,ud)),o.title?(Object(D["openBlock"])(),Object(D["createElementBlock"])("span",md,Object(D["toDisplayString"])(o.title),1)):Object(D["createCommentVNode"])("",!0)],10,cd)]))),128)),pd]),_:2},1024))),128)),hd],8,ld)):Object(D["createCommentVNode"])("",!0),e.showExport?Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:3,class:"dataTableAction activateExportSelection",title:e.translate("General_ExportThisReport"),href:"",style:{"margin-right":"3.5px"},onClick:t[2]||(t[2]=Object(D["withModifiers"])(()=>{},["prevent"]))},[bd,Object(D["createElementVNode"])("span",fd,Object(D["toDisplayString"])(e.translate("General_ExportThisReport")),1)],8,gd)),[[l,{reportTitle:e.reportTitle,requestParams:e.requestParams,apiMethod:e.apiMethodToRequestDataTable,reportFormats:e.reportFormats,maxFilterLimit:e.maxFilterLimit,canExportFlat:e.exportSupportsFlat}]]):Object(D["createCommentVNode"])("",!0),e.showExportAsImageIcon?(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:4,class:"dataTableAction tableIcon",href:"",id:"dataTableFooterExportAsImageIcon",onClick:t[3]||(t[3]=Object(D["withModifiers"])(t=>e.showExportImage(t),["prevent"])),title:e.translate("General_ExportAsImage"),style:{"margin-right":"3.5px"}},yd,8,vd)):Object(D["createCommentVNode"])("",!0),e.showAnnotations?(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:5,class:"dataTableAction annotationView",href:"",title:e.translate("Annotations_Annotations"),onClick:t[4]||(t[4]=Object(D["withModifiers"])(()=>{},["prevent"])),style:{"margin-right":"3.5px"}},Sd,8,jd)):Object(D["createCommentVNode"])("",!0),e.showSearch?(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:6,class:"dropdown-button dataTableAction searchAction",href:"",title:e.translate("General_Search"),style:{"margin-right":"3.5px"},draggable:"false",onClick:t[5]||(t[5]=Object(D["withModifiers"])(()=>{},["prevent"]))},[kd,Object(D["createElementVNode"])("span",{class:"icon-close",draggable:"false",title:e.translate("CoreHome_CloseSearch")},null,8,Dd),Object(D["createElementVNode"])("input",{id:`widgetSearch_${e.reportId}_${e.placement}`,title:e.translate("CoreHome_DataTableHowToSearch"),type:"text",class:"dataTableSearchInput"},null,8,Ed)],8,Cd)):Object(D["createCommentVNode"])("",!0),(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.dataTableActions,e=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:e.id,class:Object(D["normalizeClass"])("dataTableAction "+e.id),href:"",onClick:t[6]||(t[6]=Object(D["withModifiers"])(()=>{},["prevent"])),title:e.title,style:{"margin-right":"3.5px"}},[/^icon-/.test(e.icon||"")?(Object(D["openBlock"])(),Object(D["createElementBlock"])("span",{key:0,class:Object(D["normalizeClass"])(e.icon)},null,2)):(Object(D["openBlock"])(),Object(D["createElementBlock"])("img",{key:1,width:"16",height:"16",title:e.title,src:e.icon},null,8,Td))],10,Pd))),128)),Object(D["createElementVNode"])("ul",{id:"dropdownConfigure"+e.randomIdForDropdown,class:"dropdown-content tableConfiguration"},[e.showFlattenTable?(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",Vd,[Object(D["createElementVNode"])("div",{class:"configItem dataTableFlatten",innerHTML:e.$sanitize(e.flattenItemText)},null,8,Bd)])):Object(D["createCommentVNode"])("",!0),e.showDimensionsConfigItem?(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",Nd,[Object(D["createElementVNode"])("div",{class:"configItem dataTableShowDimensions",innerHTML:e.$sanitize(e.showDimensionsText)},null,8,Md)])):Object(D["createCommentVNode"])("",!0),e.showFlatConfigItem?(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",Id,[Object(D["createElementVNode"])("div",{class:"configItem dataTableIncludeAggregateRows",innerHTML:e.$sanitize(e.includeAggregateRowsText)},null,8,Fd)])):Object(D["createCommentVNode"])("",!0),e.showTotalsConfigItem?(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",Rd,[Object(D["createElementVNode"])("div",{class:"configItem dataTableShowTotalsRow",innerHTML:e.$sanitize(e.keepTotalsRowText)},null,8,Ld)])):Object(D["createCommentVNode"])("",!0),e.showPercentageValuesConfigItem?(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",Ad,[Object(D["createElementVNode"])("div",{class:"configItem dataTableShowPercentageValues","aria-label":e.percentageValuesLabel,innerHTML:e.$sanitize(e.percentageValuesText)},null,8,_d)])):Object(D["createCommentVNode"])("",!0),e.showExcludeLowPopulation?(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",Hd,[Object(D["createElementVNode"])("div",{class:"configItem dataTableExcludeLowPopulation",innerHTML:e.$sanitize(e.excludeLowPopText)},null,8,$d)])):Object(D["createCommentVNode"])("",!0),e.showPivotBySubtable?(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",Ud,[Object(D["createElementVNode"])("div",{class:"configItem dataTablePivotBySubtable",innerHTML:e.$sanitize(e.pivotByText)},null,8,qd)])):Object(D["createCommentVNode"])("",!0)],8,xd),e.showPeriods?Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:7,class:"dropdown-button dataTableAction activatePeriodsSelection",href:"",onClick:t[7]||(t[7]=Object(D["withModifiers"])(()=>{},["prevent"])),title:e.translate("CoreHome_ChangePeriod"),"data-target":"dropdownPeriods"+e.randomIdForDropdown},[Object(D["createElementVNode"])("div",null,[zd,Object(D["createElementVNode"])("span",Gd,Object(D["toDisplayString"])(e.translations[e.clientSideParameters.period]||e.clientSideParameters.period),1)])],8,Wd)),[[s]]):Object(D["createCommentVNode"])("",!0),e.showPeriods?(Object(D["openBlock"])(),Object(D["createElementBlock"])("ul",{key:8,id:"dropdownPeriods"+e.randomIdForDropdown,class:"dropdown-content dataTablePeriods"},[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.selectablePeriods,t=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{key:t},[Object(D["createElementVNode"])("a",{"data-period":t,class:Object(D["normalizeClass"])("tableIcon "+(e.clientSideParameters.period===t?"activeIcon":""))},[Object(D["createElementVNode"])("span",null,Object(D["toDisplayString"])(e.translations[t]||t),1)],10,Yd)]))),128))],8,Kd)):Object(D["createCommentVNode"])("",!0)])):Object(D["createCommentVNode"])("",!0)} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */function Nd(e){return!!e&&"0"!==e}function Id(e,t){return e||Nd(t)}const{$:Md}=window;function Fd(e,t,o){if(/(%(.\$)?s+)/g.test(a(e))){const i=['
'];o&&i.push(o);let n=a(e,...i);return t&&(n+=` (${a("CoreHome_Default")})`),n+="",n}return a(e)}function Rd(e,t,o){return e?Fd(t,!0):Fd(o)}var Ld=Object(D["defineComponent"])({props:{showPeriods:Boolean,showFooter:Boolean,showFooterIcons:Boolean,showSearch:Boolean,showFlattenTable:Boolean,reportSupportsFlatten:Boolean,exportSupportsFlatten:Boolean,footerIcons:{type:Array,required:!0},viewDataTable:{type:String,required:!0},reportTitle:String,requestParams:{type:Object,required:!0},apiMethodToRequestDataTable:{type:String,required:!0},maxFilterLimit:{type:Number,required:!0},showExport:Boolean,showExportAsImageIcon:Boolean,showAnnotations:Boolean,reportId:{type:String,required:!0},dataTableActions:{type:Array,required:!0},clientSideParameters:{type:Object,required:!0},hasMultipleDimensions:Boolean,isDataTableEmpty:Boolean,showTotalsRow:Boolean,showExcludeLowPopulation:Boolean,showPivotBySubtable:Boolean,selectablePeriods:Array,translations:{type:Object,required:!0},pivotDimensionName:String,placement:{type:String,default:"footer"}},components:{Passthrough:Lc},directives:{DropdownButton:Rt,ReportExport:lc},methods:{showExportImage(e){Md(e.target).closest(".dataTable").find("div.jqplot-target").trigger("piwikExportAsImage")}},computed:{randomIdForDropdown(){return Math.floor(999999*Math.random())},allFooterIcons(){return this.footerIcons.reduce((e,t)=>(e.push(...t.buttons),e),[])},activeFooterIcons(){const e=this.clientSideParameters,t=[this.viewDataTable];return 0===e.abandonedCarts||"0"===e.abandonedCarts?t.push("ecommerceOrder"):1!==e.abandonedCarts&&"1"!==e.abandonedCarts||t.push("ecommerceAbandonedCart"),t.map(e=>this.allFooterIcons.find(t=>t.id===e)).filter(e=>!!e)},activeFooterIcon(){var e;return null===(e=this.activeFooterIcons[0])||void 0===e?void 0:e.icon},activeFooterIconIds(){return this.activeFooterIcons.map(e=>e.id)},numIcons(){return this.allFooterIcons.length},hasFooterIconsToShow(){return!!this.activeFooterIcons.length&&this.numIcons>1},reportFormats(){const e={TSV:"TSV (Excel)",HTML:"HTML",JSON:"JSON",XML:"XML",CSV:"CSV",RSS:"RSS"};return e},exportSupportsFlat(){return Id(!!this.exportSupportsFlatten,this.clientSideParameters.flat)},showDimensionsConfigItem(){return this.showFlattenTable&&""+this.clientSideParameters.flat==="1"&&this.hasMultipleDimensions},showFlatConfigItem(){return this.showFlattenTable&&""+this.clientSideParameters.flat==="1"},showTotalsConfigItem(){return!this.isDataTableEmpty&&this.showTotalsRow},hasConfigItems(){return this.showFlattenTable||this.showDimensionsConfigItem||this.showFlatConfigItem||this.showTotalsConfigItem||this.showExcludeLowPopulation||this.showPivotBySubtable},flattenItemText(){const e=this.clientSideParameters;return Rd(Nd(e.flat),"CoreHome_UnFlattenDataTable","CoreHome_FlattenDataTable")},keepTotalsRowText(){const e=this.clientSideParameters;return Rd(Nd(e.keep_totals_row),"CoreHome_RemoveTotalsRowDataTable","CoreHome_AddTotalsRowDataTable")},includeAggregateRowsText(){const e=this.clientSideParameters;return Rd(Nd(e.include_aggregate_rows),"CoreHome_DataTableExcludeAggregateRows","CoreHome_DataTableIncludeAggregateRows")},showDimensionsText(){const e=this.clientSideParameters;return Rd(Nd(e.show_dimensions),"CoreHome_DataTableCombineDimensions","CoreHome_DataTableShowDimensions")},pivotByText(){const e=this.clientSideParameters;return Nd(e.pivotBy)?Fd("CoreHome_UndoPivotBySubtable",!0):Fd("CoreHome_PivotBySubtable",!1,this.pivotDimensionName)},excludeLowPopText(){const e=this.clientSideParameters;return Rd(Nd(e.enable_filter_excludelowpop),"CoreHome_IncludeRowsWithLowPopulation","CoreHome_ExcludeRowsWithLowPopulation")},isAnyConfigureIconHighlighted(){const e=this.clientSideParameters;return Nd(e.flat)||Nd(e.keep_totals_row)||Nd(e.include_aggregate_rows)||Nd(e.show_dimensions)||Nd(e.pivotBy)||Nd(e.enable_filter_excludelowpop)},isTableView(){return"table"===this.viewDataTable||"tableAllColumns"===this.viewDataTable||"tableGoals"===this.viewDataTable}}});Ld.render=Bd;var Ad=Ld;const _d={key:0,class:"title",style:{cursor:"pointer"},ref:"expander"},Hd=Object(D["createElementVNode"])("span",{class:"icon-update"},null,-1),$d={key:1,class:"title",href:"?module=CoreUpdater&action=newVersionAvailable",style:{cursor:"pointer"},ref:"expander"},Ud=Object(D["createElementVNode"])("span",{class:"icon-update"},null,-1),qd=["innerHTML"],Wd=["href"],zd={id:"updateCheckLinkContainer"},Gd={class:"dropdown positionInViewport"},Kd=["innerHTML"],Yd=["innerHTML"];function Qd(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("Passthrough"),s=Object(D["resolveDirective"])("expand-on-hover");return Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{id:"header_message",class:Object(D["normalizeClass"])(["piwikSelector",{header_info:!e.latestVersionAvailable||e.lastUpdateCheckFailed,update_available:e.latestVersionAvailable}])},[e.latestVersionAvailable?(Object(D["openBlock"])(),Object(D["createBlock"])(r,{key:0},{default:Object(D["withCtx"])(()=>[e.isMultiServerEnvironment?(Object(D["openBlock"])(),Object(D["createElementBlock"])("span",_d,[Hd,Object(D["createTextVNode"])(" "+Object(D["toDisplayString"])(e.translate("General_NewUpdatePiwikX",e.latestVersionAvailable)),1)],512)):(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",$d,[Ud,Object(D["createTextVNode"])(" "+Object(D["toDisplayString"])(e.translate("General_NewUpdatePiwikX",e.latestVersionAvailable)),1)],512))]),_:1})):e.isSuperUser&&(e.isAdminArea||e.lastUpdateCheckFailed)?(Object(D["openBlock"])(),Object(D["createBlock"])(r,{key:1},{default:Object(D["withCtx"])(()=>[e.isInternetEnabled?(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:0,class:"title",innerHTML:e.$sanitize(e.updateCheck)},null,8,qd)):(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:1,class:"title",href:e.externalRawLink("https://matomo.org/changelog/"),target:"_blank",rel:"noreferrer noopener"},[Object(D["createElementVNode"])("span",zd,Object(D["toDisplayString"])(e.translate("CoreHome_SeeAvailableVersions")),1)],8,Wd))]),_:1})):Object(D["createCommentVNode"])("",!0),Object(D["createElementVNode"])("div",Gd,[e.latestVersionAvailable&&e.isSuperUser?(Object(D["openBlock"])(),Object(D["createElementBlock"])("span",{key:0,innerHTML:e.$sanitize(e.updateNowText)},null,8,Kd)):e.latestVersionAvailable&&e.hasSomeViewAccess&&!e.isAnonymous?(Object(D["openBlock"])(),Object(D["createElementBlock"])("span",{key:1,innerHTML:e.$sanitize(e.updateAvailableText)},null,8,Yd)):Object(D["createCommentVNode"])("",!0),Object(D["createTextVNode"])(" "+Object(D["toDisplayString"])(e.translate("General_YouAreCurrentlyUsing",e.piwikVersion)),1)])],2)),[[s,{expander:"expander"}]])}var Jd=Object(D["defineComponent"])({props:{isMultiServerEnvironment:Boolean,lastUpdateCheckFailed:Boolean,latestVersionAvailable:String,isSuperUser:Boolean,isAdminArea:Boolean,isInternetEnabled:Boolean,updateCheck:String,isAnonymous:Boolean,hasSomeViewAccess:Boolean,contactEmail:String,piwikVersion:String},components:{Passthrough:Lc},directives:{ExpandOnHover:Nt},computed:{updateNowText(){let e="";if(this.isMultiServerEnvironment){const t=ue(`https://builds.matomo.org/matomo-${this.latestVersionAvailable}.zip`);e=a("CoreHome_OneClickUpdateNotPossibleAsMultiServerEnvironment",`builds.matomo.org`)}else e=a("General_PiwikXIsAvailablePleaseUpdateNow",this.latestVersionAvailable||"",'
',"",pe("https://matomo.org/changelog/"),"");return e+"
"},updateAvailableText(){const e=a("General_NewUpdatePiwikX",this.latestVersionAvailable||""),t=pe("https://matomo.org/")+"Matomo",o=pe("https://matomo.org/changelog/"),i=a("General_PiwikXIsAvailablePleaseNotifyPiwikAdmin",`${t} ${o}${this.latestVersionAvailable}`,``,"");return i+"
"}}});Jd.render=Qd;var Xd=Jd;const Zd={id:"mobile-left-menu",class:"sidenav hide-on-large-only"},eu={class:"collapsible collapsible-accordion"},tu={class:"collapsible-header"},ou={class:"collapsible-body"},iu=["title","href"];function nu(e,t,o,i,n,a){const r=Object(D["resolveDirective"])("side-nav");return Object(D["openBlock"])(),Object(D["createElementBlock"])("ul",Zd,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.menuWithSubmenuItems,(t,o)=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{class:"no-padding",key:o},[Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("ul",eu,[Object(D["createElementVNode"])("li",null,[Object(D["createElementVNode"])("a",tu,[Object(D["createTextVNode"])(Object(D["toDisplayString"])(e.translateOrDefault(o)),1),Object(D["createElementVNode"])("i",{class:Object(D["normalizeClass"])(t._icon||"icon-chevron-down")},null,2)]),Object(D["createElementVNode"])("div",ou,[Object(D["createElementVNode"])("ul",null,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(Object.entries(t).filter(([e])=>"_"!==e[0]),([t,o])=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{key:t},[Object(D["createElementVNode"])("a",{title:o._tooltip?e.translateIfNecessary(o._tooltip):"",target:"_self",href:e.getMenuUrl(o._url)},Object(D["toDisplayString"])(e.translateIfNecessary(t)),9,iu)]))),128))])])])])),[[r,{activator:e.activateLeftMenu}]])]))),128))])}const{$:au}=window;var ru=Object(D["defineComponent"])({props:{menu:{type:Object,required:!0}},directives:{SideNav:Jt},methods:{getMenuUrl(e){return"?"+U.stringify(Object.assign(Object.assign({},U.urlParsed.value),e))},translateIfNecessary(e){return e.includes("_")?a(e):e}},computed:{menuWithSubmenuItems(){const e=this.menu||{};return Object.fromEntries(Object.entries(e).filter(([,e])=>{const t=Object.entries(e).filter(([e])=>"_"!==e[0]);return Object.keys(t).length}))},activateLeftMenu(){return au("nav .activateLeftMenu")[0]}}});ru.render=nu;var su=ru; + */function Jd(e){return!!e&&"0"!==e}function Xd(e,t){return e||Jd(t)}const{$:Zd}=window;function eu(e,t,o){if(/(%(.\$)?s+)/g.test(a(e))){const i=['
'];o&&i.push(o);let n=a(e,...i);return t&&(n+=` (${a("CoreHome_Default")})`),n+="",n}return a(e)}function tu(e,t,o){return e?eu(t,!0):eu(o)}var ou=Object(D["defineComponent"])({props:{showPeriods:Boolean,showFooter:Boolean,showFooterIcons:Boolean,showSearch:Boolean,showFlattenTable:Boolean,reportSupportsFlatten:Boolean,reportSupportsPercentageValues:Boolean,exportSupportsFlatten:Boolean,footerIcons:{type:Array,required:!0},viewDataTable:{type:String,required:!0},reportTitle:String,requestParams:{type:Object,required:!0},apiMethodToRequestDataTable:{type:String,required:!0},maxFilterLimit:{type:Number,required:!0},showExport:Boolean,showExportAsImageIcon:Boolean,showAnnotations:Boolean,reportId:{type:String,required:!0},dataTableActions:{type:Array,required:!0},clientSideParameters:{type:Object,required:!0},hasMultipleDimensions:Boolean,isDataTableEmpty:Boolean,showTotalsRow:Boolean,showExcludeLowPopulation:Boolean,showPivotBySubtable:Boolean,selectablePeriods:Array,translations:{type:Object,required:!0},pivotDimensionName:String,placement:{type:String,default:"footer"}},components:{Passthrough:ed},directives:{DropdownButton:Lt,ReportExport:Cc},methods:{showExportImage(e){Zd(e.target).closest(".dataTable").find("div.jqplot-target").trigger("piwikExportAsImage")}},computed:{randomIdForDropdown(){return Math.floor(999999*Math.random())},allFooterIcons(){return this.footerIcons.reduce((e,t)=>(e.push(...t.buttons),e),[])},activeFooterIcons(){const e=this.clientSideParameters,t=[this.viewDataTable];return 0===e.abandonedCarts||"0"===e.abandonedCarts?t.push("ecommerceOrder"):1!==e.abandonedCarts&&"1"!==e.abandonedCarts||t.push("ecommerceAbandonedCart"),t.map(e=>this.allFooterIcons.find(t=>t.id===e)).filter(e=>!!e)},activeFooterIcon(){var e;return null===(e=this.activeFooterIcons[0])||void 0===e?void 0:e.icon},activeFooterIconIds(){return this.activeFooterIcons.map(e=>e.id)},numIcons(){return this.allFooterIcons.length},hasFooterIconsToShow(){return!!this.activeFooterIcons.length&&this.numIcons>1},reportFormats(){const e={TSV:"TSV (Excel)",HTML:"HTML",JSON:"JSON",XML:"XML",CSV:"CSV",RSS:"RSS"};return e},exportSupportsFlat(){return Xd(!!this.exportSupportsFlatten,this.clientSideParameters.flat)},showDimensionsConfigItem(){return this.showFlattenTable&&""+this.clientSideParameters.flat==="1"&&this.hasMultipleDimensions},showFlatConfigItem(){return this.showFlattenTable&&""+this.clientSideParameters.flat==="1"},showTotalsConfigItem(){return!this.isDataTableEmpty&&this.showTotalsRow},showPercentageValuesConfigItem(){return!this.isDataTableEmpty&&this.reportSupportsPercentageValues},hasConfigItems(){return this.showFlattenTable||this.showDimensionsConfigItem||this.showFlatConfigItem||this.showTotalsConfigItem||this.showExcludeLowPopulation||this.showPivotBySubtable||this.showPercentageValuesConfigItem},flattenItemText(){const e=this.clientSideParameters;return tu(Jd(e.flat),"CoreHome_UnFlattenDataTable","CoreHome_FlattenDataTable")},keepTotalsRowText(){const e=this.clientSideParameters;return tu(Jd(e.keep_totals_row),"CoreHome_RemoveTotalsRowDataTable","CoreHome_AddTotalsRowDataTable")},percentageValuesText(){const e=this.clientSideParameters;return tu(Jd(e.show_percentage_values),"CoreHome_ShowAbsoluteValuesDataTable","CoreHome_ShowPercentageValuesDataTable")},percentageValuesLabel(){const e=this.clientSideParameters;return Jd(e.show_percentage_values)?a("CoreHome_ShowAbsoluteValues"):a("CoreHome_ShowPercentageValues")},includeAggregateRowsText(){const e=this.clientSideParameters;return tu(Jd(e.include_aggregate_rows),"CoreHome_DataTableExcludeAggregateRows","CoreHome_DataTableIncludeAggregateRows")},showDimensionsText(){const e=this.clientSideParameters;return tu(Jd(e.show_dimensions),"CoreHome_DataTableCombineDimensions","CoreHome_DataTableShowDimensions")},pivotByText(){const e=this.clientSideParameters;return Jd(e.pivotBy)?eu("CoreHome_UndoPivotBySubtable",!0):eu("CoreHome_PivotBySubtable",!1,this.pivotDimensionName)},excludeLowPopText(){const e=this.clientSideParameters;return tu(Jd(e.enable_filter_excludelowpop),"CoreHome_IncludeRowsWithLowPopulation","CoreHome_ExcludeRowsWithLowPopulation")},isAnyConfigureIconHighlighted(){const e=this.clientSideParameters;return Jd(e.flat)||Jd(e.keep_totals_row)||Jd(e.include_aggregate_rows)||Jd(e.show_dimensions)||Jd(e.pivotBy)||Jd(e.enable_filter_excludelowpop)||Jd(e.show_percentage_values)},isTableView(){return"table"===this.viewDataTable||"tableAllColumns"===this.viewDataTable||"tableGoals"===this.viewDataTable}}});ou.render=Qd;var iu=ou;const nu={key:0,class:"title",style:{cursor:"pointer"},ref:"expander"},au=Object(D["createElementVNode"])("span",{class:"icon-update"},null,-1),ru={key:1,class:"title",href:"?module=CoreUpdater&action=newVersionAvailable",style:{cursor:"pointer"},ref:"expander"},su=Object(D["createElementVNode"])("span",{class:"icon-update"},null,-1),lu=["innerHTML"],cu=["href"],du={id:"updateCheckLinkContainer"},uu={class:"dropdown positionInViewport"},mu=["innerHTML"],pu=["innerHTML"];function hu(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("Passthrough"),s=Object(D["resolveDirective"])("expand-on-hover");return Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{id:"header_message",class:Object(D["normalizeClass"])(["piwikSelector",{header_info:!e.latestVersionAvailable||e.lastUpdateCheckFailed,update_available:e.latestVersionAvailable}])},[e.latestVersionAvailable?(Object(D["openBlock"])(),Object(D["createBlock"])(r,{key:0},{default:Object(D["withCtx"])(()=>[e.isMultiServerEnvironment?(Object(D["openBlock"])(),Object(D["createElementBlock"])("span",nu,[au,Object(D["createTextVNode"])(" "+Object(D["toDisplayString"])(e.translate("General_NewUpdatePiwikX",e.latestVersionAvailable)),1)],512)):(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",ru,[su,Object(D["createTextVNode"])(" "+Object(D["toDisplayString"])(e.translate("General_NewUpdatePiwikX",e.latestVersionAvailable)),1)],512))]),_:1})):e.isSuperUser&&(e.isAdminArea||e.lastUpdateCheckFailed)?(Object(D["openBlock"])(),Object(D["createBlock"])(r,{key:1},{default:Object(D["withCtx"])(()=>[e.isInternetEnabled?(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:0,class:"title",innerHTML:e.$sanitize(e.updateCheck)},null,8,lu)):(Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{key:1,class:"title",href:e.externalRawLink("https://matomo.org/changelog/"),target:"_blank",rel:"noreferrer noopener"},[Object(D["createElementVNode"])("span",du,Object(D["toDisplayString"])(e.translate("CoreHome_SeeAvailableVersions")),1)],8,cu))]),_:1})):Object(D["createCommentVNode"])("",!0),Object(D["createElementVNode"])("div",uu,[e.latestVersionAvailable&&e.isSuperUser?(Object(D["openBlock"])(),Object(D["createElementBlock"])("span",{key:0,innerHTML:e.$sanitize(e.updateNowText)},null,8,mu)):e.latestVersionAvailable&&e.hasSomeViewAccess&&!e.isAnonymous?(Object(D["openBlock"])(),Object(D["createElementBlock"])("span",{key:1,innerHTML:e.$sanitize(e.updateAvailableText)},null,8,pu)):Object(D["createCommentVNode"])("",!0),Object(D["createTextVNode"])(" "+Object(D["toDisplayString"])(e.translate("General_YouAreCurrentlyUsing",e.piwikVersion)),1)])],2)),[[s,{expander:"expander"}]])}var gu=Object(D["defineComponent"])({props:{isMultiServerEnvironment:Boolean,lastUpdateCheckFailed:Boolean,latestVersionAvailable:String,isSuperUser:Boolean,isAdminArea:Boolean,isInternetEnabled:Boolean,updateCheck:String,isAnonymous:Boolean,hasSomeViewAccess:Boolean,contactEmail:String,piwikVersion:String},components:{Passthrough:ed},directives:{ExpandOnHover:Mt},computed:{updateNowText(){let e="";if(this.isMultiServerEnvironment){const t=ue(`https://builds.matomo.org/matomo-${this.latestVersionAvailable}.zip`);e=a("CoreHome_OneClickUpdateNotPossibleAsMultiServerEnvironment",`builds.matomo.org`)}else e=a("General_PiwikXIsAvailablePleaseUpdateNow",this.latestVersionAvailable||"",'
',"",me("https://matomo.org/changelog/"),"");return e+"
"},updateAvailableText(){const e=a("General_NewUpdatePiwikX",this.latestVersionAvailable||""),t=me("https://matomo.org/")+"Matomo",o=me("https://matomo.org/changelog/"),i=a("General_PiwikXIsAvailablePleaseNotifyPiwikAdmin",`${t} ${o}${this.latestVersionAvailable}`,``,"");return i+"
"}}});gu.render=hu;var bu=gu;const fu={id:"mobile-left-menu",class:"sidenav hide-on-large-only"},vu={class:"collapsible collapsible-accordion"},Ou={class:"collapsible-header"},yu={class:"collapsible-body"},ju=["title","href"];function wu(e,t,o,i,n,a){const r=Object(D["resolveDirective"])("side-nav");return Object(D["openBlock"])(),Object(D["createElementBlock"])("ul",fu,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.menuWithSubmenuItems,(t,o)=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{class:"no-padding",key:o},[Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("ul",vu,[Object(D["createElementVNode"])("li",null,[Object(D["createElementVNode"])("a",Ou,[Object(D["createTextVNode"])(Object(D["toDisplayString"])(e.translateOrDefault(o)),1),Object(D["createElementVNode"])("i",{class:Object(D["normalizeClass"])(t._icon||"icon-chevron-down")},null,2)]),Object(D["createElementVNode"])("div",yu,[Object(D["createElementVNode"])("ul",null,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(Object.entries(t).filter(([e])=>"_"!==e[0]),([t,o])=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{key:t},[Object(D["createElementVNode"])("a",{title:o._tooltip?e.translateIfNecessary(o._tooltip):"",target:"_self",href:e.getMenuUrl(o._url)},Object(D["toDisplayString"])(e.translateIfNecessary(t)),9,ju)]))),128))])])])])),[[r,{activator:e.activateLeftMenu}]])]))),128))])}const{$:Su}=window;var Cu=Object(D["defineComponent"])({props:{menu:{type:Object,required:!0}},directives:{SideNav:Xt},methods:{getMenuUrl(e){return"?"+U.stringify(Object.assign(Object.assign({},U.urlParsed.value),e))},translateIfNecessary(e){return e.includes("_")?a(e):e}},computed:{menuWithSubmenuItems(){const e=this.menu||{};return Object.fromEntries(Object.entries(e).filter(([,e])=>{const t=Object.entries(e).filter(([e])=>"_"!==e[0]);return Object.keys(t).length}))},activateLeftMenu(){return Su("nav .activateLeftMenu")[0]}}});Cu.render=wu;var ku=Cu; /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */const{$:lu}=window;function cu(e){lu.scrollTo(e,20)}function du(e){e&&e.preventDefault()}function uu(e,t){var o,i;if(!e)return;if(-1!==e.indexOf("&"))return;let n=null;try{n=lu("#"+e)}catch(a){return}if(null!==(o=n)&&void 0!==o&&o.length)return cu(n),void du(t);n=lu(`a[name=${e}]`),null!==(i=n)&&void 0!==i&&i.length&&(cu(n),du(t))}function pu(e,t){return(!e||!e.origin||-1!==t.indexOf(e.origin))&&((!e||!e.pathname||-1!==t.indexOf(e.pathname))&&(!e||!e.search||-1!==t.indexOf(e.search)))}function mu(){if("#/"===window.location.hash.slice(0,2)){const e=window.location.hash.slice(2);uu(e,null)}}function hu(){Object(D["watch"])(()=>U.url.value,(e,t)=>{if(!e)return;const o=e.href.indexOf("#/");if(-1===o)return;if(t&&!pu(t,e.href))return;const i=e.href.slice(o+2);uu(i,null)})}function gu(){Object(D["nextTick"])(mu)} + */const{$:Du}=window;function Eu(e){Du.scrollTo(e,20)}function Pu(e){e&&e.preventDefault()}function Tu(e,t){var o,i;if(!e)return;if(-1!==e.indexOf("&"))return;let n=null;try{n=Du("#"+e)}catch(a){return}if(null!==(o=n)&&void 0!==o&&o.length)return Eu(n),void Pu(t);n=Du(`a[name=${e}]`),null!==(i=n)&&void 0!==i&&i.length&&(Eu(n),Pu(t))}function xu(e,t){return(!e||!e.origin||-1!==t.indexOf(e.origin))&&((!e||!e.pathname||-1!==t.indexOf(e.pathname))&&(!e||!e.search||-1!==t.indexOf(e.search)))}function Vu(){if("#/"===window.location.hash.slice(0,2)){const e=window.location.hash.slice(2);Tu(e,null)}}function Bu(){Object(D["watch"])(()=>U.url.value,(e,t)=>{if(!e)return;const o=e.href.indexOf("#/");if(-1===o)return;if(t&&!xu(t,e.href))return;const i=e.href.slice(o+2);Tu(i,null)})}function Nu(){Object(D["nextTick"])(Vu)} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */function bu(e){const t=[];if("INPUT"===e.tagName&&"password"===e.type)t.push(e);else{const o=e.querySelectorAll('input[type="password"]');o.forEach(e=>t.push(e))}return t}function fu(e,t){let o,i=e.value;const n=()=>{e.value="",e.dispatchEvent(new Event("input"))},a=()=>{o&&clearTimeout(o),o=setTimeout(n,1e3*t)},r=()=>a(),s=()=>a();e.addEventListener("input",r),e.addEventListener("change",s),e.dataset.autoClearEnabled="true";const l=setInterval(()=>{e.value!==i&&(i=e.value,a())},300);e.onUmounted={cleanup(){clearTimeout(o),clearInterval(l),e.removeEventListener("input",r),e.removeEventListener("change",s),delete e.dataset.autoClearEnabled}}}hu(),lu(mu);var vu={mounted(e,t){const o=t.value&&t.value.delay||600,i=bu(e);i.forEach(e=>fu(e,o))},unmounted(e){const t=bu(e);t.forEach(e=>{e.onUmounted&&"function"===typeof e.onUmounted.cleanup&&(e.onUmounted.cleanup(),delete e.onUmounted)})}};const Ou={key:0,class:"password-strength row"};function yu(e,t,o,i,n,a){return e.rules.length?(Object(D["openBlock"])(),Object(D["createElementBlock"])("ul",Ou,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.rules,t=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{key:t.ruleText,class:Object(D["normalizeClass"])("col s12 xl6 rule rule-"+e.ruleStatus(t))},[Object(D["createElementVNode"])("span",{class:Object(D["normalizeClass"])({icon:!0,"icon-ok":"valid"===e.ruleStatus(t),"icon-close":"invalid"===e.ruleStatus(t),"icon-circle":"undefined"===e.ruleStatus(t)})},null,2),Object(D["createTextVNode"])(" "+Object(D["toDisplayString"])(t.ruleText),1)],2))),128))])):Object(D["createCommentVNode"])("",!0)}var ju=Object(D["defineComponent"])({props:{validationRules:{type:Array,required:!0},password:{type:String,default:""},externalInputSelector:{type:String,default:""}},data(){return{pwd:"",rules:[]}},emits:["check:isValid"],watch:{pwdValue:{immediate:!0,handler(e){const t=[];this.rules.forEach(o=>{if(e.length||"undefined"===typeof o.passed)try{const i=new RegExp(o.validationRegex.replace(/^\/|\/$/g,""));i.test(e)?(o.passed=!0,t.push(!0)):o.passed=!1}catch(i){console.log("Invalid password validation pattern:",i)}else delete o.passed}),this.rules.length>0&&t.length===this.rules.length&&this.$emit("check:isValid",!0)}}},computed:{pwdValue(){var e;return null!==(e=this.externalInputSelector)&&void 0!==e&&e.length?this.pwd:this.password}},mounted(){var e;if(this.rules=this.validationRules.length?this.validationRules.map(e=>Object.assign({},e)):[],null!==(e=this.externalInputSelector)&&void 0!==e&&e.length){const e=document.querySelector(this.externalInputSelector);e&&(e.addEventListener("input",this.handleExternalInput),this.pwd=e.value)}},unmounted(){var e;if(null!==(e=this.externalInputSelector)&&void 0!==e&&e.length){const e=document.querySelector(this.externalInputSelector);e&&e.removeEventListener("input",this.handleExternalInput)}},methods:{ruleStatus(e){return"undefined"===typeof e.passed?"undefined":e.passed?"valid":"invalid"},handleExternalInput(e){const t=e.target;this.pwd=t.value}}});ju.render=yu;var wu=ju; + */function Mu(e){const t=[];if("INPUT"===e.tagName&&"password"===e.type)t.push(e);else{const o=e.querySelectorAll('input[type="password"]');o.forEach(e=>t.push(e))}return t}function Iu(e,t){let o,i=e.value;const n=()=>{e.value="",e.dispatchEvent(new Event("input"))},a=()=>{o&&clearTimeout(o),o=setTimeout(n,1e3*t)},r=()=>a(),s=()=>a();e.addEventListener("input",r),e.addEventListener("change",s),e.dataset.autoClearEnabled="true";const l=setInterval(()=>{e.value!==i&&(i=e.value,a())},300);e.onUmounted={cleanup(){clearTimeout(o),clearInterval(l),e.removeEventListener("input",r),e.removeEventListener("change",s),delete e.dataset.autoClearEnabled}}}Bu(),Du(Vu);var Fu={mounted(e,t){const o=t.value&&t.value.delay||600,i=Mu(e);i.forEach(e=>Iu(e,o))},unmounted(e){const t=Mu(e);t.forEach(e=>{e.onUmounted&&"function"===typeof e.onUmounted.cleanup&&(e.onUmounted.cleanup(),delete e.onUmounted)})}};const Ru={key:0,class:"password-strength row"};function Lu(e,t,o,i,n,a){return e.rules.length?(Object(D["openBlock"])(),Object(D["createElementBlock"])("ul",Ru,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.rules,t=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{key:t.ruleText,class:Object(D["normalizeClass"])("col s12 xl6 rule rule-"+e.ruleStatus(t))},[Object(D["createElementVNode"])("span",{class:Object(D["normalizeClass"])({icon:!0,"icon-ok":"valid"===e.ruleStatus(t),"icon-close":"invalid"===e.ruleStatus(t),"icon-circle":"undefined"===e.ruleStatus(t)})},null,2),Object(D["createTextVNode"])(" "+Object(D["toDisplayString"])(t.ruleText),1)],2))),128))])):Object(D["createCommentVNode"])("",!0)}var Au=Object(D["defineComponent"])({props:{validationRules:{type:Array,required:!0},password:{type:String,default:""},externalInputSelector:{type:String,default:""}},data(){return{pwd:"",rules:[]}},emits:["check:isValid"],watch:{pwdValue:{immediate:!0,handler(e){const t=[];this.rules.forEach(o=>{if(e.length||"undefined"===typeof o.passed)try{const i=new RegExp(o.validationRegex.replace(/^\/|\/$/g,""));i.test(e)?(o.passed=!0,t.push(!0)):o.passed=!1}catch(i){console.log("Invalid password validation pattern:",i)}else delete o.passed}),this.rules.length>0&&t.length===this.rules.length&&this.$emit("check:isValid",!0)}}},computed:{pwdValue(){var e;return null!==(e=this.externalInputSelector)&&void 0!==e&&e.length?this.pwd:this.password}},mounted(){var e;if(this.rules=this.validationRules.length?this.validationRules.map(e=>Object.assign({},e)):[],null!==(e=this.externalInputSelector)&&void 0!==e&&e.length){const e=document.querySelector(this.externalInputSelector);e&&(e.addEventListener("input",this.handleExternalInput),this.pwd=e.value)}},unmounted(){var e;if(null!==(e=this.externalInputSelector)&&void 0!==e&&e.length){const e=document.querySelector(this.externalInputSelector);e&&e.removeEventListener("input",this.handleExternalInput)}},methods:{ruleStatus(e){return"undefined"===typeof e.passed?"undefined":e.passed?"valid":"invalid"},handleExternalInput(e){const t=e.target;this.pwd=t.value}}});Au.render=Lu;var _u=Au; /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */const Su={class:"main-duplicator-modal-content"},Cu={class:"modal-header"},ku=Object(D["createElementVNode"])("span",{class:"btn-close modal-close"},[Object(D["createElementVNode"])("i",{class:"icon-close"})],-1),Du={key:0,class:"modal-sub-header"},Eu={class:"loading-message"},Pu={key:0,class:"modal-sub-header"},Tu=["innerHTML"],xu={class:"modal-content"},Vu={class:"modal-inputs"},Bu={class:"modal-sub-footer"},Nu=["innerHTML"],Iu=["innerHTML"],Mu={class:"modal-footer"},Fu=["disabled"];function Ru(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("MatomoLoader"),s=Object(D["resolveComponent"])("Field"),l=Object(D["resolveDirective"])("form");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{class:Object(D["normalizeClass"])({modal:!0,"entity-duplicator-modal":!0,"slot-configured":e.$slots.default}),ref:"root"},[Object(D["withDirectives"])(Object(D["createElementVNode"])("div",Su,[Object(D["createElementVNode"])("div",Cu,[ku,Object(D["createElementVNode"])("h2",null,Object(D["toDisplayString"])(e.getModalTitle),1)]),e.isLoading?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Du,[Object(D["createVNode"])(r),Object(D["createElementVNode"])("span",Eu,Object(D["toDisplayString"])(e.translate("General_Loading")),1)])):(Object(D["openBlock"])(),Object(D["createElementBlock"])(D["Fragment"],{key:1},[e.hideSiteSelector?Object(D["createCommentVNode"])("",!0):(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Pu,[Object(D["createElementVNode"])("p",null,[Object(D["createTextVNode"])(Object(D["toDisplayString"])(e.getDuplicateDescription)+" ",1),e.descriptionLearnMoreLink?(Object(D["openBlock"])(),Object(D["createElementBlock"])("span",{key:0,innerHTML:e.$sanitize(e.getLearnMoreLink)},null,8,Tu)):Object(D["createCommentVNode"])("",!0)]),Object(D["createVNode"])(s,{uicontrol:"site",name:"siteSelector",title:e.translate("CoreHome_ChooseWebsite"),modelValue:e.destinationSite,"onUpdate:modelValue":t[0]||(t[0]=t=>e.destinationSite=t),"ui-control-attributes":{onlySitesWithAtLeastWriteAccess:!0,siteTypesToExclude:["rollup"]}},null,8,["title","modelValue"])])),Object(D["createElementVNode"])("div",xu,[Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Vu,[Object(D["renderSlot"])(e.$slots,"default")])),[[l]])]),Object(D["createElementVNode"])("div",Bu,[e.duplicationErrors.length>0?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{key:0,class:Object(D["normalizeClass"])({alert:!0,"alert-danger":!0,"error-list":e.duplicationErrors.length>1})},[Object(D["createElementVNode"])("ul",null,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.duplicationErrors,(t,o)=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{key:o,innerHTML:e.$sanitize(t)},null,8,Nu))),128))])],2)):Object(D["createCommentVNode"])("",!0),Object(D["createElementVNode"])("p",{class:"note-text",innerHTML:e.$sanitize(e.getNoteText)},null,8,Iu)]),Object(D["createElementVNode"])("div",Mu,[Object(D["withDirectives"])(Object(D["createVNode"])(r,null,null,512),[[D["vShow"],e.hasBeenSubmitted]]),Object(D["createElementVNode"])("button",{class:"btn",disabled:!e.getIsValid||e.hasBeenSubmitted,onClick:t[1]||(t[1]=t=>e.submitRequest())},Object(D["toDisplayString"])(e.translate("General_Copy")),9,Fu)])],64))],512),[[D["vShow"],e.isModalVisible]])],2)}const Lu=Ce("CorePluginsAdmin","Field"),Au=Ce("CorePluginsAdmin","Form"),{$:_u}=window;var Hu=Object(D["defineComponent"])({directives:{Form:Au},components:{Field:Lu,MatomoLoader:Ue},props:{modalStore:{type:Object,required:!0},hideSiteSelector:{type:Boolean,default:!1},descriptionLearnMoreLink:{type:String,default:""}},data(){return{isLoading:!0,isValidated:!1,duplicationErrors:[],destinationSite:null,hasBeenSubmitted:!1}},watch:{isModalVisible(e){if(!e)return;let t;this.modalStore.adapter.beforeShowModal&&(t=this.modalStore.adapter.beforeShowModal()),t&&"undefined"!==typeof t||(t=new Promise(e=>e())),this.showModal(),t.then(()=>{this.isLoading=!1})},destinationSite(){this.isValidated=!1}},methods:{closeModal(){const e=this.$refs.root,t=_u(e);t.modal("close")},resetModal(){this.modalStore.hideModal(),this.destinationSite=null,this.isLoading=!0,this.isValidated=!1,this.duplicationErrors=[],this.hasBeenSubmitted=!1},showModal(){const e=this.$refs.root,t=_u(e);t.modal({dismissible:!0,onCloseEnd:()=>{this.resetModal()}}).modal("open")},submitRequest(){this.hasBeenSubmitted=!0,this.getValidationResultPromise().then(e=>{var t;if(!e.isValid&&e.errorMessages.length>0)return this.isValidated=!0,this.hasBeenSubmitted=!1,void(this.duplicationErrors=e.errorMessages);const o=this.modalStore.adapter.prepareApiParams(this.modalStore.getFormValues(null===(t=this.destinationSite)||void 0===t?void 0:t.id));this.modalStore.adapter.submitRequest(o).then(e=>{e&&e.success?(this.modalStore.adapter.onSuccess&&this.modalStore.adapter.onSuccess(e),this.closeModal()):this.setErrorMessages(e)}).catch(e=>{this.setErrorMessages(),this.modalStore.adapter.onFailure&&this.modalStore.adapter.onFailure(e),console.log("Unexpected server error during request.",e)}).finally(()=>{this.hasBeenSubmitted=!1})})},getValidationResultPromise(){var e;this.duplicationErrors=[];const t=this.modalStore.adapter.validateFormFields(this.modalStore.getFormValues(null===(e=this.destinationSite)||void 0===e?void 0:e.id));return"isValid"in t?new Promise(e=>e(t)):t},setErrorMessages(e=null){let t=(null===e||void 0===e?void 0:e.message)||"";t&&0!==t.length||(t=a("General_ErrorRequest","","")),this.duplicationErrors=[],this.duplicationErrors.push(t)}},mounted(){Object(D["watch"])(()=>this.modalStore.state.entityFormData,()=>{this.isValidated=!1},{deep:!0})},computed:{isModalVisible(){var e;return null!==(e=this.modalStore.state.isModalVisible)&&void 0!==e&&e},getModalTitle(){return a("CoreHome_CopyX",this.modalStore.getEntityTypeTranslation)},getNoteText(){const e=a("CoreHome_CopyModalNote","","",this.modalStore.getEntityTypeTranslation);return""+e},getDuplicateDescription(){return a("CoreHome_CopyXDescription",this.modalStore.getEntityTypeTranslation)},getLearnMoreLink(){if(!this.descriptionLearnMoreLink)return"";const e=pe(this.descriptionLearnMoreLink);return a("CoreHome_LearnMoreFullStop",e,"")},getIsValid(){return!this.isValidated||Array.isArray(this.duplicationErrors)&&0===this.duplicationErrors.length}}});Hu.render=Ru;var $u=Hu;const Uu=["title","aria-disabled"];function qu(e,t,o,i,n,a){const r=Object(D["resolveDirective"])("tooltips");return Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{class:Object(D["normalizeClass"])([{"entity-duplicator-action":!0,"table-action":!0,"icon-content-copy":!0,"is-disabled":!e.isActionEnabled},e.extraClasses]),title:e.getActionTooltip,"aria-disabled":!e.isActionEnabled,onClick:t[0]||(t[0]=t=>!e.isActionEnabled||e.handleClick())},null,10,Uu)),[[r],[D["vShow"],e.isActionVisible]])}var Wu=Object(D["defineComponent"])({props:{actionFormData:{type:Object,required:!0},modalStore:{type:Object,required:!0},isActionVisible:{type:Boolean,required:!0},isActionEnabled:{type:Boolean,default:!1},tooltipTextOverride:{type:String,default:""},tooltipTextOverrideDisabled:{type:String,default:""},extraClasses:{type:[String,Array,Object],default:""}},directives:{Tooltips:ct},methods:{handleClick(){this.modalStore.showModal(this.actionFormData)}},computed:{getActionTooltip(){return this.isActionEnabled&&this.tooltipTextOverride.length?r(this.tooltipTextOverride):!this.isActionEnabled&&this.tooltipTextOverrideDisabled.length?r(this.tooltipTextOverrideDisabled):a("CoreHome_CopyX",this.modalStore.getEntityTypeTranslation)}}});Wu.render=qu;var zu=Wu;function Gu(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} + */const Hu={class:"main-duplicator-modal-content"},$u={class:"modal-header"},Uu=Object(D["createElementVNode"])("span",{class:"btn-close modal-close"},[Object(D["createElementVNode"])("i",{class:"icon-close"})],-1),qu={key:0,class:"modal-sub-header"},Wu={class:"loading-message"},zu={key:0,class:"modal-sub-header"},Gu=["innerHTML"],Ku={class:"modal-content"},Yu={class:"modal-inputs"},Qu={class:"modal-sub-footer"},Ju=["innerHTML"],Xu=["innerHTML"],Zu={class:"modal-footer"},em=["disabled"];function tm(e,t,o,i,n,a){const r=Object(D["resolveComponent"])("MatomoLoader"),s=Object(D["resolveComponent"])("Field"),l=Object(D["resolveDirective"])("form");return Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{class:Object(D["normalizeClass"])({modal:!0,"entity-duplicator-modal":!0,"slot-configured":e.$slots.default}),ref:"root"},[Object(D["withDirectives"])(Object(D["createElementVNode"])("div",Hu,[Object(D["createElementVNode"])("div",$u,[Uu,Object(D["createElementVNode"])("h2",null,Object(D["toDisplayString"])(e.getModalTitle),1)]),e.isLoading?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",qu,[Object(D["createVNode"])(r),Object(D["createElementVNode"])("span",Wu,Object(D["toDisplayString"])(e.translate("General_Loading")),1)])):(Object(D["openBlock"])(),Object(D["createElementBlock"])(D["Fragment"],{key:1},[e.hideSiteSelector?Object(D["createCommentVNode"])("",!0):(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",zu,[Object(D["createElementVNode"])("p",null,[Object(D["createTextVNode"])(Object(D["toDisplayString"])(e.getDuplicateDescription)+" ",1),e.descriptionLearnMoreLink?(Object(D["openBlock"])(),Object(D["createElementBlock"])("span",{key:0,innerHTML:e.$sanitize(e.getLearnMoreLink)},null,8,Gu)):Object(D["createCommentVNode"])("",!0)]),Object(D["createVNode"])(s,{uicontrol:"site",name:"siteSelector",title:e.translate("CoreHome_ChooseWebsite"),modelValue:e.destinationSite,"onUpdate:modelValue":t[0]||(t[0]=t=>e.destinationSite=t),"ui-control-attributes":{onlySitesWithAtLeastWriteAccess:!0,siteTypesToExclude:["rollup"]}},null,8,["title","modelValue"])])),Object(D["createElementVNode"])("div",Ku,[Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("div",Yu,[Object(D["renderSlot"])(e.$slots,"default")])),[[l]])]),Object(D["createElementVNode"])("div",Qu,[e.duplicationErrors.length>0?(Object(D["openBlock"])(),Object(D["createElementBlock"])("div",{key:0,class:Object(D["normalizeClass"])({alert:!0,"alert-danger":!0,"error-list":e.duplicationErrors.length>1})},[Object(D["createElementVNode"])("ul",null,[(Object(D["openBlock"])(!0),Object(D["createElementBlock"])(D["Fragment"],null,Object(D["renderList"])(e.duplicationErrors,(t,o)=>(Object(D["openBlock"])(),Object(D["createElementBlock"])("li",{key:o,innerHTML:e.$sanitize(t)},null,8,Ju))),128))])],2)):Object(D["createCommentVNode"])("",!0),Object(D["createElementVNode"])("p",{class:"note-text",innerHTML:e.$sanitize(e.getNoteText)},null,8,Xu)]),Object(D["createElementVNode"])("div",Zu,[Object(D["withDirectives"])(Object(D["createVNode"])(r,null,null,512),[[D["vShow"],e.hasBeenSubmitted]]),Object(D["createElementVNode"])("button",{class:"btn",disabled:!e.getIsValid||e.hasBeenSubmitted,onClick:t[1]||(t[1]=t=>e.submitRequest())},Object(D["toDisplayString"])(e.translate("General_Copy")),9,em)])],64))],512),[[D["vShow"],e.isModalVisible]])],2)}const om=Ce("CorePluginsAdmin","Field"),im=Ce("CorePluginsAdmin","Form"),{$:nm}=window;var am=Object(D["defineComponent"])({directives:{Form:im},components:{Field:om,MatomoLoader:qe},props:{modalStore:{type:Object,required:!0},hideSiteSelector:{type:Boolean,default:!1},descriptionLearnMoreLink:{type:String,default:""}},data(){return{isLoading:!0,isValidated:!1,duplicationErrors:[],destinationSite:null,hasBeenSubmitted:!1}},watch:{isModalVisible(e){if(!e)return;let t;this.modalStore.adapter.beforeShowModal&&(t=this.modalStore.adapter.beforeShowModal()),t&&"undefined"!==typeof t||(t=new Promise(e=>e())),this.showModal(),t.then(()=>{this.isLoading=!1})},destinationSite(){this.isValidated=!1}},methods:{closeModal(){const e=this.$refs.root,t=nm(e);t.modal("close")},resetModal(){this.modalStore.hideModal(),this.destinationSite=null,this.isLoading=!0,this.isValidated=!1,this.duplicationErrors=[],this.hasBeenSubmitted=!1},showModal(){const e=this.$refs.root,t=nm(e);t.modal({dismissible:!0,onCloseEnd:()=>{this.resetModal()}}).modal("open")},submitRequest(){this.hasBeenSubmitted=!0,this.getValidationResultPromise().then(e=>{var t;if(!e.isValid&&e.errorMessages.length>0)return this.isValidated=!0,this.hasBeenSubmitted=!1,void(this.duplicationErrors=e.errorMessages);const o=this.modalStore.adapter.prepareApiParams(this.modalStore.getFormValues(null===(t=this.destinationSite)||void 0===t?void 0:t.id));this.modalStore.adapter.submitRequest(o).then(e=>{e&&e.success?(this.modalStore.adapter.onSuccess&&this.modalStore.adapter.onSuccess(e),this.closeModal()):this.setErrorMessages(e)}).catch(e=>{this.setErrorMessages(),this.modalStore.adapter.onFailure&&this.modalStore.adapter.onFailure(e),console.log("Unexpected server error during request.",e)}).finally(()=>{this.hasBeenSubmitted=!1})})},getValidationResultPromise(){var e;this.duplicationErrors=[];const t=this.modalStore.adapter.validateFormFields(this.modalStore.getFormValues(null===(e=this.destinationSite)||void 0===e?void 0:e.id));return"isValid"in t?new Promise(e=>e(t)):t},setErrorMessages(e=null){let t=(null===e||void 0===e?void 0:e.message)||"";t&&0!==t.length||(t=a("General_ErrorRequest","","")),this.duplicationErrors=[],this.duplicationErrors.push(t)}},mounted(){Object(D["watch"])(()=>this.modalStore.state.entityFormData,()=>{this.isValidated=!1},{deep:!0})},computed:{isModalVisible(){var e;return null!==(e=this.modalStore.state.isModalVisible)&&void 0!==e&&e},getModalTitle(){return a("CoreHome_CopyX",this.modalStore.getEntityTypeTranslation)},getNoteText(){const e=a("CoreHome_CopyModalNote","","",this.modalStore.getEntityTypeTranslation);return""+e},getDuplicateDescription(){return a("CoreHome_CopyXDescription",this.modalStore.getEntityTypeTranslation)},getLearnMoreLink(){if(!this.descriptionLearnMoreLink)return"";const e=me(this.descriptionLearnMoreLink);return a("CoreHome_LearnMoreFullStop",e,"")},getIsValid(){return!this.isValidated||Array.isArray(this.duplicationErrors)&&0===this.duplicationErrors.length}}});am.render=tm;var rm=am;const sm=["title","aria-disabled"];function lm(e,t,o,i,n,a){const r=Object(D["resolveDirective"])("tooltips");return Object(D["withDirectives"])((Object(D["openBlock"])(),Object(D["createElementBlock"])("a",{class:Object(D["normalizeClass"])([{"entity-duplicator-action":!0,"table-action":!0,"icon-content-copy":!0,"is-disabled":!e.isActionEnabled},e.extraClasses]),title:e.getActionTooltip,"aria-disabled":!e.isActionEnabled,onClick:t[0]||(t[0]=t=>!e.isActionEnabled||e.handleClick())},null,10,sm)),[[r],[D["vShow"],e.isActionVisible]])}var cm=Object(D["defineComponent"])({props:{actionFormData:{type:Object,required:!0},modalStore:{type:Object,required:!0},isActionVisible:{type:Boolean,required:!0},isActionEnabled:{type:Boolean,default:!1},tooltipTextOverride:{type:String,default:""},tooltipTextOverrideDisabled:{type:String,default:""},extraClasses:{type:[String,Array,Object],default:""}},directives:{Tooltips:dt},methods:{handleClick(){this.modalStore.showModal(this.actionFormData)}},computed:{getActionTooltip(){return this.isActionEnabled&&this.tooltipTextOverride.length?r(this.tooltipTextOverride):!this.isActionEnabled&&this.tooltipTextOverrideDisabled.length?r(this.tooltipTextOverrideDisabled):a("CoreHome_CopyX",this.modalStore.getEntityTypeTranslation)}}});cm.render=lm;var dm=cm;function um(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */class Ku{constructor(e){Gu(this,"module",void 0),Gu(this,"method",void 0),Gu(this,"format",void 0),Gu(this,"requiredFields",void 0),this.module=e.module||"API",this.method=e.method,this.format=e.format||"json",this.requiredFields=e.requiredFields||["idSite","idDestinationSites"]}async validateFormFields(e){const t=[];return this.requiredFields.forEach(o=>{o in e&&e[o]||t.push(a("General_Required",o))}),new Promise(e=>e({errorMessages:t,isValid:0===t.length}))}prepareApiParams(e){return Object.assign({idSite:M.idSite||U.parsed.value.idSite,idDestinationSites:[e.idDestinationSite]},e)}async submitRequest(e){this.module=e.module||this.module,this.method=e.method||this.method,this.format=e.format||this.format;const t=e;if(!this.method||this.method.length<1)throw new Error("The POST method cannot be empty!");const o=new te;return o.useCallbackInCaseOfError(),o.setErrorCallback(null),o.removeDefaultParameter("date"),o.removeDefaultParameter("period"),o.removeDefaultParameter("segment"),o.addParams({module:this.module,method:this.method,format:this.format},"GET"),o.addParams(t,"POST"),o.setFormat(this.format),o.send()}onSuccess(e){let t=new Promise(e=>e());this.onSuccessCallback&&(t=this.onSuccessCallback(e)),t.then(()=>{setTimeout(()=>{const t=Zi.show({message:e.message,context:e.success?"success":"error",type:"toast",id:"entityDuplicationResult"});Zi.scrollToNotification(t)})})}}function Yu(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} + */class mm{constructor(e){um(this,"module",void 0),um(this,"method",void 0),um(this,"format",void 0),um(this,"requiredFields",void 0),this.module=e.module||"API",this.method=e.method,this.format=e.format||"json",this.requiredFields=e.requiredFields||["idSite","idDestinationSites"]}async validateFormFields(e){const t=[];return this.requiredFields.forEach(o=>{o in e&&e[o]||t.push(a("General_Required",o))}),new Promise(e=>e({errorMessages:t,isValid:0===t.length}))}prepareApiParams(e){return Object.assign({idSite:I.idSite||U.parsed.value.idSite,idDestinationSites:[e.idDestinationSite]},e)}async submitRequest(e){this.module=e.module||this.module,this.method=e.method||this.method,this.format=e.format||this.format;const t=e;if(!this.method||this.method.length<1)throw new Error("The POST method cannot be empty!");const o=new te;return o.useCallbackInCaseOfError(),o.setErrorCallback(null),o.removeDefaultParameter("date"),o.removeDefaultParameter("period"),o.removeDefaultParameter("segment"),o.addParams({module:this.module,method:this.method,format:this.format},"GET"),o.addParams(t,"POST"),o.setFormat(this.format),o.send()}onSuccess(e){let t=new Promise(e=>e());this.onSuccessCallback&&(t=this.onSuccessCallback(e)),t.then(()=>{setTimeout(()=>{const t=en.show({message:e.message,context:e.success?"success":"error",type:"toast",id:"entityDuplicationResult"});en.scrollToNotification(t)})})}}function pm(e,t,o){return t in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e} /*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later - */class Qu{constructor(e,t,o){Yu(this,"state",Object(D["reactive"])({isModalVisible:!1,commonFormData:{},entityFormData:{},entityTypeTranslation:""})),Yu(this,"adapter",void 0),this.state.entityTypeTranslation=e,this.adapter="validateFormFields"in t?t:new Ku(t),this.state.commonFormData=null!==o&&void 0!==o?o:{}}static buildStoreInstance(e,t,o){return Object(D["reactive"])(new Qu(e,t,o))}showModal(e){this.resetFormData(),Object.entries(null!==e&&void 0!==e?e:{}).forEach(([e,t])=>{this.state.entityFormData[e]=t}),this.state.isModalVisible=!0}hideModal(){this.state.isModalVisible=!1,this.resetFormData()}resetFormData(){Object.keys(this.state.entityFormData).forEach(e=>{delete this.state.entityFormData[e]})}getFormValues(e){const t=Array.isArray(e)?e:[];return e&&!Array.isArray(e)&&t.push(e),Object.assign(Object.assign({idSite:M.idSite||U.parsed.value.idSite,idDestinationSites:t},this.state.commonFormData),this.state.entityFormData)}get getEntityTypeTranslation(){let e="CoreHome_ReportLowercase";return this.state.entityTypeTranslation&&(e=this.state.entityTypeTranslation),r(e)}} + */class hm{constructor(e,t,o){pm(this,"state",Object(D["reactive"])({isModalVisible:!1,commonFormData:{},entityFormData:{},entityTypeTranslation:""})),pm(this,"adapter",void 0),this.state.entityTypeTranslation=e,this.adapter="validateFormFields"in t?t:new mm(t),this.state.commonFormData=null!==o&&void 0!==o?o:{}}static buildStoreInstance(e,t,o){return Object(D["reactive"])(new hm(e,t,o))}showModal(e){this.resetFormData(),Object.entries(null!==e&&void 0!==e?e:{}).forEach(([e,t])=>{this.state.entityFormData[e]=t}),this.state.isModalVisible=!0}hideModal(){this.state.isModalVisible=!1,this.resetFormData()}resetFormData(){Object.keys(this.state.entityFormData).forEach(e=>{delete this.state.entityFormData[e]})}getFormValues(e){const t=Array.isArray(e)?e:[];return e&&!Array.isArray(e)&&t.push(e),Object.assign(Object.assign({idSite:I.idSite||U.parsed.value.idSite,idDestinationSites:t},this.state.commonFormData),this.state.entityFormData)}get getEntityTypeTranslation(){let e="CoreHome_ReportLowercase";return this.state.entityTypeTranslation&&(e=this.state.entityTypeTranslation),r(e)}} /*! * Matomo - free/libre analytics platform * diff --git a/app/plugins/CoreHome/vue/src/Comparisons/Comparisons.store.ts b/app/plugins/CoreHome/vue/src/Comparisons/Comparisons.store.ts index 4d107dea8..e0cc6edaa 100644 --- a/app/plugins/CoreHome/vue/src/Comparisons/Comparisons.store.ts +++ b/app/plugins/CoreHome/vue/src/Comparisons/Comparisons.store.ts @@ -7,6 +7,7 @@ import { reactive, + ref, watch, computed, readonly, @@ -84,7 +85,7 @@ export default class ComparisonsStore { readonly state = readonly(this.privateState); // for tests - private colors: { [key: string]: string } = {}; + private colors = ref<{ [key: string]: string }>({}); readonly segmentComparisons = computed(() => this.parseSegmentComparisons()); @@ -103,7 +104,7 @@ export default class ComparisonsStore { } $(() => { - this.colors = this.getAllSeriesColors() as { [key: string]: string }; + this.colors.value = this.getAllSeriesColors() as { [key: string]: string }; }); watch( @@ -163,11 +164,11 @@ export default class ComparisonsStore { ) % SERIES_COLOR_COUNT; if (metricIndex === 0) { - return this.colors[`series${seriesIndex}`]; + return this.colors.value[`series${seriesIndex}`]; } const shadeIndex = metricIndex % SERIES_SHADE_COUNT; - return this.colors[`series${seriesIndex}-shade${shadeIndex}`]; + return this.colors.value[`series${seriesIndex}-shade${shadeIndex}`]; } getSeriesColorName(seriesIndex: number, metricIndex: number): string { @@ -210,7 +211,7 @@ export default class ComparisonsStore { seriesInfo.push({ index: seriesIndex, params: { ...segmentComp.params, ...periodComp.params }, - color: this.colors[`series${seriesIndex}`], + color: this.colors.value[`series${seriesIndex}`], }); seriesIndex += 1; }); diff --git a/app/plugins/CoreHome/vue/src/DataTable/DataTableActions.component.spec.ts b/app/plugins/CoreHome/vue/src/DataTable/DataTableActions.component.spec.ts new file mode 100644 index 000000000..2a098d31e --- /dev/null +++ b/app/plugins/CoreHome/vue/src/DataTable/DataTableActions.component.spec.ts @@ -0,0 +1,121 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +import { mount } from '@vue/test-utils'; +import DataTableActions from './DataTableActions.vue'; + +function mockTranslateStub(key: string, ...args: string[]) { + const messages: Record = { + CoreHome_ShowPercentageValuesDataTable: 'The report is showing absolute values %s Show percentages', + CoreHome_ShowAbsoluteValuesDataTable: 'The report is showing percentages %s Show absolute values', + CoreHome_ShowPercentageValues: 'Show percentages', + CoreHome_ShowAbsoluteValues: 'Show absolute values', + CoreHome_Default: 'default', + CoreHome_ReportConfigure: 'Configure this report', + }; + + const message = messages[key] || key; + + // mirror the real helper: with no replacement values the raw message is returned, placeholders + // intact. getSingleStateIconText relies on that to detect whether a message has an action half. + if (!args.length) { + return message; + } + + const values = args.slice(); + + return message.replace(/%(\d\$)?s/g, () => values.shift() || ''); +} + +jest.mock('../translate', () => ({ translate: mockTranslateStub })); + +jest.mock('../DropdownButton/DropdownButton', () => ({ default: {} })); +jest.mock('../ReportExport/ReportExport', () => ({ default: {} })); + +describe('DataTableActions percentage values setting', () => { + const percentageItem = '.configItem.dataTableShowPercentageValues'; + + function mountComponent(customProps = {}) { + return mount(DataTableActions, { + props: { + showFooter: true, + showFooterIcons: true, + reportSupportsPercentageValues: true, + viewDataTable: 'table', + footerIcons: [], + requestParams: {}, + apiMethodToRequestDataTable: 'DevicesDetection.getType', + maxFilterLimit: 100, + reportId: 'DevicesDetection.getType', + dataTableActions: [], + clientSideParameters: {}, + translations: {}, + ...customProps, + }, + global: { + // the template calls `translate` as a global property, not the imported helper + config: { + globalProperties: { + translate: mockTranslateStub, + $sanitize: (value: string) => value, + } as any, + }, + }, + }); + } + + it('should not offer the setting when the report has no percentage values', () => { + const wrapper = mountComponent({ reportSupportsPercentageValues: false }); + + expect(wrapper.find(percentageItem).exists()).toBe(false); + // the whole configure icon disappears when this is the only candidate item + expect(wrapper.find('a.dropdownConfigureIcon').exists()).toBe(false); + }); + + it('should not offer the setting on an empty table, like the totals row item', () => { + const wrapper = mountComponent({ isDataTableEmpty: true }); + + expect(wrapper.find(percentageItem).exists()).toBe(false); + }); + + it('should offer the setting, unhighlighted, when the report shows absolute values', () => { + const wrapper = mountComponent({ clientSideParameters: {} }); + + const item = wrapper.find(percentageItem); + expect(item.exists()).toBe(true); + + expect(item.text()).toContain('The report is showing absolute values'); + // the offered action is rendered as the `.action` half of the item + expect(item.find('span.action').text()).toBe('Show percentages'); + expect(item.text()).not.toContain('default'); + + // the action, not the current state, is the accessible name + expect(item.attributes('aria-label')).toBe('Show percentages'); + expect(wrapper.find('a.dropdownConfigureIcon').classes()).not.toContain('highlighted'); + }); + + it('should flip the wording, the accessible name and the icon state when percentages are shown', () => { + const wrapper = mountComponent({ clientSideParameters: { show_percentage_values: '1' } }); + + const item = wrapper.find(percentageItem); + expect(item.text()).toContain('The report is showing percentages'); + // switching back returns the report to its default, as for the other toggles + expect(item.find('span.action').text()).toBe('Show absolute values (default)'); + + expect(item.attributes('aria-label')).toBe('Show absolute values'); + expect(wrapper.find('a.dropdownConfigureIcon').classes()).toContain('highlighted'); + }); + + it('should treat a disabled setting the same however it is expressed', () => { + ['0', 0, false, ''].forEach((value) => { + const wrapper = mountComponent({ clientSideParameters: { show_percentage_values: value } }); + + expect(wrapper.find(percentageItem).attributes('aria-label')).toBe('Show percentages'); + expect(wrapper.find('a.dropdownConfigureIcon').classes()).not.toContain('highlighted'); + }); + }); +}); diff --git a/app/plugins/CoreHome/vue/src/DataTable/DataTableActions.vue b/app/plugins/CoreHome/vue/src/DataTable/DataTableActions.vue index 474386d16..a52d3d9bf 100644 --- a/app/plugins/CoreHome/vue/src/DataTable/DataTableActions.vue +++ b/app/plugins/CoreHome/vue/src/DataTable/DataTableActions.vue @@ -184,6 +184,13 @@ v-html="$sanitize(keepTotalsRowText)" >
+
  • +
    +
  • ; @@ -440,6 +452,20 @@ export default defineComponent({ 'CoreHome_AddTotalsRowDataTable', ); }, + percentageValuesText() { + const params = this.clientSideParameters as Record; + return getToggledIconText( + isBooleanLikeSet(params.show_percentage_values), + 'CoreHome_ShowAbsoluteValuesDataTable', + 'CoreHome_ShowPercentageValuesDataTable', + ); + }, + percentageValuesLabel() { + const params = this.clientSideParameters as Record; + return isBooleanLikeSet(params.show_percentage_values) + ? translate('CoreHome_ShowAbsoluteValues') + : translate('CoreHome_ShowPercentageValues'); + }, includeAggregateRowsText() { const params = this.clientSideParameters as Record; return getToggledIconText( @@ -479,7 +505,8 @@ export default defineComponent({ || isBooleanLikeSet(params.include_aggregate_rows) || isBooleanLikeSet(params.show_dimensions) || isBooleanLikeSet(params.pivotBy) - || isBooleanLikeSet(params.enable_filter_excludelowpop); + || isBooleanLikeSet(params.enable_filter_excludelowpop) + || isBooleanLikeSet(params.show_percentage_values); }, isTableView() { return this.viewDataTable === 'table' diff --git a/app/plugins/CoreHome/vue/src/EnrichedHeadline/EnrichedHeadline.less b/app/plugins/CoreHome/vue/src/EnrichedHeadline/EnrichedHeadline.less index bff6c1b92..1ad36b35a 100644 --- a/app/plugins/CoreHome/vue/src/EnrichedHeadline/EnrichedHeadline.less +++ b/app/plugins/CoreHome/vue/src/EnrichedHeadline/EnrichedHeadline.less @@ -33,6 +33,8 @@ .iconsBar { line-height: 1 !important; + display: inline-flex; + align-items: center; } .ratingIcons { diff --git a/app/plugins/CoreHome/vue/src/ReportHeader/ReportHeader.less b/app/plugins/CoreHome/vue/src/ReportHeader/ReportHeader.less new file mode 100644 index 000000000..350b2b7e7 --- /dev/null +++ b/app/plugins/CoreHome/vue/src/ReportHeader/ReportHeader.less @@ -0,0 +1,71 @@ +// Shared, widget-first report header: a leading region (title + future feedback actions) on +// the left, an inline widget-controls row and a reserved report-actions anchor on the right. +// Controls are visible by default; a host can opt into hover-reveal via the +// `__reportHeader-onHover` context hook. +.reportHeader { + display: flex; + align-items: flex-start; + gap: 20px; + border-radius: 8px; + padding: 20px; + + .reportHeader__main { + flex: auto; + min-width: 0; + } + + .reportHeader__title { + margin: 0; + font-size: 20px; + line-height: 24px; + font-weight: normal; + color: @theme-color-widget-title-text; + // The title wraps over several lines rather than being clipped/ellipsised (a plugin like + // SingleMetricView also appends a metric-picker dropdown into it that must stay visible). + // break-word keeps an overly long word from spilling over the widget controls. + overflow-wrap: break-word; + } + + .reportHeader__title--clickable { + cursor: pointer; + text-decoration: none; + } + + .reportHeader__widgetControls { + flex: none; + display: flex; + align-items: safe center; + // Visible by default. Hover-reveal is opt-in per host via the `__reportHeader-onHover` + // hook (rules below); where the hook is absent the controls stay visible (e.g. a + // maximised widget). Uses opacity (not display) so they stay keyboard-focusable. + opacity: 1; + transition: opacity 0.15s ease; + + &:empty { + display: none; + } + } + + .reportHeader__actions { + flex: none; + display: flex; + align-items: safe center; + + &:empty { + display: none; + } + } +} + +// Opt-in hover-reveal: when the host marks its scope with the `__reportHeader-onHover` context +// hook (e.g. the dashboard widget), hide the controls until the host is hovered or a control +// is focused. Absent the hook the controls stay visible (base rule above), which is how a +// maximised widget keeps them on. The hook is reference-only — see CSS rules, rule 31. +.__reportHeader-onHover .reportHeader__widgetControls { + opacity: 0; +} + +.__reportHeader-onHover:hover .reportHeader__widgetControls, +.__reportHeader-onHover:focus-within .reportHeader__widgetControls { + opacity: 1; +} diff --git a/app/plugins/CoreHome/vue/src/ReportHeader/ReportHeader.spec.ts b/app/plugins/CoreHome/vue/src/ReportHeader/ReportHeader.spec.ts new file mode 100644 index 000000000..14569adb1 --- /dev/null +++ b/app/plugins/CoreHome/vue/src/ReportHeader/ReportHeader.spec.ts @@ -0,0 +1,113 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +import { mount } from '@vue/test-utils'; +import ReportHeader from './ReportHeader.vue'; + +jest.mock('../translate', () => ({ + translate: (key: string) => { + const messages: Record = { + Dashboard_Minimise: 'Minimise', + Dashboard_Maximise: 'Maximise', + General_Refresh: 'Refresh', + General_Close: 'Close', + General_Widget: 'Widget', + }; + + return messages[key] || key; + }, +})); + +describe('ReportHeader', () => { + function mountComponent(customProps = {}) { + return mount(ReportHeader, { + props: { + context: 'dashboard', + title: 'Visits Over Time', + ...customProps, + }, + }); + } + + it('should render the widget title', () => { + const wrapper = mountComponent(); + + expect(wrapper.find('.reportHeader__title').text()).toBe('Visits Over Time'); + }); + + it('should always render the reserved (empty) report-actions region', () => { + const wrapper = mountComponent(); + + expect(wrapper.find('.reportHeader__actions').exists()).toBe(true); + }); + + it('should show all four controls in the dashboard context', () => { + const wrapper = mountComponent({ context: 'dashboard' }); + + expect(wrapper.findAll('.widgetControls__action').length).toBe(4); + }); + + it('should show only minimise and refresh in the maximised context', () => { + const wrapper = mountComponent({ context: 'maximised' }); + + expect(wrapper.find('.widgetControls__action--minimise').exists()).toBe(true); + expect(wrapper.find('.widgetControls__action--refresh').exists()).toBe(true); + expect(wrapper.find('.widgetControls__action--maximise').exists()).toBe(false); + expect(wrapper.find('.widgetControls__action--close').exists()).toBe(false); + }); + + it('should show only maximise and close in the collapsed context', () => { + const wrapper = mountComponent({ context: 'collapsed' }); + + expect(wrapper.find('.widgetControls__action--maximise').exists()).toBe(true); + expect(wrapper.find('.widgetControls__action--close').exists()).toBe(true); + expect(wrapper.find('.widgetControls__action--minimise').exists()).toBe(false); + expect(wrapper.find('.widgetControls__action--refresh').exists()).toBe(false); + }); + + it('should render no controls in the preview and widgetized contexts', () => { + expect(mountComponent({ context: 'preview' }).find('.widgetControls').exists()).toBe(false); + expect(mountComponent({ context: 'widgetized' }).find('.widgetControls').exists()).toBe(false); + }); + + it('should re-emit control intents from the row', async () => { + const wrapper = mountComponent({ context: 'dashboard' }); + + await wrapper.find('.widgetControls__action--refresh').trigger('click'); + + expect(wrapper.emitted('refresh')).toBeTruthy(); + }); + + it('should dispatch a bubbling widgetcontrol:* CustomEvent for the jQuery bridge', async () => { + const wrapper = mountComponent({ context: 'dashboard' }); + const received: string[] = []; + wrapper.element.addEventListener('widgetcontrol:maximise', () => received.push('maximise')); + + await wrapper.find('.widgetControls__action--maximise').trigger('click'); + + expect(received).toEqual(['maximise']); + }); + + it('should mark the title clickable and emit titleClick when clickable', async () => { + const wrapper = mountComponent({ context: 'preview', titleClickable: true }); + + const title = wrapper.find('.reportHeader__title'); + expect(title.classes()).toContain('reportHeader__title--clickable'); + expect(title.attributes('role')).toBe('button'); + + await title.trigger('click'); + expect(wrapper.emitted('titleClick')).toBeTruthy(); + }); + + it('should not make the title clickable by default', () => { + const wrapper = mountComponent(); + + const title = wrapper.find('.reportHeader__title'); + expect(title.classes()).not.toContain('reportHeader__title--clickable'); + expect(title.attributes('role')).toBeUndefined(); + }); +}); diff --git a/app/plugins/CoreHome/vue/src/ReportHeader/ReportHeader.vue b/app/plugins/CoreHome/vue/src/ReportHeader/ReportHeader.vue new file mode 100644 index 000000000..7eaa0db32 --- /dev/null +++ b/app/plugins/CoreHome/vue/src/ReportHeader/ReportHeader.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/app/plugins/CoreHome/vue/src/Sparkline/Sparkline.vue b/app/plugins/CoreHome/vue/src/Sparkline/Sparkline.vue index 64d8be231..545762698 100644 --- a/app/plugins/CoreHome/vue/src/Sparkline/Sparkline.vue +++ b/app/plugins/CoreHome/vue/src/Sparkline/Sparkline.vue @@ -59,15 +59,12 @@ export default defineComponent({ const colors = JSON.stringify(sparklineColors); - // The redesign lets sparklines be rendered server-side at a custom size; without it the - // width/height props only control the displayed size and the server uses its defaults. // The width/height props are the displayed size; the PNG is rendered at twice that so it - // stays crisp on hi-DPI screens (matching the legacy 200x50-render / 100x25-display ratio). - const redesignEnabled = document.body.classList.contains('sparklines-redesign-enabled'); - const sizeParams = redesignEnabled ? { + // stays crisp on hi-DPI screens. + const sizeParams = { ...(typeof this.width === 'number' ? { width: this.width * 2 } : {}), ...(typeof this.height === 'number' ? { height: this.height * 2 } : {}), - } : {}; + }; const defaultParams = { forceView: '1', diff --git a/app/plugins/CoreHome/vue/src/WidgetControls/WidgetControls.less b/app/plugins/CoreHome/vue/src/WidgetControls/WidgetControls.less new file mode 100644 index 000000000..cb1d0037e --- /dev/null +++ b/app/plugins/CoreHome/vue/src/WidgetControls/WidgetControls.less @@ -0,0 +1,45 @@ +// Inline row of widget-control icon buttons. Idle icons are light grey and darken on +// hover/focus; the host (ReportHeader) hides/reveals the whole row. +.widgetControls { + display: inline-flex; + align-items: safe center; + gap: 8px; + height: 24px; + + .widgetControls__action { + display: inline-flex; + align-items: safe center; + justify-content: safe center; + width: 20px; + height: 20px; + padding: 0; + margin: 0; + background: transparent; + border: 0; + border-radius: 4px; + line-height: 0; + cursor: pointer; + appearance: none; + color: @theme-color-text-lighter; // idle + + &:hover, + &:focus-visible { + color: @theme-color-text; // hover / keyboard focus + } + + // no ring on mouse focus; show it only for keyboard nav, hugging the 20px button + &:focus { + outline: none; + } + + &:focus-visible { + outline: 2px solid @theme-color-focus-ring; + outline-offset: 2px; + } + } + + .widgetControls__icon { + font-size: 16px; + line-height: 1; + } +} diff --git a/app/plugins/CoreHome/vue/src/WidgetControls/WidgetControls.spec.ts b/app/plugins/CoreHome/vue/src/WidgetControls/WidgetControls.spec.ts new file mode 100644 index 000000000..f4853cf38 --- /dev/null +++ b/app/plugins/CoreHome/vue/src/WidgetControls/WidgetControls.spec.ts @@ -0,0 +1,77 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +import { mount } from '@vue/test-utils'; +import WidgetControls from './WidgetControls.vue'; + +jest.mock('../translate', () => ({ + translate: (key: string) => { + const messages: Record = { + Dashboard_Minimise: 'Minimise', + Dashboard_Maximise: 'Maximise', + General_Refresh: 'Refresh', + General_Close: 'Close', + }; + + return messages[key] || key; + }, +})); + +describe('WidgetControls', () => { + function mountComponent(customProps = {}) { + return mount(WidgetControls, { + props: { + canMinimise: true, + canMaximise: true, + canRefresh: true, + canClose: true, + ...customProps, + }, + }); + } + + it('should render one action button per enabled control', () => { + const wrapper = mountComponent(); + + expect(wrapper.findAll('.widgetControls__action').length).toBe(4); + }); + + it('should only render controls whose flag is set', () => { + const wrapper = mountComponent({ + canMinimise: true, + canMaximise: false, + canRefresh: true, + canClose: false, + }); + + expect(wrapper.findAll('.widgetControls__action').length).toBe(2); + expect(wrapper.find('.widgetControls__action--minimise').exists()).toBe(true); + expect(wrapper.find('.widgetControls__action--refresh').exists()).toBe(true); + expect(wrapper.find('.widgetControls__action--maximise').exists()).toBe(false); + expect(wrapper.find('.widgetControls__action--close').exists()).toBe(false); + }); + + it('should render no controls when all flags are false', () => { + const wrapper = mountComponent({ + canMinimise: false, + canMaximise: false, + canRefresh: false, + canClose: false, + }); + + expect(wrapper.findAll('.widgetControls__action').length).toBe(0); + }); + + it('should emit the matching intent when a control is clicked', async () => { + const wrapper = mountComponent(); + + await wrapper.find('.widgetControls__action--close').trigger('click'); + + expect(wrapper.emitted('close')).toBeTruthy(); + expect(wrapper.emitted('minimise')).toBeFalsy(); + }); +}); diff --git a/app/plugins/CoreHome/vue/src/WidgetControls/WidgetControls.vue b/app/plugins/CoreHome/vue/src/WidgetControls/WidgetControls.vue new file mode 100644 index 000000000..d561ae55d --- /dev/null +++ b/app/plugins/CoreHome/vue/src/WidgetControls/WidgetControls.vue @@ -0,0 +1,82 @@ + + + + + + diff --git a/app/plugins/CoreHome/vue/src/index.ts b/app/plugins/CoreHome/vue/src/index.ts index 52d692510..09a932aac 100644 --- a/app/plugins/CoreHome/vue/src/index.ts +++ b/app/plugins/CoreHome/vue/src/index.ts @@ -23,6 +23,7 @@ export { default as useExternalPluginComponent } from './useExternalPluginCompon export { default as DirectiveUtilities } from './directiveUtilities'; export { default as debounce } from './debounce'; export { default as clone } from './clone'; +export { default as ucfirst } from './ucfirst'; export { default as VueEntryContainer } from './VueEntryContainer/VueEntryContainer.vue'; export { default as ActivityIndicator } from './ActivityIndicator/ActivityIndicator.vue'; export { default as MatomoLoader } from './MatomoLoader/MatomoLoader.vue'; @@ -75,6 +76,8 @@ export { default as ReportingMenuStore } from './ReportingMenu/ReportingMenu.sto export { default as ReportingPagesStore } from './ReportingPages/ReportingPages.store'; export { default as ReportMetadataStore } from './ReportMetadata/ReportMetadata.store'; export { default as WidgetsStore } from './Widget/Widgets.store'; +export { default as ReportHeader } from './ReportHeader/ReportHeader.vue'; +export { default as WidgetControls } from './WidgetControls/WidgetControls.vue'; export { default as WidgetLoader } from './WidgetLoader/WidgetLoader.vue'; export { default as ClientWidgetRenderer } from './Widget/ClientWidgetRenderer.vue'; export { default as WidgetContainer } from './WidgetContainer/WidgetContainer.vue'; diff --git a/app/plugins/CoreHome/vue/src/ucfirst.spec.ts b/app/plugins/CoreHome/vue/src/ucfirst.spec.ts new file mode 100644 index 000000000..bf2e51375 --- /dev/null +++ b/app/plugins/CoreHome/vue/src/ucfirst.spec.ts @@ -0,0 +1,46 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +import ucfirst from './ucfirst'; + +describe('CoreHome/ucfirst', () => { + it('uppercases the first character of a lowercase string', () => { + expect(ucfirst('plays')).toBe('Plays'); + }); + + it('leaves an already-capitalized string unchanged', () => { + expect(ucfirst('Visits')).toBe('Visits'); + }); + + it('only changes the first character, leaving the rest verbatim', () => { + expect(ucfirst('test string')).toBe('Test string'); + }); + + it('uses Turkish casing rules when uppercasing the first character', () => { + expect(ucfirst('istanbul', 'tr')).toBe('İstanbul'); + }); + + it('uses Azerbaijani casing rules when uppercasing the first character', () => { + expect(ucfirst('izmir', 'az')).toBe('İzmir'); + }); + + it('uppercases a first character represented by a Unicode surrogate pair', () => { + expect(ucfirst('𐐨clair', 'en')).toBe('𐐀clair'); + }); + + it('leaves a leading non-letter (e.g. a %s placeholder) untouched', () => { + expect(ucfirst('%s plays', 'tr')).toBe('%s plays'); + }); + + it('returns an empty string for an empty input', () => { + expect(ucfirst('')).toBe(''); + }); + + it('returns an empty string when the value is undefined', () => { + expect(ucfirst(undefined)).toBe(''); + }); +}); diff --git a/app/plugins/CoreHome/vue/src/ucfirst.ts b/app/plugins/CoreHome/vue/src/ucfirst.ts new file mode 100644 index 000000000..d7d5e669c --- /dev/null +++ b/app/plugins/CoreHome/vue/src/ucfirst.ts @@ -0,0 +1,19 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +/** + * Uppercase the first character of a string, leaving the rest untouched (e.g. "visits" -> + * "Visits"). Uses locale-aware Unicode casing; an empty or missing value yields an empty string. + */ +export default function ucfirst(text?: string, locale?: string): string { + if (!text) { + return ''; + } + + const [firstCharacter, ...remainingCharacters] = Array.from(text); + return firstCharacter.toLocaleUpperCase(locale || undefined) + remainingCharacters.join(''); +} diff --git a/app/plugins/CoreVisualizations/CoreVisualizations.php b/app/plugins/CoreVisualizations/CoreVisualizations.php index a40402bad..f783764ea 100644 --- a/app/plugins/CoreVisualizations/CoreVisualizations.php +++ b/app/plugins/CoreVisualizations/CoreVisualizations.php @@ -8,9 +8,6 @@ */ namespace Piwik\Plugins\CoreVisualizations; -use Piwik\Container\StaticContainer; -use Piwik\Plugins\CoreVisualizations\FeatureFlags\SparklinesRedesign; -use Piwik\Plugins\FeatureFlags\FeatureFlagManager; use Piwik\ViewDataTable\Manager as ViewDataTableManager; require_once PIWIK_INCLUDE_PATH . '/plugins/CoreVisualizations/JqplotDataGenerator.php'; /** @@ -24,21 +21,12 @@ class CoreVisualizations extends \Piwik\Plugin */ public function registerEvents() { - return array('AssetManager.getStylesheetFiles' => 'getStylesheetFiles', 'AssetManager.getJavaScriptFiles' => 'getJsFiles', 'Translate.getClientSideTranslationKeys' => 'getClientSideTranslationKeys', 'UsersManager.deleteUser' => 'deleteUser', 'Template.bodyClass' => 'addBodyClass'); + return array('AssetManager.getStylesheetFiles' => 'getStylesheetFiles', 'AssetManager.getJavaScriptFiles' => 'getJsFiles', 'Translate.getClientSideTranslationKeys' => 'getClientSideTranslationKeys', 'UsersManager.deleteUser' => 'deleteUser'); } public function deleteUser($userLogin) { ViewDataTableManager::clearUserViewDataTableParameters($userLogin); } - public function addBodyClass(&$out, $type) - { - $featureFlagManager = StaticContainer::get(FeatureFlagManager::class); - // The sparklines redesign refreshes sparkline styling app-wide (gated by the flag), - // so sparklines also appear on other page types (e.g. admin). - if ($featureFlagManager->isFeatureActive(SparklinesRedesign::class)) { - $out .= ' sparklines-redesign-enabled'; - } - } public function getStylesheetFiles(&$stylesheets) { $stylesheets[] = "plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionBadge.less"; @@ -48,6 +36,11 @@ public function getStylesheetFiles(&$stylesheets) $stylesheets[] = "plugins/CoreVisualizations/vue/src/SingleMetricView/SingleMetricView.less"; $stylesheets[] = "plugins/CoreVisualizations/vue/src/SparklinesGrid/SparklinesGrid.less"; $stylesheets[] = "plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.less"; + $stylesheets[] = "plugins/CoreVisualizations/vue/src/Sparklines/DateAtom.less"; + $stylesheets[] = "plugins/CoreVisualizations/vue/src/Sparklines/PeriodColumns.less"; + $stylesheets[] = "plugins/CoreVisualizations/vue/src/Sparklines/DateComparison.less"; + $stylesheets[] = "plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonCard.less"; + $stylesheets[] = "plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonRow.less"; $stylesheets[] = "plugins/CoreVisualizations/stylesheets/dataTableVisualizations.less"; $stylesheets[] = "plugins/CoreVisualizations/stylesheets/jqplot.less"; } @@ -71,5 +64,6 @@ public function getClientSideTranslationKeys(&$translationKeys) $translationKeys[] = 'General_EvolutionSummaryGeneric'; $translationKeys[] = 'General_IncompletePeriod'; $translationKeys[] = 'General_InvalidatedPeriod'; + $translationKeys[] = 'General_Forecast'; } } diff --git a/app/plugins/CoreVisualizations/FeatureFlags/SparklinesRedesign.php b/app/plugins/CoreVisualizations/FeatureFlags/SparklinesRedesign.php deleted file mode 100644 index bba590c18..000000000 --- a/app/plugins/CoreVisualizations/FeatureFlags/SparklinesRedesign.php +++ /dev/null @@ -1,19 +0,0 @@ - */ protected $dataStates = []; + /** + * @var array> + */ + protected $forecastData = []; /** * @var LoggerInterface */ @@ -132,7 +136,7 @@ public function render() ProxyHttp::overrideCacheControlHeaders(); $this->checkDataStateAvailableForAllTicks(); // See https://www.jqplot.com/docs/files/jqPlotOptions-txt.html - $data = ['params' => ['axes' => &$this->axes, 'series' => &$this->series], 'data' => &$this->data, 'dataStates' => &$this->dataStates]; + $data = ['params' => ['axes' => &$this->axes, 'series' => &$this->series], 'data' => &$this->data, 'dataStates' => &$this->dataStates, 'forecastData' => &$this->forecastData]; return $data; } public function setAxisXLabelsMultiple($xLabels, $seriesToXAxis, $ticks = null) @@ -158,6 +162,15 @@ public function setDataStates(array $dataStates) : void { $this->dataStates = $dataStates; } + /** + * Set forecast values for all series/ticks. + * + * @param array> $forecastData + */ + public function setForecastData(array $forecastData) : void + { + $this->forecastData = $forecastData; + } private function getXAxis($index) { $axisName = 'xaxis'; diff --git a/app/plugins/CoreVisualizations/JqplotDataGenerator/Evolution.php b/app/plugins/CoreVisualizations/JqplotDataGenerator/Evolution.php index 14afb1ad7..e7288fca2 100644 --- a/app/plugins/CoreVisualizations/JqplotDataGenerator/Evolution.php +++ b/app/plugins/CoreVisualizations/JqplotDataGenerator/Evolution.php @@ -8,6 +8,7 @@ */ namespace Piwik\Plugins\CoreVisualizations\JqplotDataGenerator; +use Piwik\API\Request as ApiRequest; use Piwik\Archive\ArchiveState; use Piwik\Archive\DataTableFactory; use Piwik\Common; @@ -18,6 +19,7 @@ use Piwik\Period\Factory; use Piwik\Plugins\API\Filter\DataComparisonFilter; use Piwik\Plugins\CoreVisualizations\JqplotDataGenerator; +use Piwik\Plugins\CoreVisualizations\Visualizations\JqplotGraph\Evolution as JqplotEvolutionGraph; use Piwik\Site; use Piwik\Url; /** @@ -25,6 +27,19 @@ */ class Evolution extends JqplotDataGenerator { + /** + * Narrow the parent's untyped `$graph` to the evolution visualization, since + * `JqplotDataGenerator\Evolution` is only ever constructed by + * {@see JqplotEvolutionGraph::makeDataGenerator()}. Lets later code call + * forecast-specific accessors without redundant `instanceof` checks. + * + * @var JqplotEvolutionGraph + */ + protected $graph; + /** @var ForecastMetricClassifier|null */ + private $forecastClassifier; + /** @var ForecastSubPeriodFetcher|null */ + private $forecastSubPeriodFetcher; protected function getUnitsForColumnsToDisplay() { $idSite = Common::getRequestVar('idSite', null, 'int'); @@ -58,24 +73,19 @@ protected function initChartObjectData($dataTable, $visualization) $rowsToDisplay = ($this->properties['rows_to_display'] ?: array_unique($dataTable->getColumn('label'))) ?: [\false]; $columnsToDisplay = array_values($this->properties['columns_to_display']); [$seriesMetadata, $seriesUnits, $seriesLabels, $seriesToXAxis] = $this->getSeriesMetadata($rowsToDisplay, $columnsToDisplay, $units, $dataTables); - // collect series data to show. each row-to-display/column-to-display permutation creates a series. - $allSeriesData = []; - foreach ($rowsToDisplay as $rowIdentifier) { - $rowLabel = $rowIdentifier; - if (!empty($this->properties['selectable_rows'])) { - foreach ($this->properties['selectable_rows'] as $row) { - if ($rowIdentifier === $row['matcher']) { - $rowLabel = $row['label']; - } - } - } - foreach ($columnsToDisplay as $columnName) { - if (!$this->isComparing) { - $this->setNonComparisonSeriesData($allSeriesData, $rowLabel, $columnName, $dataTable); - } else { - $this->setComparisonSeriesData($allSeriesData, $seriesLabels, $rowLabel, $columnName, $dataTable); - } - } + if ($this->isComparing) { + // Comparing graphs never render a forecast, so we only need the chart-rendering + // data, not the forecast-state machinery the seriesState wrapper carries. + $allSeriesData = $this->collectComparisonSeriesData($rowsToDisplay, $columnsToDisplay, $seriesLabels, $dataTable); + } else { + // Reuse the per-series state precomputed in + // JqplotGraph\Evolution::afterAllFiltersAreApplied() whenever a forecast was + // produced, instead of running the same row × column collection loop again. When + // no state was stashed the forecast is not being rendered (no incomplete tick, bar + // mode, or disabled), so the fallback collects data-only series and skips the + // forecast classifier work. + $seriesState = $this->graph->getForecastSeriesState() ?? $this->collectForecastSeriesState($rowsToDisplay, $columnsToDisplay, $units, $dataTable, \false); + $allSeriesData = $seriesState->getAllSeriesData(); } $visualization->properties = $this->properties; $units = null; @@ -108,10 +118,51 @@ protected function initChartObjectData($dataTable, $visualization) $visualization->setAxisXOnClick($axisXOnClick); } $this->setDataStates($visualization, $dataTables); + $visualization->setForecastData($this->buildForecastData()); + } + /** + * Return the forecast values precomputed in + * {@see JqplotEvolutionGraph::afterAllFiltersAreApplied()}. Comparing graphs and graphs + * with forecast disabled both short-circuit to an empty payload — precompute would + * have returned [] anyway, but checking here saves a property lookup on the cold path. + * + * @return array> + */ + protected function buildForecastData() : array + { + if (empty($this->properties['show_forecast']) || $this->isComparing) { + return []; + } + return $this->graph->getForecastData(); } - private function getSeriesData($rowLabel, $columnName, DataTable\Map $dataTable) + /** + * Memoised access to the metric classifier. Protected so subclasses can substitute a + * classifier instantiated with an explicit semantic-type map (avoids seeding the global + * transient cache that backs {@see Metrics::getDefaultMetricSemanticTypes()}). + */ + protected function getForecastClassifier() : \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier + { + if (null === $this->forecastClassifier) { + $this->forecastClassifier = new \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier(); + } + return $this->forecastClassifier; + } + /** + * Memoised access to the sub-period sample fetcher. Protected so subclasses can + * substitute an alternative fetcher (one that returns canned sample maps without + * issuing inner API requests) instead of touching the Matomo bootstrap. + */ + protected function getForecastSubPeriodFetcher() : \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSubPeriodFetcher + { + if (null === $this->forecastSubPeriodFetcher) { + $this->forecastSubPeriodFetcher = new \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSubPeriodFetcher(); + } + return $this->forecastSubPeriodFetcher; + } + private function getSeriesData($rowLabel, $columnName, DataTable\Map $dataTable, &$seriesDataAvailability) { $seriesData = array(); + $seriesDataAvailability = array(); foreach ($dataTable->getDataTables() as $childTable) { // get the row for this label (use the first if $rowLabel is false) if ($rowLabel === \false) { @@ -122,12 +173,27 @@ private function getSeriesData($rowLabel, $columnName, DataTable\Map $dataTable) // get series data point. defaults to 0 if no row or no column value. if ($row === \false) { $seriesData[] = 0; + $seriesDataAvailability[] = \false; } else { - $seriesData[] = $row->getColumn($columnName) ?: 0; + $value = $row->getColumn($columnName); + // Preserve the legacy `?: 0` coercion for the rendered series data so '0' / + // 0.0 values continue to flow through as plain int 0 (downstream consumers + // doing `=== 0` rely on it). The hasColumnValue check below tracks the + // separate "real 0 vs missing" distinction the forecast builder needs. + $seriesData[] = $value ?: 0; + $seriesDataAvailability[] = $this->hasColumnValue($value); } } return $seriesData; } + /** + * Single source of truth for whether a column value should count as "this tick has data". + * Numeric 0 (and "0") counts as data; only false, null, and '' are treated as missing. + */ + private function hasColumnValue($value) : bool + { + return $value !== \false && $value !== null && $value !== ''; + } /** * Derive the series label from the row label and the column name. * If the row label is set, both the label and the column name are displayed. @@ -197,13 +263,88 @@ protected function addSelectedSeriesXLabels(array &$xLabels, array $dataTables) $xLabels[0][] = $period; } } - private function setNonComparisonSeriesData(array &$allSeriesData, $rowLabel, $columnName, DataTable\Map $dataTable) + /** + * Run the row × column collection loop and produce the per-series state. Shared by + * initChartObjectData() (render path) and precomputeForecast() so the two paths produce + * identical state. Comparing graphs go through {@see self::collectComparisonSeriesData()} + * instead — they only need the chart-rendering series data and never consume the + * forecast-state fields. + * + * @param array $rowsToDisplay + * @param array $columnsToDisplay + * @param array $units + * @param bool $forecastEnabled When false, only the per-series chart data is collected and + * the forecast precision/monotonicity classifiers are skipped. + * Those classifiers touch the metric semantic-type registry and + * run a cluster of string searches per column, so the render + * fallback (which is only reached when no forecast was produced) + * passes false to keep dashboards full of historical-only + * evolution graphs from paying for a feature they are not + * rendering. precomputeForecast() passes true. + */ + private function collectForecastSeriesState(array $rowsToDisplay, array $columnsToDisplay, array $units, DataTable\Map $dataTable, bool $forecastEnabled) : \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSeriesState + { + $builder = new \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSeriesStateBuilder(); + foreach ($rowsToDisplay as $rowIdentifier) { + $rowLabel = $this->resolveRowLabel($rowIdentifier); + foreach ($columnsToDisplay as $columnName) { + $seriesLabel = $this->getSeriesLabel($rowLabel, $columnName); + $seriesData = $this->getSeriesData($rowLabel, $columnName, $dataTable, $seriesDataAvailability); + if (!$forecastEnabled) { + $builder->addDataOnlySeries($seriesLabel, $seriesData); + continue; + } + $columnUnit = $units[$columnName] ?? \false; + $columnMonotonicity = $this->getForecastClassifier()->getColumnMonotonicity($columnName, $columnUnit); + $builder->addForecastSeries($seriesLabel, $seriesData, $seriesDataAvailability, $columnMonotonicity, $this->getForecastClassifier()->getForecastPrecisionForColumn($columnName, $columnUnit, $columnMonotonicity), $columnName, $rowLabel); + } + } + return $builder->build(); + } + /** + * Comparing-only twin of {@see self::collectForecastSeriesState()}: walks the same + * row × column grid but only populates the chart-rendering data, since comparing graphs + * never reach the forecast builder. + * + * @param array $rowsToDisplay + * @param array $columnsToDisplay + * @param array $seriesLabels + * @return array> + */ + private function collectComparisonSeriesData(array $rowsToDisplay, array $columnsToDisplay, array $seriesLabels, DataTable\Map $dataTable) : array + { + $allSeriesData = []; + foreach ($rowsToDisplay as $rowIdentifier) { + $rowLabel = $this->resolveRowLabel($rowIdentifier); + foreach ($columnsToDisplay as $columnName) { + $this->setComparisonSeriesData($allSeriesData, $seriesLabels, $rowLabel, $columnName, $dataTable); + } + } + return $allSeriesData; + } + /** + * Apply the `selectable_rows` matcher → label translation that both collection paths + * share, keeping the loop body identical between comparing and non-comparing. + * + * @param mixed $rowIdentifier + * @return mixed + */ + private function resolveRowLabel($rowIdentifier) { - $seriesLabel = $this->getSeriesLabel($rowLabel, $columnName); - $seriesData = $this->getSeriesData($rowLabel, $columnName, $dataTable); - $allSeriesData[$seriesLabel] = $seriesData; + if (!empty($this->properties['selectable_rows'])) { + foreach ($this->properties['selectable_rows'] as $row) { + if ($rowIdentifier === $row['matcher']) { + return $row['label']; + } + } + } + return $rowIdentifier; } - private function setComparisonSeriesData(array &$allSeriesData, array $seriesLabels, $rowLabel, $columnName, DataTable\Map $dataTable) + /** + * @param array> $allSeriesData + * @param array $seriesLabels + */ + private function setComparisonSeriesData(array &$allSeriesData, array $seriesLabels, $rowLabel, $columnName, DataTable\Map $dataTable) : void { foreach ($dataTable->getDataTables() as $label => $childTable) { // get the row for this label (use the first if $rowLabel is false) @@ -265,10 +406,23 @@ private function getSeriesMetadata(array $rowsToDisplay, array $columnsToDisplay /** * @param array $dataTables */ - private function setDataStates(\Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\Chart $visualization, array $dataTables) : void + private function setDataStates(\Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\Chart $visualization, array $dataTables) : array + { + $dataStates = $this->computeDataStates($dataTables); + $visualization->setDataStates($dataStates); + return $dataStates; + } + /** + * Pure data-state computation. Returns the per-tick archive state for the given + * per-period DataTables, ordered by the original DataTable\Map keys. + * + * @param array $dataTables + * @return array + */ + public function computeDataStates(array $dataTables) : array { if (0 === count($dataTables)) { - return; + return []; } $dataTableDates = array_keys($dataTables); $mostRecentDate = end($dataTableDates); @@ -278,9 +432,8 @@ private function setDataStates(\Piwik\Plugins\CoreVisualizations\JqplotDataGener $siteToday = Date::factoryInTimezone('today', $site->getTimezone())->getTimestamp(); $previousState = ArchiveState::COMPLETE; foreach ($dataTableDates as $dataTableDate) { - /** @var Period $period */ - $period = $dataTables[$dataTableDate]->getMetadata(DataTableFactory::TABLE_METADATA_PERIOD_INDEX); - $state = $dataTables[$dataTableDate]->getMetadata(DataTable::ARCHIVE_STATE_METADATA_NAME); + $childTable = $dataTables[$dataTableDate]; + $state = $childTable->getMetadata(DataTable::ARCHIVE_STATE_METADATA_NAME); if (\false === $state) { // Missing archive state information should only occur if no // usable archive was found in the database. Treat a missing archive @@ -288,12 +441,69 @@ private function setDataStates(\Piwik\Plugins\CoreVisualizations\JqplotDataGener // as complete unless it follows an incomplete archive. $state = ArchiveState::INCOMPLETE === $previousState ? ArchiveState::INCOMPLETE : ArchiveState::COMPLETE; } - if ($siteToday <= $period->getDateEnd()->getTimestamp()) { + if (self::isIncompleteTick($childTable, $siteToday)) { $state = ArchiveState::INCOMPLETE; } $dataStates[$dataTableDate] = $state; $previousState = $state; } - $visualization->setDataStates(array_values($dataStates)); + return array_values($dataStates); + } + /** + * Decides whether a single child table from an evolution Map represents an + * incomplete tick. Two signals can mark a tick incomplete: an explicit + * INCOMPLETE archive_state metadata flag (set by the archiver when ts_archived + * falls before the period end), or the tick's period running on/past the + * site's "today". The siteToday rule exists because the archiver may not + * write numeric records for periods with no data (low-volume Goals/Ecommerce + * reports hit this), in which case the metadata is absent even though the + * period is, by definition, still in progress. + * + * Called from {@see computeDataStates()} as the override that forces the + * per-tick state to INCOMPLETE. + */ + public static function isIncompleteTick(DataTable $childTable, int $siteToday) : bool + { + if (ArchiveState::INCOMPLETE === $childTable->getMetadata(DataTable::ARCHIVE_STATE_METADATA_NAME)) { + return \true; + } + $period = $childTable->getMetadata(DataTableFactory::TABLE_METADATA_PERIOD_INDEX); + return $period instanceof Period && $siteToday <= $period->getDateEnd()->getTimestamp(); + } + /** + * Compute forecast values for the given DataTable\Map without rendering a chart. + * Called by the visualization in afterAllFiltersAreApplied() so the always-on forecast + * is computed once, ahead of render. + * + * The collected per-series state is stashed on the visualization so the later + * initChartObjectData() call can reuse it instead of running the same loop again. + * + * @return array> + */ + public function precomputeForecast(DataTable\Map $dataTable) : array + { + if ($this->isComparing) { + return []; + } + $dataTables = $dataTable->getDataTables(); + if ([] === $dataTables) { + return []; + } + // Cheap gate: without at least one incomplete tick the builder cannot + // produce a forecast value, so skip the per-series construction below. + // This runs on every evolution graph render, so the early exit matters + // for dashboards full of historical-only graphs. + $dataStates = $this->computeDataStates($dataTables); + if (!in_array(ArchiveState::INCOMPLETE, $dataStates, \true)) { + return []; + } + $units = $this->getUnitsForColumnsToDisplay(); + $rowsToDisplay = ($this->properties['rows_to_display'] ?: array_unique($dataTable->getColumn('label'))) ?: [\false]; + $columnsToDisplay = array_values($this->properties['columns_to_display']); + [, $seriesUnits] = $this->getSeriesMetadata($rowsToDisplay, $columnsToDisplay, $units, $dataTables); + $seriesState = $this->collectForecastSeriesState($rowsToDisplay, $columnsToDisplay, $units, $dataTable, \true); + $this->graph->setForecastSeriesState($seriesState); + $subPeriodSamples = $this->getForecastSubPeriodFetcher()->collect($dataTables, $seriesState, $this->graph->requestConfig->apiMethodToRequestDataTable, Common::getRequestVar('idSite', 0, 'int'), (string) ApiRequest::getRawSegmentFromRequest()); + return (new \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastBuilder())->build($seriesState, $dataTables, $dataStates, $seriesUnits, $subPeriodSamples['daily'], $subPeriodSamples['monthly'], $subPeriodSamples['earliestDataDate'] ?? null); } } diff --git a/app/plugins/CoreVisualizations/JqplotDataGenerator/ForecastBuilder.php b/app/plugins/CoreVisualizations/JqplotDataGenerator/ForecastBuilder.php new file mode 100644 index 000000000..8eaefcadf --- /dev/null +++ b/app/plugins/CoreVisualizations/JqplotDataGenerator/ForecastBuilder.php @@ -0,0 +1,1277 @@ += current" gate + * matches the additive case because a running max can only rise within the period. + * - MONOTONICITY_FREE — ratio/rate/percentage/average series: the forecast is the historical + * same-period prior with no directional gate, because the period's value can move either way + * during the remaining time. + * + * The builder is stateless and reusable. Period bounds and the archive timestamp are read from + * DataTable metadata, so any caller producing comparable DataTable maps can reuse it. + */ +class ForecastBuilder +{ + /** + * Damping factor applied to the linear-trend projection of historical priors. 1.0 = full + * least-squares extrapolation (most responsive to trends, most prone to overshoot on noisy + * ratios). 0.0 = no projection (flat mean). 0.5 keeps the regression line's fit on the + * historical samples but only takes a half-step in the slope direction for the next-period + * forecast — a trade-off between catching growth on count series and not amplifying noise + * on volatile averages. + */ + private const TREND_DAMPING = 0.5; + /** + * Minimum number of calendar-aligned historical samples (same week-of-year, same calendar + * month) required before preferring them over the full sample set. With only one aligned + * sample there is no slope to fit and the recency-only window is more informative than a + * single calendar-matched data point. + */ + private const MIN_ALIGNED_SAMPLES_TO_PREFER = 2; + /** + * Minimum number of historical samples required before clamping the blended forecast to a + * historical-range envelope. With fewer samples the empirical standard deviation is too + * noisy to define a meaningful upper/lower bound, so the clamp is skipped. + */ + private const MIN_SAMPLES_FOR_BOUNDED_RANGE = 4; + /** + * Below this count the day-level analog reducer falls back to a plain mean instead of a + * trend fit. With only a handful of weekly-strided samples the least-squares slope swings + * wildly because the time base is short and neighbouring same-DoW values drift around a + * stationary mean rather than a persistent trend; the envelope clamp is also not active, + * so a noisy slope cannot be contained. The plain mean is the more informative reducer + * until enough samples are present to constrain the slope. + */ + private const MIN_SAMPLES_FOR_DAY_LEVEL_TREND = 5; + /** + * Width of the historical-range envelope expressed in (robust) standard deviations of the + * past samples — see {@see MAD_TO_SIGMA} for how the spread is estimated. Three sigmas covers + * ~99.7% of normally-distributed history, so a forecast landing outside this band almost + * certainly reflects an extrapolation artefact rather than a credible final value. + */ + private const BOUNDED_RANGE_SIGMAS = 3.0; + /** + * Minimum half-width of the historical-range envelope expressed as a fraction of the sample + * median. Without this floor a perfectly stable history (spread ≈ 0) would collapse the + * envelope onto the centre and forbid any deviation, including the legitimate case where the + * partial period is genuinely trending up or down. + */ + private const BOUNDED_RANGE_MIN_RELATIVE_SPREAD = 0.05; + /** + * Scales the median absolute deviation onto the standard-deviation scale: for normally + * distributed samples, sigma ≈ 1.4826 * MAD. The envelope spread is built from the MAD + * rather than the raw standard deviation so a single recent spike — the case the clamp + * exists to tame — cannot inflate the band and contain its own runaway extrapolation. + */ + private const MAD_TO_SIGMA = 1.4826; + /** + * Number of same-DoW samples to draw for the day-period historical prior when the caller + * supplies a daily sample map. The day path never enters the seasonal-decomposition branch + * (a day has no useful sub-period to decompose), so the prior-only path is the only + * forecast surface and a 70-day fetched window yields at most ten same-DoW samples. Above + * MIN_SAMPLES_FOR_BOUNDED_RANGE so the envelope clamp engages, and enough data points for + * the trend fit to resist single-day noise on short displays. + */ + private const DAY_PRIOR_TARGET_SAMPLES = 10; + /** + * Number of most-recent same-period samples whose median defines the "current level" used to + * detect a traffic level shift in {@see stripPreLevelShiftSamples()}. Matches + * MIN_SAMPLES_FOR_BOUNDED_RANGE so the same window that must exist for the envelope clamp also + * anchors the shift detector; four points is enough for a median to shrug off a single noisy + * recent day without being so wide it reaches back across the very shift it is meant to detect. + */ + private const RECENT_LEVEL_WINDOW = 4; + /** + * Fold-change threshold marking a same-period sample as belonging to a pre-shift traffic + * regime rather than the current one, used by {@see stripPreLevelShiftSamples()}. A sample + * more than this factor above or below the current level (so outside + * [level / RATIO, level * RATIO]) reads as a discrete step change — a marketing, tracking or + * seasonality regime the current period no longer belongs to — not the gradual drift the + * damped linear trend is meant to model. 2.0 leaves a genuine trend (which does not double or + * halve within a ten-week same-DoW window) untouched while catching the multi-fold steps that + * otherwise drag the trend fit far below the current level. + */ + private const LEVEL_SHIFT_RATIO = 2.0; + /** + * Default number of same-DoW analog samples per remaining day slot when forecasting a week. + * Smaller chunks lose accuracy from a single noisy analog; larger chunks pull in older + * traffic that drifts away from current site level. + */ + private const WEEK_ANALOG_CHUNK = 3; + /** + * Default number of same-DoW analog samples per remaining day slot when forecasting a + * month. Slightly larger than the week default because monthly forecasts span more remaining + * days, so additional analogs bring incremental stability without introducing meaningful + * drift. + */ + private const MONTH_ANALOG_CHUNK = 4; + /** + * Default number of same-month-of-year analog samples when forecasting a year. With one + * sample per analog year, eight is enough to drive the trend fit's slope while remaining + * within typical multi-year archive depth. + */ + private const YEAR_ANALOG_CHUNK = 8; + /** + * Number of consecutive complete prior ticks immediately preceding the forecast tick that + * must read as "no data" (zero value or missing column) before the forecast is suppressed + * as a no-recent-traffic case. Set to 2 so a legitimate single-zero observation (e.g. a + * 0% bounce_rate on a low-traffic day with one bounceless visit, or a min_* metric whose + * single archived sample was 0) does not trigger suppression — those are valid priors. + * Two consecutive empty ticks is the shortest pattern that distinguishes a sustained + * outage from a one-tick blip. + */ + private const MIN_RECENT_NO_DATA_TICKS_FOR_SUPPRESSION = 2; + /** + * @param ForecastSeriesState $seriesState Per-series state collected upstream — data, + * availability, intra-period monotonicity, and forecast precision. Missing + * monotonicity entries fall back to FREE for percent-unit series and UP otherwise; + * missing precision entries preserve the historical 4-decimal default. + * @param array $dataTables + * @param array $dataStates + * @param array $seriesUnits + * @param array> $allSeriesDailySamples Per-series map of + * Y-m-d → final daily value, covering enough history to populate same-DoW analog + * slots for the highest-tick week/month target. Required for MONOTONICITY_UP + * week/month forecasts; without it the builder falls back to prior-only same-period + * projection on the period-level series. + * @param array> $allSeriesMonthlySamples Per-series map of + * YYYY-MM → final monthly value, used by MONOTONICITY_UP year forecasts to project + * remaining months from same-month-of-year analogs. + * @param string|null $earliestDataDate Earliest 'Y-m-d' the site/segment can hold data + * (site creation date, raised to an auto-archived segment's re-archive start). Analog + * samples dated before it are dropped from the day-period prior so a wide displayed + * range cannot resurrect pre-creation history the sub-period fetch already floors away + * — without it the day forecast flips on/off with the "rows to display" width. Null + * disables the floor (no resolvable creation date). + * @return array> + */ + public function build(\Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSeriesState $seriesState, array $dataTables, array $dataStates, array $seriesUnits, array $allSeriesDailySamples = [], array $allSeriesMonthlySamples = [], ?string $earliestDataDate = null) : array + { + $allSeriesData = $seriesState->getAllSeriesData(); + $allSeriesDataAvailability = $seriesState->getAllSeriesDataAvailability(); + $allSeriesMonotonicity = $seriesState->getAllSeriesMonotonicity(); + $allSeriesForecastPrecision = $seriesState->getAllSeriesForecastPrecision(); + if ([] === $allSeriesData || [] === $dataTables || [] === $dataStates) { + return []; + } + /** @var Site|null $site */ + $site = reset($dataTables)->getMetadata(DataTableFactory::TABLE_METADATA_SITE_INDEX); + if (empty($site)) { + return []; + } + $dataTableList = array_values($dataTables); + $seriesNames = array_keys($allSeriesData); + $seriesDataList = array_values($allSeriesData); + $seriesUnitsList = array_values($seriesUnits); + $seriesDataAvailabilityList = array_values($allSeriesDataAvailability); + $seriesMonotonicityList = array_values($allSeriesMonotonicity); + $seriesForecastPrecisionList = array_values($allSeriesForecastPrecision); + $resolvedMonotonicity = []; + foreach ($seriesDataList as $seriesIndex => $unused) { + $isPercentSeries = ($seriesUnitsList[$seriesIndex] ?? \false) === '%'; + $resolvedMonotonicity[$seriesIndex] = $seriesMonotonicityList[$seriesIndex] ?? ($isPercentSeries ? \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_FREE : \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_UP); + } + // Process MONOTONICITY_UP series first so the cross-series gate (below) can read each + // tick's count-series forecast before deciding whether dependent ratios/averages should + // render. Output order is restored by indexing $forecastData on the original series + // index and ksort'ing at the end. + $processingOrder = array_keys($seriesDataList); + usort($processingOrder, function ($a, $b) use($resolvedMonotonicity) { + $aRank = \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_UP === $resolvedMonotonicity[$a] ? 0 : 1; + $bRank = \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_UP === $resolvedMonotonicity[$b] ? 0 : 1; + if ($aRank === $bRank) { + return $a <=> $b; + } + return $aRank <=> $bRank; + }); + // Per-tick "any UP series produced a renderable forecast > 0" map. Built up as the + // first-pass UP series finish processing, consumed by the second-pass FREE/DOWN series + // to suppress dependent ratios/averages on ticks where no count series carries data. + $upSeriesNonZeroByTick = []; + $hasAnyUpSeries = \false; + $forecastData = []; + foreach ($processingOrder as $seriesIndex) { + $seriesData = $seriesDataList[$seriesIndex]; + $seriesName = $seriesNames[$seriesIndex] ?? null; + $seriesForecasts = []; + // Reset on every non-rendered tick so a suppressed or skipped forecast does not + // bridge into later zero-data ticks; later ticks must restart from historical priors. + $previousForecastValue = null; + $seriesDataAvailability = $seriesDataAvailabilityList[$seriesIndex] ?? []; + $monotonicity = $resolvedMonotonicity[$seriesIndex]; + $forecastPrecision = $seriesForecastPrecisionList[$seriesIndex] ?? 4; + $isUpSeries = \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_UP === $monotonicity; + if ($isUpSeries) { + $hasAnyUpSeries = \true; + } + // Running sample maps grow as forecasts are produced for earlier incomplete ticks + // in this series, so subsequent ticks pick up those projections in their analog + // walks instead of regressing to the partial/empty data left for forecast ticks in + // the original sample fetch. Without this feedback the second of two same-DoW + // forecast days (e.g. a Tuesday this week and a Tuesday next week) sees this + // Tuesday's partial value as a historical analog and pulls the trend hard down. + // + // Only feed projections forward when the caller originally supplied the matching + // sample map. With an empty caller-supplied map the seasonal/day-target paths do + // not engage and the prior comes from the legacy dataTableList walk; folding + // projections into a previously-empty map would flip the path for later ticks and + // hide the legacy behaviour the dataTableList walk is meant to provide. + $runningDailySamples = $seriesName !== null && isset($allSeriesDailySamples[$seriesName]) ? $allSeriesDailySamples[$seriesName] : []; + $runningMonthlySamples = $seriesName !== null && isset($allSeriesMonthlySamples[$seriesName]) ? $allSeriesMonthlySamples[$seriesName] : []; + $feedDailyProjections = [] !== $runningDailySamples; + $feedMonthlyProjections = [] !== $runningMonthlySamples; + foreach ($seriesData as $tickIndex => $currentValueRaw) { + $state = $dataStates[$tickIndex] ?? ArchiveState::COMPLETE; + if (ArchiveState::INCOMPLETE !== $state) { + $seriesForecasts[] = null; + $previousForecastValue = null; + continue; + } + $currentValue = (float) $currentValueRaw; + $dataTable = $dataTableList[$tickIndex] ?? null; + if (empty($dataTable)) { + $seriesForecasts[] = null; + $previousForecastValue = null; + continue; + } + // Trailing-no-data gate: if the most recent complete prior tick(s) read as + // empty (zero value or column unavailable), the same-period analog walks will + // happily reach back over the no-data stretch and "snap" the forecast to the + // pre-outage level — a ghost spike on the chart with no observable basis. + // Suppress instead. Applies to every series in the build pass, regardless of + // monotonicity, so a no-traffic stretch hides counts, ratios and averages + // uniformly. + if ($this->hasRecentNoDataPattern($seriesData, $dataStates, $seriesDataAvailability, $tickIndex, self::MIN_RECENT_NO_DATA_TICKS_FOR_SUPPRESSION)) { + $seriesForecasts[] = null; + $previousForecastValue = null; + continue; + } + // Cross-series gate: ratios and averages (FREE/DOWN) are only meaningful when + // the underlying count exists. After the UP-first pass has finished, suppress + // any dependent series at ticks where no UP series rendered a non-zero + // forecast. Skipped when no UP series were present in the build call. + if (!$isUpSeries && $hasAnyUpSeries && empty($upSeriesNonZeroByTick[$tickIndex])) { + $seriesForecasts[] = null; + $previousForecastValue = null; + continue; + } + $pastValues = $this->getHistoricalSamplesForSeries($seriesData, $dataTableList, $dataStates, $tickIndex, $dataTable, $seriesDataAvailability, $monotonicity, $runningDailySamples, $earliestDataDate); + $tickWindow = new \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSampleWindow($runningDailySamples, $runningMonthlySamples); + $forecastValue = $this->buildForecastValue($currentValue, $pastValues, $previousForecastValue, $monotonicity, $dataTable, $site, $tickWindow); + if ($forecastValue === null) { + $seriesForecasts[] = null; + $previousForecastValue = null; + continue; + } + // An additive count (UP) and a running max (MAX) share the same lower bound: the + // final-period value is at least what has already been observed this period, so a + // forecast below the current partial is impossible. Floor it at current so the + // point renders flat instead of being suppressed by the >= gate below. This is + // the mirror of the min_* clamp further down, but applied BEFORE the gate, because + // for UP/MAX "below current" is a legitimate outcome to render at current, whereas + // for min_* "above current" is an impossible value to suppress. + // + // For MAX the floored case means "the max will not grow further". For UP it is the + // elapsed-blind prior-only fallback (day targets, or week/month without sub-period + // samples) returning a static historical prior the partial has already overtaken; + // this only happens late in the period, where current is most of the final value, + // so flooring renders a guaranteed lower bound near the realised total rather than + // dropping the in-progress point entirely. The seasonal decomposition path is + // already >= current by construction and is unaffected. + if (\Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_MAX === $monotonicity || \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_UP === $monotonicity) { + $forecastValue = max($forecastValue, $currentValue); + } + if (!$this->shouldRenderForecastValue($forecastValue, $currentValue, $monotonicity)) { + $seriesForecasts[] = null; + $previousForecastValue = null; + continue; + } + // Belt-and-braces clamp: a min_* metric's final-period value can never exceed + // the current partial min, so even if the gate let the prior through (e.g. it + // equalled current within rounding) we hold the rendered forecast at or below + // current. Cheap insurance against an ever-rising-min visual in the chart. + if (\Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_DOWN === $monotonicity) { + $forecastValue = min($forecastValue, $currentValue); + } + $roundedForecast = round($forecastValue, $forecastPrecision); + $seriesForecasts[] = $roundedForecast; + $previousForecastValue = $roundedForecast; + if ($isUpSeries && $forecastValue > 0.0) { + $upSeriesNonZeroByTick[$tickIndex] = \true; + } + // Feed this tick's projections forward as historical analogs for later + // incomplete ticks in the same series. Use raw (un-rounded) values so the + // feedback channel keeps the precision of the underlying computation; the + // rounding step above is for display only. + if ($feedDailyProjections) { + foreach ($tickWindow->getDayProjections() as $anchor => $value) { + $runningDailySamples[$anchor] = (float) $value; + } + } + if ($feedMonthlyProjections) { + foreach ($tickWindow->getMonthProjections() as $anchor => $value) { + $runningMonthlySamples[$anchor] = (float) $value; + } + } + } + $forecastData[$seriesIndex] = $seriesForecasts; + } + ksort($forecastData); + return array_values($forecastData); + } + /** + * True when the $requiredCount complete prior ticks immediately preceding $currentTickIndex + * all read as "no data" — either zero value or column-availability false. Skips earlier + * incomplete ticks (forecast slots) so an unbroken run of forecast ticks does not interrupt + * the walk back into real history. Returns false if the walk runs out of complete history + * before $requiredCount ticks have been examined; suppression is a "we are confident the + * recent past was empty" decision, and partial evidence does not justify it. + * + * @param array $seriesData + * @param array $dataStates + * @param array $seriesDataAvailability + */ + private function hasRecentNoDataPattern(array $seriesData, array $dataStates, array $seriesDataAvailability, int $currentTickIndex, int $requiredCount) : bool + { + if ($requiredCount <= 0) { + return \false; + } + $examined = 0; + for ($i = $currentTickIndex - 1; $i >= 0 && $examined < $requiredCount; --$i) { + $state = $dataStates[$i] ?? ArchiveState::COMPLETE; + if (ArchiveState::COMPLETE !== $state) { + continue; + } + $available = $seriesDataAvailability[$i] ?? \true; + $value = (float) ($seriesData[$i] ?? 0); + if ($available && $value > 0.0) { + return \false; + } + ++$examined; + } + return $examined >= $requiredCount; + } + /** + * Single forecast-value entry point for all monotonicities. When sub-period samples are + * available and the target is week/month/year, runs the seasonal-decomposition path with + * a reducer chosen by monotonicity: + * + * - {@see ForecastMetricClassifier::MONOTONICITY_UP}: SUM completed sub-period totals + + * in-progress partial-floor + remaining analog projections (additive-count semantics). + * - {@see ForecastMetricClassifier::MONOTONICITY_FREE}: AVG of completed daily rates + + * analog projections for in-progress and remaining sub-periods (rate-approximation + * semantics; unweighted-vs-traffic-weighted introduces a bounded relative error in + * exchange for stability of the forecast across changes to the displayed range). + * - {@see ForecastMetricClassifier::MONOTONICITY_DOWN}: MIN over completed sub-period + * running mins + analog projections (min-as-min semantics). + * + * Falls back to a prior-only same-period projection on $pastValues (with envelope clamp + * for UP and MAX — both run the damped linear-trend prior, so both carry the + * trend-extrapolation runaway the clamp guards against) when sub-period samples are absent. + * Final fallback is $previousForecastValue from the prior tick in this series. + * + * @param array $pastValues + */ + private function buildForecastValue(float $currentValue, array $pastValues, ?float $previousForecastValue, string $monotonicity, DataTable $dataTable, Site $site, \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSampleWindow $window) : ?float + { + $periodLabel = $this->getPeriodLabel($dataTable); + $period = $this->getPeriod($dataTable); + $seasonal = $this->buildSeasonalForecastValue($dataTable, $periodLabel, $period, $currentValue, $monotonicity, $site, $window); + if ($seasonal !== null) { + return $seasonal; + } + if ([] !== $pastValues) { + $prior = $this->computeHistoricalPrior($pastValues); + if ((\Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_UP === $monotonicity || \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_MAX === $monotonicity) && count($pastValues) >= self::MIN_SAMPLES_FOR_BOUNDED_RANGE) { + $prior = $this->clampForecastToHistoricalRange($prior, $pastValues); + } + // Day-target prior-only path: record the day's forecast under its own anchor so a + // later same-DoW day in this series picks it up via recentSameDoWValues instead of + // walking back to a partial/zero entry the sample fetch left for this day. + if ('day' === $periodLabel) { + $window->projectDay($period->getDateStart()->toString('Y-m-d'), $prior); + } + return $prior; + } + if ($previousForecastValue !== null) { + if ('day' === $periodLabel) { + $window->projectDay($period->getDateStart()->toString('Y-m-d'), $previousForecastValue); + } + return $previousForecastValue; + } + return null; + } + /** + * Run the seasonal-decomposition path for whichever monotonicity applies, when the caller + * has supplied the sub-period samples it needs. Returns null when the path does not apply + * (day target, no sub-period samples, unsupported period), letting the caller fall back + * to the prior-only path on the displayed-range $pastValues. + */ + private function buildSeasonalForecastValue(DataTable $dataTable, string $periodLabel, Period $period, float $currentValue, string $monotonicity, Site $site, \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSampleWindow $window) : ?float + { + switch ($periodLabel) { + case 'week': + if ([] === $window->getDailySamples()) { + return null; + } + return $this->forecastWeekSeasonal($dataTable, $period, $currentValue, $monotonicity, $site, $window); + case 'month': + if ([] === $window->getDailySamples()) { + return null; + } + return $this->forecastMonthSeasonal($dataTable, $period, $currentValue, $monotonicity, $site, $window); + case 'year': + if ([] === $window->getMonthlySamples()) { + return null; + } + return $this->forecastYearSeasonal($dataTable, $period, $currentValue, $monotonicity, $site, $window); + default: + return null; + } + } + /** + * Week forecast via daily decomposition. Completed days contribute their archived values; + * the in-progress day and remaining days are projected from same-DoW analog samples. The + * combining reducer is chosen by monotonicity: SUM for UP, AVG for FREE, MIN for DOWN. + */ + private function forecastWeekSeasonal(DataTable $dataTable, Period $weekPeriod, float $currentValue, string $monotonicity, Site $site, \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSampleWindow $window) : ?float + { + $weekStart = $weekPeriod->getDateStart(); + $siteTz = $site->getTimezone(); + $dayAnchors = []; + for ($i = 0; $i < 7; ++$i) { + $dayAnchors[$i] = $weekStart->addDay($i)->toString('Y-m-d'); + } + $todayIdx = $this->resolveSubPeriodTodayIndex($dataTable, $dayAnchors, $siteTz); + switch ($monotonicity) { + case \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_FREE: + return $this->decomposeAndAverageRate($dayAnchors, $todayIdx, self::WEEK_ANALOG_CHUNK, $window); + case \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_DOWN: + return $this->decomposeAndMinimize($dayAnchors, $todayIdx, self::WEEK_ANALOG_CHUNK, $window); + case \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_MAX: + return $this->decomposeAndMaximize($dayAnchors, $todayIdx, self::WEEK_ANALOG_CHUNK, $window); + case \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_UP: + default: + return $this->decomposeAndForecast($dayAnchors, $todayIdx, $currentValue, self::WEEK_ANALOG_CHUNK, 1.0, $window); + } + } + /** + * Month forecast via daily decomposition. The UP path applies a month-of-year scale to + * the analog daily counts so a Feb forecast does not borrow Aug-level traffic from the + * rolling day window; FREE/DOWN paths skip the MoY scaling because rates/mins do not + * scale proportionally with traffic volume (a 0.8 traffic ratio does not imply + * bounce_rate × 0.8). + */ + private function forecastMonthSeasonal(DataTable $dataTable, Period $monthPeriod, float $currentValue, string $monotonicity, Site $site, \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSampleWindow $window) : ?float + { + $monthStart = $monthPeriod->getDateStart(); + $siteTz = $site->getTimezone(); + // 't' = days in the month containing $monthStart. Cheaper and DST-safe vs differencing + // strtotime() of the boundaries, which a non-UTC process can off-by-one across a DST gap. + $dayCount = (int) $monthStart->toString('t'); + $dayAnchors = []; + for ($i = 0; $i < $dayCount; ++$i) { + $dayAnchors[$i] = $monthStart->addDay($i)->toString('Y-m-d'); + } + $todayIdx = $this->resolveSubPeriodTodayIndex($dataTable, $dayAnchors, $siteTz); + switch ($monotonicity) { + case \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_FREE: + return $this->decomposeAndAverageRate($dayAnchors, $todayIdx, self::MONTH_ANALOG_CHUNK, $window); + case \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_DOWN: + return $this->decomposeAndMinimize($dayAnchors, $todayIdx, self::MONTH_ANALOG_CHUNK, $window); + case \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_MAX: + // No month-of-year scaling: like MIN/AVG, a running max does not scale + // proportionally with monthly traffic volume (a 0.8 traffic ratio does not + // imply max_actions * 0.8). + return $this->decomposeAndMaximize($dayAnchors, $todayIdx, self::MONTH_ANALOG_CHUNK, $window); + case \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_UP: + default: + $monthAnchor = $monthStart->toString('Y-m'); + $monthOfYearScale = $this->computeMonthOfYearScale($monthAnchor, self::MONTH_ANALOG_CHUNK, $window); + return $this->decomposeAndForecast($dayAnchors, $todayIdx, $currentValue, self::MONTH_ANALOG_CHUNK, $monthOfYearScale, $window); + } + } + /** + * Year forecast via monthly decomposition. Same per-monotonicity reducer dispatch as the + * week and month paths: SUM for UP, AVG for FREE, MIN for DOWN. Completed months come + * from the monthly sample map; the current month is estimated by recursing into the + * month seasonal path with the same monotonicity when daily samples are available; + * remaining months are projected from same-month-of-year monthly analogs. + */ + private function forecastYearSeasonal(DataTable $dataTable, Period $yearPeriod, float $currentValue, string $monotonicity, Site $site, \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSampleWindow $window) : ?float + { + $yearStart = $yearPeriod->getDateStart(); + $siteTz = $site->getTimezone(); + $monthAnchors = []; + for ($i = 0; $i < 12; ++$i) { + $monthAnchors[$i] = $yearStart->addMonth($i)->toString('Y-m'); + } + $referenceTs = $this->resolveReferenceTimestamp($dataTable); + // Calendar-aligned anchor lookup using the same {@see Date::adjustForTimezone()} primitive + // {@see resolveSubPeriodTodayIndex()} relies on, so the day/month/year branches all derive + // the in-progress sub-period from the same site-local 'Y-m' anchor surface. A + // reference instant outside the displayed year (rare; only possible for an archive whose + // ts_archived predates the year start) is treated as Dec, matching the previous behaviour. + $referenceMonthAnchor = Date::factory(Date::adjustForTimezone($referenceTs, $siteTz))->toString('Y-m'); + $idx = array_search($referenceMonthAnchor, $monthAnchors, \true); + $todayMonthIdx = \false === $idx ? 11 : (int) $idx; + if (\Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_UP === $monotonicity) { + return $this->forecastYearSeasonalUp($dataTable, $monthAnchors, $todayMonthIdx, $currentValue, $site, $window); + } + return $this->forecastYearSeasonalAggregate($dataTable, $monthAnchors, $todayMonthIdx, $monotonicity, $site, $window); + } + /** + * UP-flavoured year decomposition: SUM completed monthly counts + current-month partial + * floor + remaining same-MoY analog projections. Extracted from the year-level dispatch + * so the dispatch reads cleanly. + * + * @param array $monthAnchors + */ + private function forecastYearSeasonalUp(DataTable $dataTable, array $monthAnchors, int $todayMonthIdx, float $currentValue, Site $site, \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSampleWindow $window) : float + { + $dailySamples = $window->getDailySamples(); + $monthlySamples = $window->getMonthlySamples(); + $completedReal = 0.0; + for ($i = 0; $i < $todayMonthIdx; ++$i) { + $completedReal += $monthlySamples[$monthAnchors[$i]] ?? 0.0; + } + $currentMonthPartial = max(0.0, $currentValue - $completedReal); + $currentMonthAnchorStr = $monthAnchors[$todayMonthIdx] . '-01'; + $currentMonthEstimate = null; + if ([] !== $dailySamples) { + $currentMonthEstimate = $this->forecastMonthSeasonal($dataTable, new Month(Date::factory($currentMonthAnchorStr)), $currentMonthPartial, \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_UP, $site, $window); + } + if ($currentMonthEstimate === null) { + $samples = $this->recentSameMoYValues($monthlySamples, $monthAnchors[$todayMonthIdx], self::YEAR_ANALOG_CHUNK); + $currentMonthEstimate = !empty($samples) ? $this->computeHistoricalPrior($samples) : $currentMonthPartial; + } + $currentMonthEstimate = max($currentMonthEstimate, $currentMonthPartial); + $window->projectMonth($monthAnchors[$todayMonthIdx], $currentMonthEstimate); + $remainingExpected = 0.0; + for ($i = $todayMonthIdx + 1; $i < 12; ++$i) { + $samples = $this->recentSameMoYValues($monthlySamples, $monthAnchors[$i], self::YEAR_ANALOG_CHUNK); + if (empty($samples)) { + continue; + } + $projected = $this->computeHistoricalPrior($samples); + $remainingExpected += $projected; + $window->projectMonth($monthAnchors[$i], $projected); + } + return $completedReal + $currentMonthEstimate + $remainingExpected; + } + /** + * FREE/DOWN-flavoured year decomposition: build per-month value list (archived monthly + * value for completed months, recursive month-seasonal forecast for the current month, + * same-MoY analog mean for remaining months) and combine with the monotonicity's reducer + * (AVG for FREE, MIN for DOWN). No partial-floor: monthly rates and monthly mins do not + * combine with the year's running value the way monthly counts do. + * + * @param array $monthAnchors + */ + private function forecastYearSeasonalAggregate(DataTable $dataTable, array $monthAnchors, int $todayMonthIdx, string $monotonicity, Site $site, \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSampleWindow $window) : ?float + { + $dailySamples = $window->getDailySamples(); + $monthlySamples = $window->getMonthlySamples(); + $monthlyValues = []; + for ($i = 0; $i < $todayMonthIdx; ++$i) { + if (isset($monthlySamples[$monthAnchors[$i]])) { + $monthlyValues[] = (float) $monthlySamples[$monthAnchors[$i]]; + } + } + $currentMonthAnchorStr = $monthAnchors[$todayMonthIdx] . '-01'; + $currentMonthEstimate = null; + if ([] !== $dailySamples) { + // Recurse with the same monotonicity so the month-level decomposition uses AVG + // or MIN as appropriate. currentValue is unused by those reducers, so 0.0 is fine. + $currentMonthEstimate = $this->forecastMonthSeasonal($dataTable, new Month(Date::factory($currentMonthAnchorStr)), 0.0, $monotonicity, $site, $window); + } + if ($currentMonthEstimate === null) { + $samples = $this->recentSameMoYValues($monthlySamples, $monthAnchors[$todayMonthIdx], self::YEAR_ANALOG_CHUNK); + if (!empty($samples)) { + $currentMonthEstimate = $this->reduceMonthlySamples($samples, $monotonicity); + } + } + if ($currentMonthEstimate !== null) { + $monthlyValues[] = $currentMonthEstimate; + $window->projectMonth($monthAnchors[$todayMonthIdx], $currentMonthEstimate); + } + for ($i = $todayMonthIdx + 1; $i < 12; ++$i) { + $samples = $this->recentSameMoYValues($monthlySamples, $monthAnchors[$i], self::YEAR_ANALOG_CHUNK); + if (empty($samples)) { + continue; + } + $projected = $this->reduceMonthlySamples($samples, $monotonicity); + $monthlyValues[] = $projected; + $window->projectMonth($monthAnchors[$i], $projected); + } + if (empty($monthlyValues)) { + return null; + } + return $this->reduceMonthlySamples($monthlyValues, $monotonicity); + } + /** + * Combine same-month-of-year analog samples (and the per-month estimates derived from them) + * for the year-aggregate path, by the reducer the monotonicity implies: MIN for running + * mins, MAX for running maxes, and the unweighted mean for the FREE rate/average case. + * MONOTONICITY_UP never reaches this helper -- additive year forecasts take the dedicated + * {@see self::forecastYearSeasonalUp()} sum path instead. + * + * @param array $samples Non-empty. + */ + private function reduceMonthlySamples(array $samples, string $monotonicity) : float + { + switch ($monotonicity) { + case \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_DOWN: + return min($samples); + case \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_MAX: + return max($samples); + default: + return array_sum($samples) / count($samples); + } + } + /** + * Shared completed/in-progress/remaining decomposition for the UP (additive count) week and + * month paths. "Today" is the sub-period containing the current site-local instant. + * Sub-periods before today contribute their real archived values from $dailySamples. + * Today's contribution is the larger of (the partial floor implied by currentValue minus + * completed real) and the same-DoW analog prior — never an elapsed-time multiplication. + * Remaining sub-periods are projected from same-DoW analogs reduced by the day-level reducer. + * + * Captures today's contribution and the remaining-day projections onto $window so the + * caller can feed them forward into the running daily samples map. Completed-day values + * are not written back because they are already present in $window's daily samples; only + * the values the decomposition produced for this tick need to be added. + * + * @param array $dayAnchors + */ + private function decomposeAndForecast(array $dayAnchors, int $todayIdx, float $currentValue, int $analogChunk, float $analogScale, \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSampleWindow $window) : float + { + $dailySamples = $window->getDailySamples(); + $completedReal = 0.0; + for ($i = 0; $i < $todayIdx; ++$i) { + $completedReal += $dailySamples[$dayAnchors[$i]] ?? 0.0; + } + $todayAnchor = $dayAnchors[$todayIdx]; + $todayPartial = max(0.0, $currentValue - $completedReal); + $todayPrior = $this->projectDailyValueViaAnalog($todayAnchor, $dailySamples, $analogChunk, $analogScale, null); + $todayContribution = $todayPrior !== null ? max($todayPartial, $todayPrior) : $todayPartial; + $window->projectDay($todayAnchor, $todayContribution); + $remainingExpected = 0.0; + $count = count($dayAnchors); + for ($i = $todayIdx + 1; $i < $count; ++$i) { + $projected = $this->projectDailyValueViaAnalog($dayAnchors[$i], $dailySamples, $analogChunk, $analogScale, $window); + if ($projected === null) { + continue; + } + $remainingExpected += $projected; + } + return $completedReal + $todayContribution + $remainingExpected; + } + /** + * Average-of-daily-rates decomposition for the FREE (rate, ratio, average) week and month + * paths. Same shape as {@see self::decomposeAndForecast()} but the in-progress day is treated + * as just another analog-projected day (no partial-floor max — the partial rate is not a + * lower bound for the final rate the way a partial count is), and remaining days are + * combined by unweighted mean instead of sum. + * + * The mean-of-daily-rates differs from the true traffic-weighted period rate by a bounded + * relative error (Simpson's-paradox magnitude that depends on how traffic is distributed + * across days) — accepted as the price for keeping the forecast stable across changes to + * the displayed range. $analogScale is always 1.0 here: scaling daily *rates* by a + * monthly-traffic ratio is conceptually wrong (a 0.8 month-of-year traffic ratio does not + * imply bounce_rate × 0.8). + * + * @param array $dayAnchors + */ + private function decomposeAndAverageRate(array $dayAnchors, int $todayIdx, int $analogChunk, \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSampleWindow $window) : ?float + { + $dailySamples = $window->getDailySamples(); + $dailyValues = $this->collectCompletedAndProjectedDailyValues($dayAnchors, $todayIdx, $dailySamples, $analogChunk, $window); + if (empty($dailyValues)) { + return null; + } + return array_sum($dailyValues) / count($dailyValues); + } + /** + * Min-of-daily-mins decomposition for the DOWN (running-min) week and month paths. Same + * collection shape as {@see self::decomposeAndAverageRate()} (in-progress day is analog- + * projected, no partial-floor), but the combining operator is `min` because a running min + * over a period is the min of the per-sub-period running mins by construction. + * + * @param array $dayAnchors + */ + private function decomposeAndMinimize(array $dayAnchors, int $todayIdx, int $analogChunk, \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSampleWindow $window) : ?float + { + $dailySamples = $window->getDailySamples(); + $dailyValues = $this->collectCompletedAndProjectedDailyValues($dayAnchors, $todayIdx, $dailySamples, $analogChunk, $window); + if (empty($dailyValues)) { + return null; + } + return min($dailyValues); + } + /** + * Max-of-daily-maxes decomposition for the MAX (running-max) week and month paths. Mirror + * of {@see self::decomposeAndMinimize()}: a running max over a period is the max of the + * per-sub-period running maxes by construction, so the combining operator is `max`. The + * in-progress day is analog-projected (no partial-floor); the completed days contribute + * their archived daily maxes. + * + * @param array $dayAnchors + */ + private function decomposeAndMaximize(array $dayAnchors, int $todayIdx, int $analogChunk, \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSampleWindow $window) : ?float + { + $dailySamples = $window->getDailySamples(); + $dailyValues = $this->collectCompletedAndProjectedDailyValues($dayAnchors, $todayIdx, $dailySamples, $analogChunk, $window); + if (empty($dailyValues)) { + return null; + } + return max($dailyValues); + } + /** + * Build the per-day value list the rate-/min-decomposition reducers consume: archived + * daily values for completed days, same-DoW analog projections for the in-progress day + * and remaining days. Records the analog projections on $window so later same-DoW ticks + * in the same series pick them up. + * + * @param array $dayAnchors + * @param array $dailySamples + * @return array + */ + private function collectCompletedAndProjectedDailyValues(array $dayAnchors, int $todayIdx, array $dailySamples, int $analogChunk, \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSampleWindow $window) : array + { + $values = []; + for ($i = 0; $i < $todayIdx; ++$i) { + if (isset($dailySamples[$dayAnchors[$i]])) { + $values[] = (float) $dailySamples[$dayAnchors[$i]]; + } + } + $count = count($dayAnchors); + for ($i = $todayIdx; $i < $count; ++$i) { + $projected = $this->projectDailyValueViaAnalog($dayAnchors[$i], $dailySamples, $analogChunk, 1.0, $window); + if ($projected !== null) { + $values[] = $projected; + } + } + return $values; + } + /** + * Project a single sub-period day's value from same-DoW analog samples. Returns null when + * no analogs exist in the supplied $dailySamples. When $window is non-null the projection + * is also recorded into the window's day-projection accumulator so later same-DoW ticks + * in this series pick it up via the running daily map. + * + * @param array $dailySamples + */ + private function projectDailyValueViaAnalog(string $anchor, array $dailySamples, int $analogChunk, float $analogScale, ?\Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSampleWindow $window) : ?float + { + $samples = $this->recentSameDoWValues($dailySamples, $anchor, $analogChunk); + if (empty($samples)) { + return null; + } + if ($analogScale !== 1.0) { + $samples = array_map(static function ($v) use($analogScale) { + return $v * $analogScale; + }, $samples); + } + $value = $this->dayLevelAnalogPrior($samples); + if ($window !== null) { + $window->projectDay($anchor, $value); + } + return $value; + } + /** + * Day-level analog reducer. Plain mean below MIN_SAMPLES_FOR_DAY_LEVEL_TREND (the slope of + * a 2-3 sample fit on weekly-strided same-DoW values is dominated by noise and the envelope + * clamp is not active to contain it); damped least-squares trend at and above the threshold. + * + * @param array $samples + */ + private function dayLevelAnalogPrior(array $samples) : float + { + $n = count($samples); + if ($n === 0) { + return 0.0; + } + if ($n < self::MIN_SAMPLES_FOR_DAY_LEVEL_TREND) { + return max(0.0, array_sum($samples) / $n); + } + return $this->computeHistoricalPrior($samples); + } + /** + * Walk back 7 days at a time from the day before $targetAnchor, collecting up to $K samples + * that exist in $dailySamples. Returned oldest-first so the trend fit lines up with + * chronological order. + * + * @param array $dailySamples + * @return array + */ + private function recentSameDoWValues(array $dailySamples, string $targetAnchor, int $K) : array + { + if (empty($dailySamples)) { + return []; + } + // Drive the stride from Matomo's Date class so the cursor sequence stays calendar-aligned + // regardless of the process timezone. The samples map is keyed by site-local anchors, so a + // process-TZ stride could drift across midnight and skip or duplicate a key. + $samples = []; + $maxLookbackYears = max(1, $K); + $cursor = Date::factory($targetAnchor)->subDay(7); + $stop = Date::factory($targetAnchor)->subYear($maxLookbackYears); + while (count($samples) < $K && $cursor->isLater($stop)) { + $key = $cursor->toString('Y-m-d'); + if (isset($dailySamples[$key])) { + $samples[] = (float) $dailySamples[$key]; + } + $cursor = $cursor->subDay(7); + } + return array_reverse($samples); + } + /** + * Walk back same-month-of-year entries from $monthlySamples. Keys are 'YYYY-MM'. + * + * @param array $monthlySamples + * @return array + */ + private function recentSameMoYValues(array $monthlySamples, string $targetMonthAnchor, int $K) : array + { + if (empty($monthlySamples)) { + return []; + } + $samples = []; + $parts = explode('-', $targetMonthAnchor); + if (count($parts) < 2) { + return []; + } + $year = (int) $parts[0]; + $month = (int) $parts[1]; + $minYear = $year - max(1, $K) - 1; + while (count($samples) < $K && $year > $minYear) { + --$year; + $key = sprintf('%04d-%02d', $year, $month); + if (isset($monthlySamples[$key])) { + $samples[] = (float) $monthlySamples[$key]; + } + } + return array_reverse($samples); + } + /** + * Same-MoY level relative to the rolling-monthly baseline implied by the daily sample + * window. Returns 1.0 when either side is missing or degenerate so the caller falls back + * gracefully to the unscaled day-level mean. + * + * The denominator uses the daily sample sum (not the monthly index) because that is the + * same surface the same-DoW analog reducer draws from -- so the ratio cancels the "average + * month covered by day samples" out and leaves only the MoY effect. + */ + private function computeMonthOfYearScale(string $monthAnchor, int $analogChunk, \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSampleWindow $window) : float + { + $monthlySamples = $window->getMonthlySamples(); + if (empty($monthlySamples)) { + return 1.0; + } + $samples = $this->recentSameMoYValues($monthlySamples, $monthAnchor, $analogChunk); + if (empty($samples)) { + return 1.0; + } + $numer = $this->computeHistoricalPrior($samples); + if ($numer <= 0.0) { + return 1.0; + } + $dailySamples = $window->getDailySamples(); + if (empty($dailySamples)) { + $monthValues = array_values($monthlySamples); + $tail = array_slice($monthValues, -min(count($monthValues), 12)); + if (empty($tail)) { + return 1.0; + } + $denom = array_sum($tail) / count($tail); + } else { + $sum = array_sum($dailySamples); + if ($sum <= 0.0) { + return 1.0; + } + // 30.4375 = average days per month over a 4-year cycle. + $denom = $sum / count($dailySamples) * 30.4375; + } + if ($denom <= 0.0) { + return 1.0; + } + return $numer / $denom; + } + /** + * Return the index of the sub-period (within $dayAnchors) that contains the reference instant + * in the site's timezone. Used to decide which sub-periods are "completed" (real) vs + * "in-progress" / "future" (analog-projected). + * + * Calendar-aligned lookup against the anchor list rather than seconds arithmetic so a DST + * transition mid-period (where one wall-clock day is 23h or 25h) cannot shift the index. + * + * @param array $dayAnchors Site-local 'Y-m-d' strings for each sub-period, in + * chronological order. Must be non-empty. + */ + private function resolveSubPeriodTodayIndex(DataTable $dataTable, array $dayAnchors, string $siteTz) : int + { + $referenceTs = $this->resolveReferenceTimestamp($dataTable); + // {@see Date::setTimezone()} reinterprets the wall-clock as belonging to a different + // timezone rather than projecting a UTC instant into that timezone's wall-clock, so the + // round-trip lands a calendar day off for far-offset sites. {@see Date::adjustForTimezone()} + // shifts the UTC seconds so that the same wall-clock formatting in UTC reads as the + // site-local wall-clock -- the same primitive + // {@see \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\Evolution::computeDataStates()} + // uses to derive "today in site TZ" -- so the anchor lookup matches a site-local 'Y-m-d'. + $referenceAnchor = Date::factory(Date::adjustForTimezone($referenceTs, $siteTz))->toString('Y-m-d'); + $idx = array_search($referenceAnchor, $dayAnchors, \true); + if (\false !== $idx) { + return (int) $idx; + } + if ($referenceAnchor < $dayAnchors[0]) { + return 0; + } + return count($dayAnchors) - 1; + } + /** + * Reference instant for "as of when is this incomplete tick being forecast". Defaults to + * Date::now() so live charts always reflect the current state, but yields to a smaller + * ARCHIVED_DATE_METADATA_NAME when present so historical archive runs (and tests) get a + * deterministic forecast pinned to the moment the archive was produced. + */ + private function resolveReferenceTimestamp(DataTable $dataTable) : int + { + $referenceTs = Date::now()->getTimestampUTC(); + $archivedDateStr = $dataTable->getMetadata(DataTable::ARCHIVED_DATE_METADATA_NAME); + if (!empty($archivedDateStr)) { + $archivedTs = Date::factory($archivedDateStr)->getTimestampUTC(); + if ($archivedTs < $referenceTs) { + $referenceTs = $archivedTs; + } + } + return $referenceTs; + } + /** + * Same-period historical prior. With fewer than two samples the only signal is the single + * value (or a flat mean). With two or more samples we apply a least-squares linear-trend + * extrapolation projected one step forward, then dampen the projection by TREND_DAMPING so + * noisy ratios do not runaway-extrapolate from a spurious slope. Catching sustained growth + * or decline that a flat mean would systematically lag is the win; the damping is what keeps + * that win from becoming a loss on volatile averages. The result is clamped to >= 0 because + * every metric the builder serves (counts, percentages, durations) is non-negative; a + * negative trend extrapolation past zero is never a defensible forecast. + * + * @param array $pastValues Same-period historical samples in temporal order + * (oldest first), already filtered by availability. Leading zeros have been stripped + * only for MONOTONICITY_UP series, where they likely mark "tracking had not started + * yet"; for FREE/DOWN series a leading 0 is a legitimate observation (a real 0% rate, + * an actual running min of 0) and is retained. + */ + private function computeHistoricalPrior(array $pastValues) : float + { + $sampleCount = count($pastValues); + if ($sampleCount < 2) { + return max(0.0, (float) $pastValues[0]); + } + $sumX = $sampleCount * ($sampleCount + 1) / 2; + $sumY = array_sum($pastValues); + $sumXX = 0.0; + $sumXY = 0.0; + for ($i = 0; $i < $sampleCount; ++$i) { + $x = $i + 1; + $sumXX += $x * $x; + $sumXY += $x * $pastValues[$i]; + } + $denominator = $sampleCount * $sumXX - $sumX * $sumX; + if ($denominator <= 0.0) { + return max(0.0, $sumY / $sampleCount); + } + $slope = ($sampleCount * $sumXY - $sumX * $sumY) / $denominator; + $intercept = ($sumY - $slope * $sumX) / $sampleCount; + // Equivalent to projecting from the regressed value at x=sampleCount and taking a + // fractional step in the slope direction: y(n) + damping * slope. + return max(0.0, $intercept + $slope * ($sampleCount + self::TREND_DAMPING)); + } + /** + * @param array $seriesData + * @param array $dataTableList + * @param array $dataStates + * @param array $seriesDataAvailability + * @param string $monotonicity Per-series intra-period direction tag, one of the + * {@see ForecastMetricClassifier::MONOTONICITY_*} constants. Drives whether leading zeros are + * stripped: only MONOTONICITY_UP treats them as "tracking had not started yet". + * For FREE/DOWN a leading 0 is kept as a legitimate observation. + * @param array $dailySamples Optional daily sample map (Y-m-d → value) + * covering enough history to populate the day-period prior. When supplied on a day + * target, the prior is built from same-DoW analogs walked back through this map + * instead of from the displayed range alone — short displays (4-7 day charts) + * otherwise carry at most one same-DoW history tick. + * @param string|null $earliestDataDate Earliest 'Y-m-d' the site/segment can hold data. + * Same-period analog samples dated before it are dropped so the day prior does not + * depend on how far back the displayed range happens to reach: the sub-period fetch + * already floors its own window here, but the displayed-range map and the legacy + * dataTableList walk are not fetched through that floor, so a wide "rows to display" + * would otherwise pull in pre-creation history and flip the forecast on. Null skips + * the floor. + * @return array + */ + private function getHistoricalSamplesForSeries(array $seriesData, array $dataTableList, array $dataStates, int $currentTickIndex, DataTable $currentDataTable, array $seriesDataAvailability = [], string $monotonicity = \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_UP, array $dailySamples = [], ?string $earliestDataDate = null) : array + { + $allSamples = []; + $alignedSamples = []; + $periodLabel = $this->getPeriodLabel($currentDataTable); + if ('day' === $periodLabel && [] !== $dailySamples) { + if (null !== $earliestDataDate) { + foreach (array_keys($dailySamples) as $sampleDate) { + if (strcmp((string) $sampleDate, $earliestDataDate) < 0) { + unset($dailySamples[$sampleDate]); + } + } + } + $todayAnchor = $this->getPeriod($currentDataTable)->getDateStart()->toString('Y-m-d'); + $samples = $this->recentSameDoWValues($dailySamples, $todayAnchor, self::DAY_PRIOR_TARGET_SAMPLES); + if (\Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_UP === $monotonicity) { + return $this->stripPreLevelShiftSamples($this->removeLeadingZeroSamples($samples)); + } + return array_values($samples); + } + for ($tickIndex = 0; $tickIndex < $currentTickIndex; ++$tickIndex) { + if (($dataStates[$tickIndex] ?? null) !== ArchiveState::COMPLETE) { + continue; + } + if (!isset($seriesData[$tickIndex])) { + continue; + } + if (($seriesDataAvailability[$tickIndex] ?? \true) === \false) { + continue; + } + $value = (float) $seriesData[$tickIndex]; + $dataTable = $dataTableList[$tickIndex] ?? null; + if (empty($dataTable)) { + continue; + } + // Floor the walk at the earliest date the site/segment can hold data. The displayed + // range is not fetched through the sub-period fetcher's creation-date clamp, so + // without this a wide "rows to display" pulls pre-creation ticks into the prior and + // makes the forecast depend on the displayed width. Compared on the period start so a + // period straddling the creation date is kept. + if (null !== $earliestDataDate && strcmp($this->getPeriod($dataTable)->getDateStart()->toString('Y-m-d'), $earliestDataDate) < 0) { + continue; + } + $isAligned = $this->isSamplePeriodCalendarAligned($currentDataTable, $dataTable, $periodLabel); + if ('day' === $periodLabel) { + // Daily series have strong day-of-week effects; mixing weekdays into a Saturday + // forecast (or vice versa) is worse than working with a single same-DOW sample, + // so the strict filter overrides the aligned/all fallback used for week/month. + if (!$isAligned) { + continue; + } + $allSamples[] = $value; + continue; + } + $allSamples[] = $value; + if ($isAligned) { + $alignedSamples[] = $value; + } + } + $samples = 'day' !== $periodLabel && count($alignedSamples) >= self::MIN_ALIGNED_SAMPLES_TO_PREFER ? $alignedSamples : $allSamples; + // Leading-zero stripping is only sound for additive counts where a leading 0 most + // likely marks "tracking had not started yet". For MONOTONICITY_DOWN (running mins) + // and MONOTONICITY_FREE (rates/averages) a leading 0 is a legitimate observation + // (e.g. a real running min of 0, a 0% rate on a low-traffic day) and dropping it + // would inflate the prior — for DOWN it tends to fail the forecast <= current gate + // and silently suppress an otherwise-renderable forecast. + if (\Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_UP === $monotonicity) { + return $this->removeLeadingZeroSamples($samples); + } + return $samples; + } + /** + * True when the candidate sample is "calendar-aligned" to the current period: same + * day-of-week for daily, same ISO week-of-year for weekly, same calendar month for monthly. + * Year periods have no useful alignment (every prior tick is the same kind of period), so + * the check returns true and lets the recency-only sample set carry the forecast. + */ + private function isSamplePeriodCalendarAligned(DataTable $current, DataTable $candidate, string $periodLabel) : bool + { + switch ($periodLabel) { + case 'day': + return $this->getPeriodStartDayOfWeek($current) === $this->getPeriodStartDayOfWeek($candidate); + case 'week': + return $this->getPeriodStartIsoWeek($current) === $this->getPeriodStartIsoWeek($candidate); + case 'month': + return $this->getPeriodStartCalendarMonth($current) === $this->getPeriodStartCalendarMonth($candidate); + default: + return \true; + } + } + /** + * Clamp a trend extrapolation to a robust historical-range envelope. Both the centre and the + * spread are robust statistics of the past samples, so a single recent spike — the case the + * clamp exists to tame — can neither shift the band onto itself nor inflate it wide enough to + * contain its own runaway extrapolation: + * + * - Centre is the sample *median*, not the value being clamped. The extrapolation is the + * noisy term we want to bound, so an envelope built around it (the previous behaviour) + * always contained it and never fired. + * - Spread is the median absolute deviation scaled onto the sigma scale, not the raw + * standard deviation. A lone outlier barely moves the MAD but blows up the std, which + * would widen a std-based band past the outlier-driven prior and defeat the clamp. + * + * A genuine sustained trend still produces enough MAD spread — plus the relative-spread + * floor — to pass through untouched. Used by the prior-only fallback. + * + * @param array $pastValues Historical samples used to size and centre the envelope. + */ + private function clampForecastToHistoricalRange(float $forecastValue, array $pastValues) : float + { + if ([] === $pastValues) { + return $forecastValue; + } + $center = $this->median($pastValues); + $absoluteDeviations = []; + foreach ($pastValues as $sample) { + $absoluteDeviations[] = abs($sample - $center); + } + $spread = $this->median($absoluteDeviations) * self::MAD_TO_SIGMA; + $minSpread = abs($center) * self::BOUNDED_RANGE_MIN_RELATIVE_SPREAD; + $halfWidth = max($spread, $minSpread) * self::BOUNDED_RANGE_SIGMAS; + $lower = max(0.0, $center - $halfWidth); + $upper = $center + $halfWidth; + return max($lower, min($upper, $forecastValue)); + } + /** + * Median of an unordered sample list. Even counts return the mean of the two central values. + * + * @param array $values Non-empty sample list. + */ + private function median(array $values) : float + { + sort($values); + $count = count($values); + $mid = intdiv($count, 2); + if ($count % 2 === 1) { + return (float) $values[$mid]; + } + return ((float) $values[$mid - 1] + (float) $values[$mid]) / 2.0; + } + /** + * @param array $samples + * @return array + */ + private function removeLeadingZeroSamples(array $samples) : array + { + while ([] !== $samples && 0.0 === (float) reset($samples)) { + array_shift($samples); + } + return array_values($samples); + } + /** + * Strip the leading (oldest) run of same-period samples that sit on the far side of a traffic + * level shift from the current level, so the damped linear-trend prior fits the current + * regime instead of reading the pre-shift level as a steep ongoing trend. + * + * Without this, a site whose traffic stepped (e.g. a 3.5x drop) partway through the same-DoW + * window collapses: the fit runs a line from the old high samples down through the recent low + * ones and projects well below the current level, which the envelope clamp then only floors at + * a low bound. The current level is the median of the most recent {@see RECENT_LEVEL_WINDOW} + * samples; any unbroken run of oldest samples more than {@see LEVEL_SHIFT_RATIO} away from it + * (either direction) is dropped. A gradual trend never crosses that ratio within the window so + * it survives untouched — only a discrete multi-fold step is trimmed. Mirrors + * {@see removeLeadingZeroSamples()}, which strips a different kind of unrepresentative leading + * run (pre-tracking zeros). + * + * Skipped below RECENT_LEVEL_WINDOW samples (too few to tell a shift from noise) and when the + * recent level is <= 0 (the trailing-no-data path handles a genuinely empty recent window). + * Never trims into the recent-level window itself, so at least those samples always remain. + * + * @param array $samples Oldest-first. + * @return array + */ + private function stripPreLevelShiftSamples(array $samples) : array + { + $count = count($samples); + if ($count < self::RECENT_LEVEL_WINDOW) { + return $samples; + } + $recentLevel = $this->median(array_slice($samples, -self::RECENT_LEVEL_WINDOW)); + if ($recentLevel <= 0.0) { + return $samples; + } + $lower = $recentLevel / self::LEVEL_SHIFT_RATIO; + $upper = $recentLevel * self::LEVEL_SHIFT_RATIO; + $maxTrim = $count - self::RECENT_LEVEL_WINDOW; + $trim = 0; + while ($trim < $maxTrim && ($samples[$trim] < $lower || $samples[$trim] > $upper)) { + ++$trim; + } + return array_slice($samples, $trim); + } + private function shouldRenderForecastValue(float $forecastValue, float $currentDisplayValue, string $monotonicity) : bool + { + switch ($monotonicity) { + case \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_FREE: + return \true; + case \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_DOWN: + return $forecastValue <= $currentDisplayValue; + case \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_MAX: + case \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_UP: + default: + // A running max can only rise within the period, so the final value cannot fall + // below the current partial max -- same gate as the additive UP case. + return $forecastValue >= $currentDisplayValue; + } + } + private function getPeriod(DataTable $dataTable) : Period + { + /** @var Period $period */ + $period = $dataTable->getMetadata(DataTableFactory::TABLE_METADATA_PERIOD_INDEX); + return $period; + } + private function getPeriodLabel(DataTable $dataTable) : string + { + return $this->getPeriod($dataTable)->getLabel(); + } + private function getPeriodStartDayOfWeek(DataTable $dataTable) : string + { + return $this->getPeriod($dataTable)->getDateStart()->toString('N'); + } + private function getPeriodStartIsoWeek(DataTable $dataTable) : string + { + return $this->getPeriod($dataTable)->getDateStart()->toString('W'); + } + private function getPeriodStartCalendarMonth(DataTable $dataTable) : string + { + return $this->getPeriod($dataTable)->getDateStart()->toString('m'); + } +} diff --git a/app/plugins/CoreVisualizations/JqplotDataGenerator/ForecastMetricClassifier.php b/app/plugins/CoreVisualizations/JqplotDataGenerator/ForecastMetricClassifier.php new file mode 100644 index 000000000..272450b6a --- /dev/null +++ b/app/plugins/CoreVisualizations/JqplotDataGenerator/ForecastMetricClassifier.php @@ -0,0 +1,152 @@ + */ + private $semanticTypes; + /** + * @param array|null $semanticTypes Semantic-type map (column name → one of + * the {@see Dimension}::TYPE_* constants). Pass null in production to use Matomo's + * default registry; pass a fixed map in tests. + */ + public function __construct(?array $semanticTypes = null) + { + $this->semanticTypes = $semanticTypes ?? Metrics::getDefaultMetricSemanticTypes(); + } + /** + * Classify a column into one of three intra-period directions: + * + * - MONOTONICITY_UP: counts/sums/totals that can only grow within the period + * ("forecast >= current" gate applies). + * - MONOTONICITY_DOWN: running mins that can only fall within the period + * ("forecast <= current" gate applies). + * - MONOTONICITY_MAX: running maxes that can only rise within the period (same + * "forecast >= current" gate as UP), but whose period value is the max -- not the sum -- + * of its sub-periods, so the seasonal decomposition combines sub-periods with max(). + * - MONOTONICITY_FREE: ratios, rates, percentages, averages whose value can move in either + * direction within the period (no gate). + * + * Driven by column unit, semantic type, and a small name-convention layer. The convention + * layer cannot disambiguate metrics whose names look like counts but are actually ratios + * (ctr, position, web-vitals percentiles); those need a dedicated plugin signal. + * + * @param string|false $columnUnit + * @return self::MONOTONICITY_* + */ + public function getColumnMonotonicity(string $columnName, $columnUnit) : string + { + if ($columnUnit === '%') { + return self::MONOTONICITY_FREE; + } + // TYPE_PERCENT and TYPE_FLOAT are non-monotonic by construction (a percentage's or a + // ratio's value can move in either direction within a partial period). Plugins extend + // the semantic-type map via the Metrics.getDefaultMetricSemanticTypes event, so a + // custom metric declared as TYPE_PERCENT or TYPE_FLOAT classifies correctly without + // needing a magic name. + $semanticType = $this->semanticTypes[$columnName] ?? null; + if ($semanticType === Dimension::TYPE_PERCENT || $semanticType === Dimension::TYPE_FLOAT) { + return self::MONOTONICITY_FREE; + } + // Name-pattern fallback for metrics whose semantic type is the ambiguous TYPE_NUMBER + // but whose name reveals ratio shape (e.g. nb_actions_per_visit is TYPE_NUMBER yet + // genuinely non-monotonic). The avg_ prefix also disambiguates TYPE_DURATION_*/TYPE_BYTE + // averages from their additive sum_ siblings. + if ($this->hasRatioShapedColumnName($columnName)) { + return self::MONOTONICITY_FREE; + } + // min_* metrics carry a structural invariant: more samples within the period can only + // pull the running min down or leave it unchanged. The default monotonic-up gate would + // render upward-projecting forecasts on a metric that cannot rise, so flip to a + // monotonic-down gate instead. + if (strpos($columnName, 'min_') === 0) { + return self::MONOTONICITY_DOWN; + } + // max_* metrics (max_actions, max_event_value, …) are the mirror of min_*: the period + // value is the max over its sub-periods, not their sum. The default UP path would + // SUM the per-day maxes (≈ days × per-day max), inflating the forecast by an order of + // magnitude. The "forecast >= current" gate still holds (a running max only rises), + // so MAX shares UP's gate but combines sub-periods with max() instead of sum(). + if (strpos($columnName, 'max_') === 0) { + return self::MONOTONICITY_MAX; + } + // Default unknown metrics to monotonic-up count behaviour. The "forecast >= current" + // gate then suppresses obviously-wrong forecasts on metrics whose semantics we cannot + // classify, which is safer than emitting a downward forecast on a metric that turns + // out to be additive (visits, conversions, revenue, …). + return self::MONOTONICITY_UP; + } + /** + * Derive conservative raw forecast payload precision for a metric. + * + * Integer/count-like metrics should not emit fractional forecast values. Ratios, averages, + * durations, money, bytes, floats, and unknown numeric metrics keep up to two decimals. + * + * MONOTONICITY_UP, MONOTONICITY_DOWN, and MONOTONICITY_MAX are all treated as "monotonic" + * for precision — a running min_ or max_ count metric should round to integers the same way + * an additive nb_ count does. Only MONOTONICITY_FREE (ratios/averages/percentages) keeps the + * two-decimal default for TYPE_NUMBER metrics, which is the original allowsDownward = true + * behaviour. + * + * @param string|false $columnUnit + * @param self::MONOTONICITY_* $monotonicity + */ + public function getForecastPrecisionForColumn(string $columnName, $columnUnit, string $monotonicity) : int + { + if ($columnUnit !== \false) { + return 2; + } + $semanticType = $this->semanticTypes[$columnName] ?? null; + if (in_array($semanticType, [Dimension::TYPE_BYTE, Dimension::TYPE_DURATION_MS, Dimension::TYPE_DURATION_S, Dimension::TYPE_FLOAT, Dimension::TYPE_MONEY, Dimension::TYPE_PERCENT], \true)) { + return 2; + } + // Word-boundary check: an underscore-delimited "time"/"length" segment in the column + // name signals a duration- or length-shaped metric (sum_time_spent, time_per_action, + // nb_visit_length, length_score). Anchored substring matches avoid false positives on + // unrelated names that happen to contain the literal letters (lifetime_*, wavelength). + if ($this->hasRatioShapedColumnName($columnName) || strpos($columnName, '_time') !== \false || strpos($columnName, 'time_') === 0 || strpos($columnName, '_length') !== \false || strpos($columnName, 'length_') === 0) { + return 2; + } + if ($semanticType === Dimension::TYPE_NUMBER && $monotonicity !== self::MONOTONICITY_FREE) { + return 0; + } + if (strpos($columnName, 'nb_') === 0 || strpos($columnName, '_nb_') !== \false || strpos($columnName, '_count') !== \false || in_array($columnName, ['hits', 'items', 'quantity', 'orders', 'goals'], \true)) { + return 0; + } + return 2; + } + /** + * True when a column name carries one of the ratio/average/rate name patterns Matomo uses + * for non-monotonic metrics. Shared between the monotonicity classifier and the forecast + * precision picker so the two cannot drift on the same set of name fragments. + */ + private function hasRatioShapedColumnName(string $columnName) : bool + { + return strpos($columnName, '_rate') !== \false || strpos($columnName, '_percentage') !== \false || strpos($columnName, 'avg_') === 0 || strpos($columnName, '_per_') !== \false; + } +} diff --git a/app/plugins/CoreVisualizations/JqplotDataGenerator/ForecastSampleWindow.php b/app/plugins/CoreVisualizations/JqplotDataGenerator/ForecastSampleWindow.php new file mode 100644 index 000000000..bf8869b42 --- /dev/null +++ b/app/plugins/CoreVisualizations/JqplotDataGenerator/ForecastSampleWindow.php @@ -0,0 +1,79 @@ + Read-only daily sample map (Y-m-d → value). */ + private $dailySamples; + /** @var array Read-only monthly sample map (Y-m → value). */ + private $monthlySamples; + /** @var array Day projections produced during this tick (Y-m-d → value). */ + private $dayProjections = []; + /** @var array Month projections produced during this tick (Y-m → value). */ + private $monthProjections = []; + /** + * @param array $dailySamples + * @param array $monthlySamples + */ + public function __construct(array $dailySamples, array $monthlySamples) + { + $this->dailySamples = $dailySamples; + $this->monthlySamples = $monthlySamples; + } + /** @return array */ + public function getDailySamples() : array + { + return $this->dailySamples; + } + /** @return array */ + public function getMonthlySamples() : array + { + return $this->monthlySamples; + } + public function projectDay(string $anchor, float $value) : void + { + $this->dayProjections[$anchor] = $value; + } + public function projectMonth(string $anchor, float $value) : void + { + $this->monthProjections[$anchor] = $value; + } + /** @return array */ + public function getDayProjections() : array + { + return $this->dayProjections; + } + /** @return array */ + public function getMonthProjections() : array + { + return $this->monthProjections; + } +} diff --git a/app/plugins/CoreVisualizations/JqplotDataGenerator/ForecastSeriesState.php b/app/plugins/CoreVisualizations/JqplotDataGenerator/ForecastSeriesState.php new file mode 100644 index 000000000..9b5685492 --- /dev/null +++ b/app/plugins/CoreVisualizations/JqplotDataGenerator/ForecastSeriesState.php @@ -0,0 +1,102 @@ +> */ + private $allSeriesData; + /** @var array> */ + private $allSeriesDataAvailability; + /** + * Per-series intra-period direction tag. Values are one of the + * {@see ForecastMetricClassifier::MONOTONICITY_*} constants: + * - MONOTONICITY_UP: counts/sums; gate forecast >= current. + * - MONOTONICITY_DOWN: running mins; gate forecast <= current. + * - MONOTONICITY_FREE: ratios/averages; no gate. + * + * @var array + */ + private $allSeriesMonotonicity; + /** @var array */ + private $allSeriesForecastPrecision; + /** + * Per-series map from the (translated) series label used as the array key in the other + * parallel maps to the raw archive column name behind it. Needed because sub-period API + * results are keyed by raw column name (after ReplaceColumnNames runs) while the rest of + * the forecast pipeline keys by series label. + * + * @var array + */ + private $allSeriesColumns; + /** + * Per-series map from the (translated) series label to the row label/matcher the + * displayed-series path passes to {@see DataTable::getRowFromLabel()}. `false` selects + * the sub-table's first row (single-row reports). Multi-row evolution graphs + * (selectable_rows) need this so the sub-period sample fetch pulls each series' + * historical samples from its own row instead of whichever row happens to sort first. + * + * @var array + */ + private $allSeriesRows; + /** + * @param array> $allSeriesData + * @param array> $allSeriesDataAvailability + * @param array $allSeriesMonotonicity + * @param array $allSeriesForecastPrecision + * @param array $allSeriesColumns + * @param array $allSeriesRows + */ + public function __construct(array $allSeriesData, array $allSeriesDataAvailability, array $allSeriesMonotonicity, array $allSeriesForecastPrecision, array $allSeriesColumns = [], array $allSeriesRows = []) + { + $this->allSeriesData = $allSeriesData; + $this->allSeriesDataAvailability = $allSeriesDataAvailability; + $this->allSeriesMonotonicity = $allSeriesMonotonicity; + $this->allSeriesForecastPrecision = $allSeriesForecastPrecision; + $this->allSeriesColumns = $allSeriesColumns; + $this->allSeriesRows = $allSeriesRows; + } + /** @return array> */ + public function getAllSeriesData() : array + { + return $this->allSeriesData; + } + /** @return array> */ + public function getAllSeriesDataAvailability() : array + { + return $this->allSeriesDataAvailability; + } + /** @return array */ + public function getAllSeriesMonotonicity() : array + { + return $this->allSeriesMonotonicity; + } + /** @return array */ + public function getAllSeriesForecastPrecision() : array + { + return $this->allSeriesForecastPrecision; + } + /** @return array */ + public function getAllSeriesColumns() : array + { + return $this->allSeriesColumns; + } + /** @return array */ + public function getAllSeriesRows() : array + { + return $this->allSeriesRows; + } +} diff --git a/app/plugins/CoreVisualizations/JqplotDataGenerator/ForecastSeriesStateBuilder.php b/app/plugins/CoreVisualizations/JqplotDataGenerator/ForecastSeriesStateBuilder.php new file mode 100644 index 000000000..a8cd500cf --- /dev/null +++ b/app/plugins/CoreVisualizations/JqplotDataGenerator/ForecastSeriesStateBuilder.php @@ -0,0 +1,74 @@ +> */ + private $allSeriesData = []; + /** @var array> */ + private $allSeriesDataAvailability = []; + /** @var array */ + private $allSeriesMonotonicity = []; + /** @var array */ + private $allSeriesForecastPrecision = []; + /** @var array */ + private $allSeriesColumns = []; + /** @var array */ + private $allSeriesRows = []; + /** + * Record a series for which only the chart-rendering data has been collected (forecast + * disabled). The forecast-only fields stay absent from the resulting state -- consumers + * fall back to their default behaviour when the series label is missing from those maps. + * + * @param array $seriesData + */ + public function addDataOnlySeries(string $seriesLabel, array $seriesData) : void + { + $this->allSeriesData[$seriesLabel] = $seriesData; + } + /** + * Record a series with its full per-series state (data + the classifier-driven + * monotonicity, precision, and row/column identifiers the builder and sub-period + * fetcher consume). + * + * @param array $seriesData + * @param array $seriesDataAvailability + * @param mixed $rowLabel + */ + public function addForecastSeries(string $seriesLabel, array $seriesData, array $seriesDataAvailability, string $monotonicity, int $forecastPrecision, string $columnName, $rowLabel) : void + { + $this->allSeriesData[$seriesLabel] = $seriesData; + $this->allSeriesDataAvailability[$seriesLabel] = $seriesDataAvailability; + $this->allSeriesMonotonicity[$seriesLabel] = $monotonicity; + $this->allSeriesForecastPrecision[$seriesLabel] = $forecastPrecision; + $this->allSeriesColumns[$seriesLabel] = $columnName; + $this->allSeriesRows[$seriesLabel] = $rowLabel; + } + public function build() : \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSeriesState + { + return new \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSeriesState($this->allSeriesData, $this->allSeriesDataAvailability, $this->allSeriesMonotonicity, $this->allSeriesForecastPrecision, $this->allSeriesColumns, $this->allSeriesRows); + } +} diff --git a/app/plugins/CoreVisualizations/JqplotDataGenerator/ForecastSubPeriodFetcher.php b/app/plugins/CoreVisualizations/JqplotDataGenerator/ForecastSubPeriodFetcher.php new file mode 100644 index 000000000..31a377ba9 --- /dev/null +++ b/app/plugins/CoreVisualizations/JqplotDataGenerator/ForecastSubPeriodFetcher.php @@ -0,0 +1,784 @@ +): mixed */ + private $apiRequestProcessor; + /** @var LoggerInterface */ + private $logger; + /** @var callable(int, string): (string|null) */ + private $earliestDataDateResolver; + /** + * @param callable(string, array): mixed|null $apiRequestProcessor + * Inner-request driver. Receives the API method name and a parameter array, returns + * whatever the API method returns (expected to be a {@see DataTable\Map} for + * usable responses; anything else is treated as an empty sample set). Null uses + * {@see ApiRequest::processRequest()}. + * @param callable(int, string): (string|null)|null $earliestDataDateResolver + * Resolves the earliest 'Y-m-d' date the displayed series can have archivable data + * for, given the idSite and raw segment expression. The fan-out windows are clamped + * to this floor so the fetcher never requests sub-periods that predate the site (or + * an auto-archived segment) -- periods that cannot hold data but would otherwise + * cost a skip-path recompute or an on-demand archive on every render. Returns null + * for "no floor". Null uses {@see self::resolveEarliestDataDate()}. + */ + public function __construct(?callable $apiRequestProcessor = null, ?LoggerInterface $logger = null, ?callable $earliestDataDateResolver = null) + { + $this->apiRequestProcessor = $apiRequestProcessor ?? static function (string $apiMethod, array $params) { + return ApiRequest::processRequest($apiMethod, $params); + }; + $this->logger = $logger ?? StaticContainer::get(LoggerInterface::class); + $this->earliestDataDateResolver = $earliestDataDateResolver ?? function (int $idSite, string $segment) : ?string { + return $this->resolveEarliestDataDate($idSite, $segment); + }; + } + /** + * Fetch sub-period samples for the displayed evolution series. Returns empty maps when + * the displayed period type does not need them (e.g. non-day/week/month/year) or when + * the inner request cannot be issued (no API method, no idSite, etc.). + * + * @param array $dataTables Per-tick tables of the displayed series, ordered by + * date. Used to read the period type and the end date of the displayed range. + * @param ForecastSeriesState $seriesState Per-series metadata (columns, rows, monotonicity) + * threaded through to {@see self::extractSamples()}. + * @param string $apiMethod API method spec to fan out to (`Module.action`). + * @param int $idSite Site id the displayed series belongs to. + * @param string $segment Segment expression to pin onto the inner request. + * @return array{daily: array>, monthly: array>, earliestDataDate: string|null} + */ + public function collect(array $dataTables, \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSeriesState $seriesState, string $apiMethod, int $idSite, string $segment) : array + { + $empty = ['daily' => [], 'monthly' => [], 'earliestDataDate' => null]; + if (empty($dataTables) || [] === $seriesState->getAllSeriesColumns()) { + return $empty; + } + if (empty($apiMethod) || strpos($apiMethod, '.') === \false) { + return $empty; + } + if ($idSite <= 0) { + return $empty; + } + // Earliest date the site/segment can hold data. Both the inner fetch windows below and + // the caller's ForecastBuilder floor the analog history at this date, so resolve it once + // and thread it through every return path (including the display-only day branches that + // skip the inner request). + $earliestDataDate = ($this->earliestDataDateResolver)($idSite, $segment); + $firstTable = reset($dataTables); + $period = $firstTable->getMetadata(DataTableFactory::TABLE_METADATA_PERIOD_INDEX); + if (!$period instanceof Period) { + return $empty; + } + $periodLabel = $period->getLabel(); + $lastTable = end($dataTables); + $lastPeriod = $lastTable->getMetadata(DataTableFactory::TABLE_METADATA_PERIOD_INDEX); + if (!$lastPeriod instanceof Period) { + return $empty; + } + $endDate = $lastPeriod->getDateEnd()->subDay(1)->toString('Y-m-d'); + // getDateEnd() is the calendar end of the (possibly in-progress) last displayed period, + // so for a mid-period month/year target it lands in the future. Anchoring the historical + // sample windows there would push the daily window entirely into the future -- every + // sub-period a guaranteed-empty archive read that feeds no samples into the seasonal + // decomposition. Clamp to the last complete day in the site's timezone; the in-progress + // period's own partial value already comes from the displayed $dataTables, not this fetch. + $site = $lastTable->getMetadata(DataTableFactory::TABLE_METADATA_SITE_INDEX); + if ($site instanceof Site) { + $endDate = $this->clampEndDate($endDate, $site); + } + try { + if ('day' === $periodLabel) { + // Day-period forecasts cannot decompose into sub-periods, so the prior-only + // path is the only forecast surface. The analog walk in ForecastBuilder + // collects up to DAY_ANALOG_WINDOW_DAYS / 7 same-DoW samples from the day + // before the target back through DAY_ANALOG_WINDOW_DAYS days. The displayed + // $dataTables already carry the same archived per-day values for the + // displayed range, so the inner request only needs to fill the *gap* between + // the analog window start and the displayed range start. When the displayed + // range itself already spans the analog window (e.g. evolution_day_last_n >= 70), + // no inner request fires at all. + $firstDisplayedDate = $period->getDateStart()->toString('Y-m-d'); + $displayDailyMap = $this->extractDisplayedDailyMap($dataTables, $seriesState); + $analogWindowStart = Date::factory($endDate)->subDay(self::DAY_ANALOG_WINDOW_DAYS)->toString('Y-m-d'); + if (strcmp($firstDisplayedDate, $analogWindowStart) <= 0) { + // Displayed range covers (or exceeds) the analog window. Skip the fetch. + return ['daily' => $displayDailyMap, 'monthly' => [], 'earliestDataDate' => $earliestDataDate]; + } + $gapEndDate = Date::factory($firstDisplayedDate)->subDay(1)->toString('Y-m-d'); + // Clamp the gap window to the earliest date the site (or an auto-archived + // segment) can have data. A gap start that predates the site's existence asks + // for sub-periods that cannot hold data: each would hit the archiver's skip + // path (recomputed, never persisted) or, under browser archiving, trigger an + // on-demand archive -- on every render. When the whole gap predates the data, + // the displayed range alone supplies the analog walk, so drop the inner request. + $gapStartDate = $this->clampStartDate($analogWindowStart, $earliestDataDate); + if (strcmp($gapStartDate, $gapEndDate) > 0) { + return ['daily' => $displayDailyMap, 'monthly' => [], 'earliestDataDate' => $earliestDataDate]; + } + $gapDailyMap = $this->fetchSeries($apiMethod, $idSite, $segment, 'day', $gapStartDate, $gapEndDate, $seriesState); + return ['daily' => $this->mergeDailyMaps($gapDailyMap, $displayDailyMap), 'monthly' => [], 'earliestDataDate' => $earliestDataDate]; + } + if ('week' === $periodLabel || 'month' === $periodLabel) { + // Daily window sized to what the seasonal-decomposition path actually consumes: + // WEEK_DAILY_WINDOW_DAYS for week (in-progress week + same-DoW × 3 history), + // MONTH_DAILY_WINDOW_DAYS for month (in-progress month + same-DoW × 4 history). + $dailyWindow = 'week' === $periodLabel ? self::WEEK_DAILY_WINDOW_DAYS : self::MONTH_DAILY_WINDOW_DAYS; + $startDate = Date::factory($endDate)->subDay($dailyWindow)->toString('Y-m-d'); + $dailyMap = $this->fetchClampedSeries($apiMethod, $idSite, $segment, 'day', $startDate, $endDate, $seriesState, $earliestDataDate); + // Monthly fan-out on the month target is consumed only by the UP-flavoured + // {@see ForecastBuilder::computeMonthOfYearScale()}; FREE/DOWN month forecasts + // skip the MoY scaling because scaling rates/mins by a traffic ratio is + // conceptually wrong. Skip the inner request when no UP series is on this + // graph -- the data would have no consumer. + $monthlyMap = []; + if ('month' === $periodLabel && $this->seriesStateHasUpSeries($seriesState)) { + $monthlyMap = $this->fetchClampedSeries($apiMethod, $idSite, $segment, 'month', $this->yearsBack($endDate, self::MONTH_MONTHLY_WINDOW_YEARS), $endDate, $seriesState, $earliestDataDate); + } + return ['daily' => $dailyMap, 'monthly' => $monthlyMap, 'earliestDataDate' => $earliestDataDate]; + } + if ('year' === $periodLabel) { + $dailyStart = Date::factory($endDate)->subDay(self::YEAR_DAILY_WINDOW_DAYS)->toString('Y-m-d'); + return ['daily' => $this->fetchClampedSeries($apiMethod, $idSite, $segment, 'day', $dailyStart, $endDate, $seriesState, $earliestDataDate), 'monthly' => $this->fetchClampedSeries($apiMethod, $idSite, $segment, 'month', $this->yearsBack($endDate, self::YEAR_MONTHLY_WINDOW_YEARS), $endDate, $seriesState, $earliestDataDate), 'earliestDataDate' => $earliestDataDate]; + } + } catch (\Throwable $e) { + // Defensive: any error in the parallel fetch falls back to the prior-only path. + // The seasonal-decomposition advantage is lost on this render, but the forecast + // still renders something defensible from the displayed series alone. Log so a + // sustained dip in forecast quality is investigable instead of silent. + $this->logger->info('Evolution forecast sub-period fetch failed for {apiMethod} (idSite={idSite}, period={period}): {message}', ['apiMethod' => $apiMethod, 'idSite' => $idSite, 'period' => $periodLabel, 'message' => $e->getMessage(), 'exception' => $e]); + } + // Fetch failed or the period type needs no sub-period samples: still surface the + // resolved floor so the caller's prior-only path stays width-independent. + return ['daily' => [], 'monthly' => [], 'earliestDataDate' => $earliestDataDate]; + } + /** + * Issue a single sub-period API request and shape the result into a series-keyed map of + * date → value. Date keys are 'Y-m-d' for day targets and 'Y-m' for month targets, + * matching what {@see ForecastBuilder}'s analog walks expect. The returned map keys by + * series label so the builder's per-series lookup hits a populated entry, while the API + * row lookup uses the raw archive column name (after ReplaceColumnNames). + * + * @return array> + */ + private function fetchSeries(string $apiMethod, int $idSite, string $segment, string $subPeriod, string $startDate, string $endDate, \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSeriesState $seriesState) : array + { + // Scope the inner request to the columns the chart actually plots. The displayed + // evolution graph already does this (Controller::getLastUnitGraphAcrossPlugins sets + // custom_parameters['columns'] = columns_to_display); the fan-out did not, so when the + // graph's API method is the cross-plugin merge API.get, every sub-period rebuilt the + // full union of all contributing plugins' metrics (VisitsSummary, Actions, Referrers, + // Goals, ...). The forecast only consumes the plotted columns, so querying the rest is + // pure waste. An empty set is sent as columns='' which API.get reads as "all metrics", + // preserving the legacy unscoped behaviour for callers without a populated series state. + $plottedColumns = array_values(array_unique(array_filter($seriesState->getAllSeriesColumns(), static function ($column) : bool { + return '' !== $column; + }))); + // Resolve the cross-plugin merge API.get into one fetch group per owning Module.get report. + // API.get is a PHP-side merge, not an archive method, so the framework rebuilds its + // multi-sub-period result one sub-period at a time -- re-running the full report-metadata + // catalog build (getReportMetadata -> configureReportMetadata across every report of every + // plugin) on each, the dominant forecast-render cost. Concrete archive-backed `.get` + // methods return the whole sub-period Map from one batched archive read with no catalog + // build, so each group costs one catalog-free read instead of one full API.get rebuild per + // sub-period. Graphs whose metrics span several plugins (the Visits Overview set is the + // common case) fan out into one request per module; their per-series samples are merged + // below. Non-API.get callers and unresolvable column sets fall back to a single group on + // the original method (see resolveModuleColumnGroups). + $groups = $this->resolveModuleColumnGroups($apiMethod, $idSite, $plottedColumns); + $samples = []; + foreach ($groups as $group) { + $map = $this->requestSubPeriodMap($group['method'], $idSite, $segment, $subPeriod, $startDate, $endDate, $group['columns']); + if (null === $map) { + continue; + } + $this->applyChartUnitFormatting($map, $group['method']); + // Restrict to this group's own series before merging: the group fetched only its + // module's columns, so a sibling series' column is absent from these tables and + // extractSamples would emit a synthetic MONOTONICITY_UP zero for it on any empty + // sub-period. Merging that zero could clobber the real value the sibling's own group + // produced (and the merge order is not significant once each series is sourced solely + // from its owning group). + $groupSamples = $this->restrictSamplesToColumns($this->extractSamples($map, $seriesState, $subPeriod), $seriesState, $group['columns']); + $samples = $this->mergeSampleMaps($samples, $groupSamples); + } + return $samples; + } + /** + * Issue one sub-period request for $method scoped to $columns and return the raw + * {@see DataTable\Map}, or null when the API method did not return a Map (unsupported method, + * error response). The pinned framework result-shape params keep the inner rows aligned with + * what the chart plots; see the inline notes. + * + * @param array $columns + */ + private function requestSubPeriodMap(string $method, int $idSite, string $segment, string $subPeriod, string $startDate, string $endDate, array $columns) : ?DataTable\Map + { + // processRequest() picks up the core convention's compare=0 / format=original / + // serialize=0 defaults. $_GET + $_POST inheritance is asymmetric on purpose: scope + // params (idGoal, idDimension, future plugin selectors) inherit so the inner sample + // hits the same series the chart plots -- the set is open-ended and an allowlist + // would have to predict every plugin. Framework result-shape mutators are a closed + // set, pinned below: inheriting them would silently shift which rows the inner + // samples come from. isComparing upstream guards against comparison leakage. + $result = ($this->apiRequestProcessor)($method, [ + 'idSite' => $idSite, + 'period' => $subPeriod, + 'date' => $startDate . ',' . $endDate, + 'segment' => $segment, + 'columns' => implode(',', $columns), + 'filter_limit' => -1, + 'disable_generic_filters' => 1, + // format_metrics=1 replaces numeric values with display strings (bandwidth -> "3 G") + // and stamps PROCESSED_METRICS_FORMATTED_FLAG so the Numeric pass below cannot + // re-run. Pin raw so the decomposition has numbers to work with. + 'format_metrics' => 0, + // Framework result-shape pins -- inheriting these would mismatch the chart's rows: + 'flat' => 0, + // flattens subtables; row matcher hits wrong labels + 'expanded' => 0, + // same risk as flat=1 + 'idSubtable' => '', + // would point at an unrelated subtable + 'pivotBy' => '', + // transposes rows around a dimension + 'filter_offset' => 0, + // skips rows; shifts getFirstRow() + 'filter_sort_column' => '', + // outer sort reorders the inner first row + 'filter_sort_order' => '', + 'filter_pattern' => '', + // outer UI search shrinks the inner result + 'filter_pattern_recursive' => '', + 'filter_column' => '', + 'keep_summary_row' => 0, + ]); + return $result instanceof DataTable\Map ? $result : null; + } + /** + * Bring the inner samples onto the same scale the outer evolution chart plots in. The + * outer is rendered by {@see \Piwik\Plugins\CoreVisualizations\Visualizations\Graph}, + * which formats with the {@see Numeric} formatter (format-as-number, not as string): + * {@see \Piwik\Plugin\ProcessedMetric} columns are rewritten in place -- bandwidth + * bytes divided by 1024^3 onto the chart's fixed 'G' axis, percent quotients scaled + * to whole percent, etc. The inner request is pinned to raw numeric units, so without this + * pass the forecast would sum analog bytes-per-day against an outer current value already in + * gigabytes, the ~10^9 blowup seen on bandwidth forecasts. Running the identical Numeric pass + * here mirrors that transformation row-for-row so the seasonal decomposition stays in one unit + * system. $method is the report the group was fetched from, so per-module groups format with + * their own report's processed-metric definitions. + */ + private function applyChartUnitFormatting(DataTable\Map $result, string $method) : void + { + $report = $this->resolveReportForFormatting($method); + // Some `Module.get` reports (Goals.get is the canonical case) list a percent/ratio + // ProcessedMetric only by its string name in $processedMetrics, which + // Report::getProcessedMetricsById() drops -- the metric *object* that knows how to + // scale the raw quotient to whole percent lives on the sibling `Module.getMetrics` + // report the outer request delegates to. Without it, formatMetrics() cannot recognise + // e.g. conversion_rate and leaves the inner sample as the raw 0..1 quotient while the + // chart shows whole percent -- a forecast ~100x too small. Seed those metric objects + // onto each sub-table's metadata (the same surface AddColumnsProcessedMetrics uses) so + // the Numeric pass recognises and scales them exactly as the displayed chart does. + $extraMetrics = $this->resolveDelegatedProcessedMetrics($method); + $formatter = new Numeric(); + foreach ($result->getDataTables() as $subTable) { + if ([] !== $extraMetrics) { + $existing = $subTable->getMetadata(DataTable::EXTRA_PROCESSED_METRICS_METADATA_NAME) ?: []; + $subTable->setMetadata(DataTable::EXTRA_PROCESSED_METRICS_METADATA_NAME, array_merge($extraMetrics, $existing)); + } + $formatter->formatMetrics($subTable, $report); + } + } + /** + * Fetch a sub-period series after clamping its window start to the earliest date the + * site/segment can have data. Returns an empty sample map -- without issuing the inner + * request -- when the whole window predates that floor, since every sub-period in it would + * be a guaranteed-empty archive lookup. + * + * @return array> + */ + private function fetchClampedSeries(string $apiMethod, int $idSite, string $segment, string $subPeriod, string $startDate, string $endDate, \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSeriesState $seriesState, ?string $earliestDataDate) : array + { + $clampedStart = $this->clampStartDate($startDate, $earliestDataDate); + if (strcmp($clampedStart, $endDate) > 0) { + // The entire window falls before the site/segment came into existence. The prior-only + // path in ForecastBuilder takes over from the displayed period-level series alone. + return []; + } + return $this->fetchSeries($apiMethod, $idSite, $segment, $subPeriod, $clampedStart, $endDate, $seriesState); + } + /** + * Raise $startDate to $earliestDataDate when the latter is later. Both are 'Y-m-d' strings, + * which compare lexicographically in chronological order, so strcmp() is a date comparison + * here. A null floor (resolver opted out) leaves the start untouched. + */ + private function clampStartDate(string $startDate, ?string $earliestDataDate) : string + { + if (null !== $earliestDataDate && strcmp($earliestDataDate, $startDate) > 0) { + return $earliestDataDate; + } + return $startDate; + } + /** + * Lower $endDate to the last complete day in the site's timezone when the displayed period + * runs past it (a mid-period month/year target whose calendar end is in the future). Mirrors + * {@see self::clampStartDate()}: both operands are 'Y-m-d' strings comparing lexicographically + * in chronological order, so strcmp() is a date comparison here. + */ + private function clampEndDate(string $endDate, Site $site) : string + { + $lastCompleteDay = Date::factoryInTimezone('today', $site->getTimezone())->subDay(1)->toString('Y-m-d'); + return strcmp($endDate, $lastCompleteDay) > 0 ? $lastCompleteDay : $endDate; + } + /** + * Earliest 'Y-m-d' date the displayed series can hold archivable data for. The hard floor is + * the site creation date -- no traffic can predate the site. When the request carries an + * auto-archived segment, the floor is raised to the segment's re-archive start date, because + * core only persists that segment's archives from that date forward (earlier periods return + * empty via the archiver's segment skip path anyway, so clamping them out costs no real + * samples and removes the synthetic-zero backfill that would otherwise drag the prior down). + * + * On-demand (non auto-archived) segments are deliberately left at the site floor: core + * computes and persists their historic archives on request, so that history is genuine data + * the forecast must keep. {@see SegmentArchiving::findSegmentForHash()} returns null for + * them, which is exactly the gate. + * + * Any failure (site removed mid-request, unparseable segment) falls back to null -- no clamp + * -- so a resolver hiccup can never silently truncate a legitimate window. + */ + private function resolveEarliestDataDate(int $idSite, string $segment) : ?string + { + try { + $earliest = Date::factory(Site::getCreationDateFor($idSite)); + } catch (\Throwable $e) { + return null; + } + if ('' !== $segment) { + $segmentStart = $this->resolveSegmentStartDate($segment, $idSite); + if (null !== $segmentStart && $segmentStart->isLater($earliest)) { + $earliest = $segmentStart; + } + } + return $earliest->toString('Y-m-d'); + } + /** + * Re-archive start date for an auto-archived segment matching $segment on $idSite, or null + * when the segment is on-demand (so historic data is real and must not be clamped away) or + * cannot be resolved. + */ + private function resolveSegmentStartDate(string $segment, int $idSite) : ?Date + { + try { + $segmentObj = new Segment($segment, [$idSite]); + /** @var SegmentArchiving $segmentArchiving */ + $segmentArchiving = StaticContainer::get(SegmentArchiving::class); + $segmentInfo = $segmentArchiving->findSegmentForHash($segmentObj->getHash(), $idSite); + if (null === $segmentInfo) { + return null; + } + return $segmentArchiving->getReArchiveSegmentStartDate($segmentInfo); + } catch (\Throwable $e) { + return null; + } + } + /** + * Split the plotted columns into one fetch group per owning `Module.get` report, so the + * sub-period fan-out can issue catalog-free archive-backed requests instead of the + * per-sub-period API.get rebuild (see {@see self::fetchSeries()} for why that rebuild + * dominates the render). Each group is `['method' => 'Module.get', 'columns' => [...]]`. + * + * Returns a single group carrying the original $apiMethod when: + * - the method is not the cross-plugin merge API.get (concrete reports already avoid the + * rebuild, so there is nothing to split); + * - there are no plotted columns to scope by; + * - any column resolves to other than exactly one module (an unmapped metric, or one shared + * by several `.get` reports), where reproducing API.get's own merge precedence is not worth + * the risk -- the column-scoped API.get path stays correct, just slower. + * + * The column -> module mapping is read from the report metadata exactly as + * {@see \Piwik\Plugins\API\API::get()} reads it, keyed on the outer request's period/date so it + * reuses the catalog the displayed graph already built (a transient-cache hit). Any failure + * falls back to the single API.get group, matching this class's defensive degradation. + * + * @param array $plottedColumns + * @return array}> + */ + private function resolveModuleColumnGroups(string $apiMethod, int $idSite, array $plottedColumns) : array + { + $fallback = [['method' => $apiMethod, 'columns' => $plottedColumns]]; + if ('API.get' !== $apiMethod || [] === $plottedColumns) { + return $fallback; + } + try { + $period = Common::getRequestVar('period', 'day', 'string'); + $date = Common::getRequestVar('date', 'today', 'string'); + $meta = $this->fetchReportMetadataCatalog($idSite, $period, $date); + } catch (\Throwable $e) { + return $fallback; + } + // Build column -> set-of-owning-modules from every plugin's `.get` report, mirroring the + // scan API.get itself does (action 'get', no parameters, not the API module, has metrics). + $modulesByColumn = []; + foreach ($meta as $reportMeta) { + if (($reportMeta['action'] ?? null) !== 'get' || isset($reportMeta['parameters']) || ($reportMeta['module'] ?? 'API') === 'API' || empty($reportMeta['metrics'])) { + continue; + } + $module = $reportMeta['module']; + $metrics = array_merge($reportMeta['metrics'], $reportMeta['processedMetrics'] ?? []); + foreach ($metrics as $column => $translation) { + $modulesByColumn[$column][$module] = \true; + } + } + // Group the plotted columns by their single owning module. Bail to the API.get fallback if + // any column is unmapped or owned by more than one module, since a concrete per-module + // fan-out cannot reproduce API.get's merge for it without guessing precedence. + $columnsByModule = []; + foreach ($plottedColumns as $column) { + $owners = $modulesByColumn[$column] ?? []; + if (count($owners) !== 1) { + // Unmapped (0 owners) or shared (>1) column: the whole graph regresses to a single + // API.get fetch. This is correct but slow, and it would otherwise flip silently if a + // plugin later registered a colliding metric name -- log the offending column and its + // owner count so the regression is investigable rather than invisible. + $this->logger->debug('Evolution forecast module fan-out disabled: column {column} maps to {ownerCount} ' . 'modules (idSite={idSite}), falling back to API.get', ['column' => $column, 'ownerCount' => count($owners), 'idSite' => $idSite]); + return $fallback; + } + $columnsByModule[(string) key($owners)][] = $column; + } + $groups = []; + foreach ($columnsByModule as $module => $columns) { + $groups[] = ['method' => $module . '.get', 'columns' => $columns]; + } + return $groups; + } + /** + * Report-metadata catalog used to resolve each plotted column to its owning `Module.get` + * report, read exactly as {@see \Piwik\Plugins\API\API::get()} reads it. + * + * This is a soft optimization: it reuses getReportMetadata's transient cache only when the + * outer request already built the catalog under the same (idSite, period, date) key -- the + * common evolution-graph path does. A caller reaching here without that priming (e.g. an + * unusual widget path) pays one full catalog build itself, which is still the cost the fan-out + * then avoids on every sub-period. Kept as an overridable seam so the column->module + * resolution and its fallbacks can be unit-tested without a live report catalog. + * + * @return array> + */ + protected function fetchReportMetadataCatalog(int $idSite, string $period, string $date) : array + { + return \Piwik\Plugins\API\API::getInstance()->getReportMetadata($idSite, $period, $date); + } + /** + * Per-series union of two sample maps (seriesLabel -> dateKey -> value), used to combine the + * per-module group fetches. Each plotted column is owned by exactly one module + * ({@see self::resolveModuleColumnGroups()} bails otherwise) and each group's samples are + * restricted to its own series before merging, so a given series is contributed by a single + * group and the unions never collide; array_replace is order-insensitive here as a result. + * + * @param array> $base + * @param array> $add + * @return array> + */ + private function mergeSampleMaps(array $base, array $add) : array + { + foreach ($add as $seriesLabel => $dateValues) { + $base[$seriesLabel] = array_replace($base[$seriesLabel] ?? [], $dateValues); + } + return $base; + } + /** + * Keep only the series whose plotted column belongs to $columns. A per-module group fetches + * just its module's columns, so a sibling series' column is absent from the group's tables; + * {@see self::extractSamplesFromTables()} would still emit a synthetic MONOTONICITY_UP zero for + * that sibling on any empty sub-period, and merging the zero could clobber the real value the + * sibling's own group produced. Dropping out-of-group series here keeps every series sourced + * solely from its owning module. A single-group (fallback / concrete-method) fetch passes the + * full plotted-column set, so this is a no-op there. + * + * @param array> $samples + * @param array $columns + * @return array> + */ + private function restrictSamplesToColumns(array $samples, \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSeriesState $seriesState, array $columns) : array + { + $seriesColumns = $seriesState->getAllSeriesColumns(); + $allowed = array_fill_keys($columns, \true); + $restricted = []; + foreach ($samples as $seriesLabel => $dateValues) { + $column = $seriesColumns[$seriesLabel] ?? null; + if (null !== $column && isset($allowed[$column])) { + $restricted[$seriesLabel] = $dateValues; + } + } + return $restricted; + } + /** + * Resolve the {@see \Piwik\Plugin\Report} the formatter consults to discover which + * columns carry a {@see \Piwik\Plugin\ProcessedMetric} (and therefore need format() + * applied). API methods without a registered report (custom dimensions, plugin-defined + * report-less endpoints) yield null; {@see Numeric::formatMetrics()} then only acts on + * processed metrics carried in the table's own EXTRA_PROCESSED_METRICS_METADATA_NAME, + * which is the same surface the outer formatter sees. + */ + private function resolveReportForFormatting(string $apiMethod) : ?\Piwik\Plugin\Report + { + $parts = explode('.', $apiMethod, 2); + if (count($parts) !== 2) { + return null; + } + return ReportsProvider::factory($parts[0], $parts[1]); + } + /** + * Object-declared ProcessedMetrics from the `Module.getMetrics` report a `Module.get` + * report delegates its metric computation to. These carry the format() logic (e.g. the + * percent-quotient scaling) that the `Module.get` report exposes only as a string name and + * therefore hides from {@see \Piwik\Plugin\Report::getProcessedMetricsById()}. Returns an + * empty array for non-`get` methods or modules without a `getMetrics` sibling, so reports + * that already declare their metrics as objects (e.g. Bandwidth) are unaffected. + * + * @return array + */ + private function resolveDelegatedProcessedMetrics(string $apiMethod) : array + { + $parts = explode('.', $apiMethod, 2); + if (count($parts) !== 2 || 'get' !== $parts[1]) { + return []; + } + $metricsReport = ReportsProvider::factory($parts[0], 'getMetrics'); + if (null === $metricsReport) { + return []; + } + return $metricsReport->getProcessedMetricsById(); + } + /** + * Shape a sub-period DataTable\Map into a series-keyed map of date → value. The result of + * the inner API request has already been through ReplaceColumnNames so its row columns are + * the raw archive column names; we look up by raw name and store under the series label so + * ForecastBuilder's per-series lookup hits the right entry. + * + * Each series carries its own row matcher in $seriesState. Multi-row evolution graphs + * (selectable_rows on a non-summary report) plot one series per selected row, so the + * historical sample for each series must come from that series' own row in the sub-period + * archive -- not from {@see DataTable::getFirstRow()}, which would silently pin every + * series to whichever row sorts first in that sub-table (typically the top-ranked row). + * `false` matchers fall through to getFirstRow() to keep the single-row default behaviour. + * + * Missing monotonicity entries fall back to MONOTONICITY_UP so the legacy backfill + * behaviour survives for callers that have not propagated the classifier output yet. + * + * @return array> + */ + public function extractSamples(DataTable\Map $result, \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSeriesState $seriesState, string $subPeriod) : array + { + return $this->extractSamplesFromTables($result->getDataTables(), $seriesState, $subPeriod); + } + /** + * Same extraction as {@see self::extractSamples()} but reads from a plain array of + * sub-tables. Lets the day-target path in {@see self::collect()} reuse the same + * column-name + row-matcher walk against the already-loaded displayed `$dataTables`, + * so the inner API request can be skipped when the displayed range alone covers the + * analog window. + * + * @param array $subTables + * @return array> + */ + private function extractSamplesFromTables(array $subTables, \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSeriesState $seriesState, string $subPeriod) : array + { + $samples = []; + $rowKey = $subPeriod === 'month' ? 'Y-m' : 'Y-m-d'; + $seriesColumns = $seriesState->getAllSeriesColumns(); + $seriesRows = $seriesState->getAllSeriesRows(); + $seriesMonotonicity = $seriesState->getAllSeriesMonotonicity(); + foreach ($subTables as $subTable) { + if (!$subTable instanceof DataTable) { + continue; + } + $tablePeriod = $subTable->getMetadata(DataTableFactory::TABLE_METADATA_PERIOD_INDEX); + if (!$tablePeriod instanceof Period) { + continue; + } + $dateKey = $tablePeriod->getDateStart()->toString($rowKey); + foreach ($seriesColumns as $seriesLabel => $columnName) { + $rowMatcher = $seriesRows[$seriesLabel] ?? \false; + $row = \false === $rowMatcher ? $subTable->getFirstRow() : $subTable->getRowFromLabel($rowMatcher); + if (empty($row)) { + // No matching row on this date. Only MONOTONICITY_UP count series can + // defensibly read that as a real 0 (no observation = zero count), so they + // get the backfill to keep the analog calendar dense. MONOTONICITY_DOWN + // (running mins) and MONOTONICITY_FREE (rates/averages) have no + // "no observation → 0" mapping: a min of nothing is not 0, and a 0% rate + // inferred from no traffic is not a real ratio observation. Leaving the + // date absent lets recentSameDoWValues() skip it instead of treating a + // synthetic zero as a same-DoW analog, which would pull the prior below + // current and trip shouldRenderForecastValue() into silent suppression. + // The column-missing-on-existing-row branch below is deliberately + // different: a row that exists but lacks the requested column means the + // metric isn't reported here, which is not the same as zero -- and that + // branch already skips for every monotonicity. + $monotonicity = $seriesMonotonicity[$seriesLabel] ?? \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_UP; + if (\Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_UP === $monotonicity) { + $samples[$seriesLabel][$dateKey] = 0.0; + } + continue; + } + $value = $row->getColumn($columnName); + if ($value === \false || $value === null) { + continue; + } + $samples[$seriesLabel][$dateKey] = (float) $value; + } + } + return $samples; + } + private function yearsBack(string $endDate, int $years) : string + { + return Date::factory($endDate)->subYear($years)->toString('Y-m-d'); + } + /** + * True when any series on the chart is classified MONOTONICITY_UP. Used to decide whether + * the monthly fan-out on month target needs to fire: month-level MoY scaling only applies + * to count metrics, so a graph of nothing but ratios/averages/mins gets no value from the + * monthly archive lookup. Falls back to "assume an UP series is present" when the series + * state is partially or completely unclassified, matching ForecastBuilder's defensive + * default monotonicity for unclassified non-percent series. + */ + private function seriesStateHasUpSeries(\Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSeriesState $seriesState) : bool + { + $monotonicities = $seriesState->getAllSeriesMonotonicity(); + $columns = $seriesState->getAllSeriesColumns(); + // Defensive fallback: unclassified or partially-classified state could imply UP-like + // metrics, so do not skip the monthly fetch in that case. + if (count($monotonicities) !== count($columns) || [] === $monotonicities) { + return \true; + } + return in_array(\Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastMetricClassifier::MONOTONICITY_UP, $monotonicities, \true); + } + /** + * Build a daily sample map from the already-loaded displayed `$dataTables`, skipping + * tables flagged ArchiveState::INCOMPLETE. The skip is what keeps the substitution + * equivalent to the API fetch: the API path naturally omits incomplete days from its + * result (missing/in-progress archive → no entry), and matching that ensures a partial + * value on an in-progress tick cannot leak into a later same-DoW tick's analog walk + * via the running daily map. + * + * @param array $dataTables + * @return array> + */ + private function extractDisplayedDailyMap(array $dataTables, \Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSeriesState $seriesState) : array + { + $completeTables = []; + foreach ($dataTables as $key => $table) { + if (!$table instanceof DataTable) { + continue; + } + if (ArchiveState::INCOMPLETE === $table->getMetadata(DataTable::ARCHIVE_STATE_METADATA_NAME)) { + continue; + } + $completeTables[$key] = $table; + } + return $this->extractSamplesFromTables($completeTables, $seriesState, 'day'); + } + /** + * Per-series union of two daily sample maps. Display values win on the (unexpected) + * overlap with the gap fetch — both should reference the same archive rows for the + * same dates, but treating the display values as authoritative keeps the result + * consistent with what the chart is rendering on the same screen. + * + * @param array> $gap + * @param array> $display + * @return array> + */ + private function mergeDailyMaps(array $gap, array $display) : array + { + $merged = []; + $seriesLabels = array_unique(array_merge(array_keys($gap), array_keys($display))); + foreach ($seriesLabels as $seriesLabel) { + $merged[$seriesLabel] = array_replace($gap[$seriesLabel] ?? [], $display[$seriesLabel] ?? []); + } + return $merged; + } +} diff --git a/app/plugins/CoreVisualizations/Metrics/MetricTotalsTreatment.php b/app/plugins/CoreVisualizations/Metrics/MetricTotalsTreatment.php new file mode 100644 index 000000000..627903787 --- /dev/null +++ b/app/plugins/CoreVisualizations/Metrics/MetricTotalsTreatment.php @@ -0,0 +1,80 @@ + self::TREATMENT_DERIVED, 'nb_users' => self::TREATMENT_DERIVED, 'max_actions' => self::TREATMENT_DERIVED]; + /** + * Returns how the total of the given metric should be treated. + * + * @param array $semanticTypes Metric name => semantic type, as returned by + * {@link \Piwik\Plugin\Report::getMetricSemanticTypes()}. + * @param string[] $processedMetricNames Names of the metrics that are computed from other metrics. + * @param array $aggregationOps Column name => aggregation operation, as + * stored in the + * {@link \Piwik\DataTable::COLUMN_AGGREGATION_OPS_METADATA_NAME} + * metadata. + * @return string One of the `TREATMENT_*` constants. + */ + public static function getTreatment(string $metricName, array $semanticTypes = [], array $processedMetricNames = [], array $aggregationOps = []) : string + { + if (isset(self::TREATMENT_OVERRIDES[$metricName])) { + return self::TREATMENT_OVERRIDES[$metricName]; + } + if (in_array($metricName, $processedMetricNames, \true)) { + return self::TREATMENT_DERIVED; + } + if (isset($aggregationOps[$metricName]) && !self::isSumOperation($aggregationOps[$metricName])) { + return self::TREATMENT_DERIVED; + } + if (!empty($semanticTypes[$metricName])) { + return in_array($semanticTypes[$metricName], self::ADDITIVE_SEMANTIC_TYPES, \true) ? self::TREATMENT_ADDITIVE : self::TREATMENT_DERIVED; + } + return self::TREATMENT_ADDITIVE; + } + /** + * @param string|callable $operation + */ + private static function isSumOperation($operation) : bool + { + return is_string($operation) && 'sum' === strtolower($operation); + } +} diff --git a/app/plugins/CoreVisualizations/Visualizations/HtmlTable.php b/app/plugins/CoreVisualizations/Visualizations/HtmlTable.php index 2c403b918..1693ac555 100644 --- a/app/plugins/CoreVisualizations/Visualizations/HtmlTable.php +++ b/app/plugins/CoreVisualizations/Visualizations/HtmlTable.php @@ -17,7 +17,9 @@ use Piwik\NumberFormatter; use Piwik\Period; use Piwik\Piwik; +use Piwik\Plugin\Report; use Piwik\Plugin\Visualization; +use Piwik\Plugins\CoreVisualizations\Metrics\MetricTotalsTreatment; /** * DataTable visualization that shows DataTable data in an HTML table. * @@ -122,6 +124,80 @@ public function beforeRender() $this->assignTemplateVar('segmentTitlePretty', $this->dataTable->getMetadata('segmentPretty')); $period = $this->dataTable->getMetadata('period'); $this->assignTemplateVar('periodTitlePretty', $period ? $period->getLocalizedShortString() : ''); + $this->assignFilteredTotalsRowVars(); + // Note: This needs to be done last, as it depends on the final columns to display + $this->config->report_supports_percentage_values = $this->supportsPercentageValues(); + } + /** + * Returns whether at least one displayed column has a meaningful percentage value, using the + * same eligibility rule as the individual cells (see _dataTableViz_htmlTable_ratio.twig). + */ + private function supportsPercentageValues() : bool + { + $totals = $this->dataTable ? $this->dataTable->getMetadata('totals') : null; + if (empty($totals) || empty($this->config->columns_to_display)) { + return \false; + } + $ratioColumns = array_intersect($this->config->report_ratio_columns, array_keys($totals)); + return !empty(array_intersect($this->config->columns_to_display, $ratioColumns)); + } + /** + * Makes the report totals available next to a totals row that only totals the rows matching the + * table search, so both values can be shown, and adds the note explaining what the search did + * and did not recalculate. + */ + private function assignFilteredTotalsRowVars() : void + { + if (!$this->config->show_totals_row || !$this->dataTable->getRowsCount() || !$this->dataTable->getTotalsRow() || \true !== $this->dataTable->getMetadata(DataTable::TOTALS_ROW_IS_FILTERED_METADATA_NAME)) { + return; + } + $this->assignTemplateVar('isFilteredTotalsRow', \true); + $this->assignTemplateVar('filteredTotalsRowContext', $this->getFilteredTotalsRowContext($this->report)); + $note = Piwik::translate('General_FilteredTotalsNote', Piwik::translate('General_FilteredTotal')); + $this->config->show_footer_message = empty($this->config->show_footer_message) ? $note : $this->config->show_footer_message . '
    ' . $note; + } + /** + * Returns the report total of each displayed metric, together with how that total relates to the + * total of the rows matching the table search. + * + * @param Report|null $report The report of the table, which is not set for every visualization. + * @return array + */ + private function getFilteredTotalsRowContext(?Report $report) : array + { + $reportTotals = $this->dataTable->getMetadata('totals'); + if (!is_array($reportTotals)) { + return array(); + } + $semanticTypes = $report ? $report->getMetricSemanticTypes() : array(); + $processedMetricNames = array_keys(Report::getProcessedMetricsForTable($this->dataTable, $report)); + $aggregationOps = $this->getAggregationOpsByMetricName(); + $context = array(); + foreach ($this->config->columns_to_display as $column) { + if ('label' === $column || !array_key_exists($column, $reportTotals)) { + continue; + } + $context[$column] = array('treatment' => MetricTotalsTreatment::getTreatment($column, $semanticTypes, $processedMetricNames, $aggregationOps), 'reportTotal' => $reportTotals[$column]); + } + return $context; + } + /** + * Returns the aggregation operations of the table indexed by metric name, as they can still be + * indexed by metric ID at this point. + * + * @return array + */ + private function getAggregationOpsByMetricName() : array + { + $aggregationOps = $this->dataTable->getMetadata(DataTable::COLUMN_AGGREGATION_OPS_METADATA_NAME); + if (!is_array($aggregationOps)) { + return array(); + } + $result = array(); + foreach ($aggregationOps as $column => $operation) { + $result[Metrics::getReadableColumnName($column)] = $operation; + } + return $result; } public function beforeGenericFiltersAreAppliedToLoadedDataTable() { diff --git a/app/plugins/CoreVisualizations/Visualizations/HtmlTable/Config.php b/app/plugins/CoreVisualizations/Visualizations/HtmlTable/Config.php index ea6e4f501..9c4493c94 100644 --- a/app/plugins/CoreVisualizations/Visualizations/HtmlTable/Config.php +++ b/app/plugins/CoreVisualizations/Visualizations/HtmlTable/Config.php @@ -94,6 +94,15 @@ class Config extends VisualizationConfig * @var array */ public $report_ratio_columns = array(); + /** + * Whether this report displays at least one column that has a meaningful percentage value, + * ie. whether the setting to show percentage values should be offered for this report. + * + * Derived from columns_to_display, so it is only accurate once those are final. A visualization + * that rewrites the columns after HtmlTable::beforeRender() has run must set this again. + * @var bool + */ + public $report_supports_percentage_values = \false; /** * The minimum width for the label column in table visualizations. * diff --git a/app/plugins/CoreVisualizations/Visualizations/HtmlTable/RequestConfig.php b/app/plugins/CoreVisualizations/Visualizations/HtmlTable/RequestConfig.php index 32ec1fa85..c1e3a1a50 100644 --- a/app/plugins/CoreVisualizations/Visualizations/HtmlTable/RequestConfig.php +++ b/app/plugins/CoreVisualizations/Visualizations/HtmlTable/RequestConfig.php @@ -31,6 +31,13 @@ class RequestConfig extends VisualizationRequestConfig * Default value: false */ public $keep_totals_row = \false; + /** + * If true, eligible metric cells show the percentage of the report total, and the absolute + * value is shown on hover instead. + * + * Default value: false + */ + public $show_percentage_values = \false; public function __construct() { $this->totals = \true; @@ -43,7 +50,7 @@ public function __construct() } $this->filter_excludelowpop_value = \false; } - $this->addPropertiesThatShouldBeAvailableClientSide(array('search_recursive', 'filter_limit', 'filter_offset', 'filter_sort_column', 'filter_sort_order', 'keep_summary_row', 'keep_totals_row', 'show_dimensions')); - $this->addPropertiesThatCanBeOverwrittenByQueryParams(array('keep_summary_row', 'keep_totals_row', 'show_dimensions')); + $this->addPropertiesThatShouldBeAvailableClientSide(array('search_recursive', 'filter_limit', 'filter_offset', 'filter_sort_column', 'filter_sort_order', 'keep_summary_row', 'keep_totals_row', 'show_dimensions', 'show_percentage_values')); + $this->addPropertiesThatCanBeOverwrittenByQueryParams(array('keep_summary_row', 'keep_totals_row', 'show_dimensions', 'show_percentage_values')); } } diff --git a/app/plugins/CoreVisualizations/Visualizations/JqplotGraph/Evolution.php b/app/plugins/CoreVisualizations/Visualizations/JqplotGraph/Evolution.php index 7047e5e74..836172e6f 100644 --- a/app/plugins/CoreVisualizations/Visualizations/JqplotGraph/Evolution.php +++ b/app/plugins/CoreVisualizations/Visualizations/JqplotGraph/Evolution.php @@ -11,9 +11,11 @@ use Piwik\API\Request as ApiRequest; use Piwik\Common; use Piwik\Container\StaticContainer; +use Piwik\DataTable; use Piwik\Period\Factory; use Piwik\Period\Range; use Piwik\Plugins\CoreVisualizations\JqplotDataGenerator; +use Piwik\Plugins\CoreVisualizations\JqplotDataGenerator\ForecastSeriesState; use Piwik\Plugins\CoreVisualizations\Visualizations\JqplotGraph; use Piwik\Plugins\CoreVisualizations\Visualizations\EvolutionPeriodSelector; use Piwik\Site; @@ -26,10 +28,41 @@ class Evolution extends JqplotGraph { public const ID = 'graphEvolution'; public const SERIES_COLOR_COUNT = 8; + /** + * Precomputed forecast values, keyed by series index then tick index. Populated by + * afterAllFiltersAreApplied() so the data generator can reuse the same result instead + * of recomputing it during render. + * + * @var array> + */ + private $forecastData = []; + /** + * Per-series state collected by JqplotDataGenerator\Evolution::precomputeForecast() + * so the later initChartObjectData() pass can skip its row × column loop. Null when + * no precompute ran. + * + * @var ForecastSeriesState|null + */ + private $forecastSeriesState = null; public static function getDefaultConfig() { return new \Piwik\Plugins\CoreVisualizations\Visualizations\JqplotGraph\Evolution\Config(); } + /** + * @return array> + */ + public function getForecastData() : array + { + return $this->forecastData; + } + public function setForecastSeriesState(?ForecastSeriesState $state) : void + { + $this->forecastSeriesState = $state; + } + public function getForecastSeriesState() : ?ForecastSeriesState + { + return $this->forecastSeriesState; + } public function beforeRender() { parent::beforeRender(); @@ -59,6 +92,12 @@ public function beforeLoadDataTable() $this->requestConfig->request_parameters_to_modify['period'] = $selector->getHighestPeriodInCommon($requestingPeriod, []); $this->requestConfig->request_parameters_to_modify['date'] = $requestingPeriod->getRangeString(); } + // Forecast values can only be drawn by the LineRenderer. Force-off when the viz is in + // bar mode (subclass override or ?show_line_graph=0 query param) so the always-on + // forecast cannot sneak forecast computation into a bar-mode render. + if (!$this->config->show_line_graph) { + $this->config->show_forecast = \false; + } $this->config->custom_parameters['columns'] = $this->config->columns_to_display; if ($this->isComparing() && $isComparingDatesOrPeriods) { $this->config->show_limit_control = \false; @@ -88,11 +127,39 @@ public function afterAllFiltersAreApplied() $rowCount = $this->dataTable->getRowsCount(); $this->config->x_axis_step_size = $this->getDefaultXAxisStepSize($rowCount); } + // The forecast is always active for line charts, but the per-series builder only runs + // when there is something to forecast: precomputeForecast() bails cheaply unless at + // least one tick is incomplete, so dashboards full of historical-only evolution + // widgets do not pay for the regression on every render. + if ($this->config->show_forecast && !$this->config->disable_forecast) { + $this->forecastData = $this->precomputeForecastData(); + } } protected function makeDataGenerator($properties) { return JqplotDataGenerator::factory('evolution', $properties, $this); } + /** + * @return array> + */ + private function precomputeForecastData() : array + { + if ($this->isComparing()) { + return []; + } + /** @var DataTable|DataTable\Map|null $dataTable */ + $dataTable = $this->dataTable; + if (!$dataTable instanceof DataTable\Map) { + return []; + } + // Same merge order as Visualization::render() when it populates + // $view->properties, so the precomputed forecast sees the same property + // set the rendered chart will. + $properties = array_merge($this->requestConfig->getProperties(), $this->config->getProperties()); + /** @var JqplotDataGenerator\Evolution $dataGenerator */ + $dataGenerator = $this->makeDataGenerator($properties); + return $dataGenerator->precomputeForecast($dataTable); + } /** * Based on the period, date and evolution_{$period}_last_n query parameters, * calculates the date range this evolution chart will display data for. diff --git a/app/plugins/CoreVisualizations/Visualizations/JqplotGraph/Evolution/Config.php b/app/plugins/CoreVisualizations/Visualizations/JqplotGraph/Evolution/Config.php index 50cdb212d..0f1bc6669 100644 --- a/app/plugins/CoreVisualizations/Visualizations/JqplotGraph/Evolution/Config.php +++ b/app/plugins/CoreVisualizations/Visualizations/JqplotGraph/Evolution/Config.php @@ -21,6 +21,25 @@ class Config extends JqplotGraphConfig * Default value: true */ public $show_line_graph = \true; + /** + * Whether forecast values should be rendered for incomplete periods. The forecast is + * always active; whether anything is actually drawn still depends on feasibility (line + * chart, not {@see $disable_forecast}, and at least one incomplete period that yields a + * renderable value). Forced off in bar mode by the visualization's beforeLoadDataTable() + * since the BarRenderer has nowhere to draw forecast points. + * + * Default value: true + */ + public $show_forecast = \true; + /** + * Hard gate that suppresses the forecast feature regardless of {@see $show_forecast}. + * Skips the precompute path so callers that fan out into label-filtered inner API calls + * (e.g. row evolution popovers) do not pay for the sub-period blob fetches the forecast + * builder consumes. + * + * Default value: false + */ + public $disable_forecast = \false; public function __construct() { parent::__construct(); @@ -30,8 +49,10 @@ public function __construct() $this->hide_annotations_view = \false; $this->x_axis_step_size = \false; $this->show_line_graph = \true; - $this->addPropertiesThatShouldBeAvailableClientSide(array('show_line_graph')); - $this->addPropertiesThatCanBeOverwrittenByQueryParams(array('show_line_graph')); + $this->show_forecast = \true; + $this->disable_forecast = \false; + $this->addPropertiesThatShouldBeAvailableClientSide(['show_line_graph']); + $this->addPropertiesThatCanBeOverwrittenByQueryParams(['show_line_graph']); $period = Common::getRequestVar('period'); if ($period !== 'range') { $this->show_limit_control = \true; diff --git a/app/plugins/CoreVisualizations/Visualizations/Sparklines.php b/app/plugins/CoreVisualizations/Visualizations/Sparklines.php index 4b9ee73ed..fd1fc8039 100644 --- a/app/plugins/CoreVisualizations/Visualizations/Sparklines.php +++ b/app/plugins/CoreVisualizations/Visualizations/Sparklines.php @@ -10,7 +10,6 @@ use Piwik\API\Request; use Piwik\Common; -use Piwik\Container\StaticContainer; use Piwik\DataTable; use Piwik\Metrics; use Piwik\Metrics\Formatter as MetricFormatter; @@ -19,8 +18,6 @@ use Piwik\Plugin\ReportsProvider; use Piwik\Plugin\ViewDataTable; use Piwik\Plugins\API\Filter\DataComparisonFilter; -use Piwik\Plugins\CoreVisualizations\FeatureFlags\SparklinesRedesign; -use Piwik\Plugins\FeatureFlags\FeatureFlagManager; use Piwik\Piwik; use Piwik\SettingsPiwik; use Piwik\View; @@ -98,17 +95,55 @@ public function render() $view->titleAttributes = $this->config->title_attributes; $view->footerMessage = $this->config->show_footer_message; $view->areSparklinesLinkable = $this->config->areSparklinesLinkable(); - $view->isComparing = $this->isComparing(); - // The redesigned Vue card grid (gated by the SparklinesRedesign feature flag) currently only - // covers the no-comparison layout, so fall back to the legacy Twig layout while comparing. - $featureFlagManager = StaticContainer::get(FeatureFlagManager::class); - $view->useNewSparklinesGrid = $featureFlagManager->isFeatureActive(SparklinesRedesign::class) && !$this->isComparing(); + // The redesigned Vue card grid covers the no-comparison layout, two-date comparison, + // segment comparison, and segment + date comparison; comparing three or more dates + // stays on the legacy Twig layout. + $comparisonMode = $this->getSupportedRedesignComparisonMode(); + $view->useNewSparklinesGrid = $comparisonMode !== null; + // Layout the grid should render: 'none', 'date', 'segment' or 'segmentDate' + // (see getSupportedRedesignComparisonMode()). + $view->sparklinesComparisonMode = $comparisonMode ?? 'none'; $view->title = ''; if ($this->config->show_title) { $view->title = $this->config->title; } return $view->render(); } + /** + * Which layout the redesigned Vue card grid should render for the current request, or null when + * the request is not supported and must fall back to the legacy Twig layout. Supported modes: + * + * - 'none' no comparison + * - 'date' comparison of exactly two dates (one extra compareDate), without segment comparison + * - 'segment' segment comparison of any number of segments over a single date + * - 'segmentDate' segment comparison of any number of segments over exactly two dates (one extra + * compareDate) + * + * Comparing three or more dates stays on the legacy layout. + */ + private function getSupportedRedesignComparisonMode() : ?string + { + if (!$this->isComparing()) { + return 'none'; + } + $request = $this->getRequestArray(); + $compareSegments = $request['compareSegments'] ?? []; + $compareDates = $request['compareDates'] ?? []; + $comparedDatesCount = is_array($compareDates) ? count($compareDates) : 0; + // Date comparison of exactly two dates (one extra compareDate), without segment comparison. + if (empty($compareSegments) && $comparedDatesCount === 1) { + return 'date'; + } + // Segment comparison over a single date, without date comparison. + if (!empty($compareSegments) && $comparedDatesCount === 0) { + return 'segment'; + } + // Segment comparison over exactly two dates (one extra compareDate): the combined mode. + if (!empty($compareSegments) && $comparedDatesCount === 1) { + return 'segmentDate'; + } + return null; + } /** * Load the datatable from the API using the pre-configured request object * @@ -221,7 +256,7 @@ private function fetchConfiguredSparklines() continue; } $formattedValue = $this->formatSparklineMetricValue($value, $columnToUse[$i], $columnMetrics, $metricFormatter, $idSite); - $metricInfo = ['value' => $formattedValue, 'description' => $compareDescriptions[$i], 'title' => $metricTranslations[$columnToUse[$i]] ?? $compareDescriptions[$i], 'group' => $periodPretty]; + $metricInfo = ['value' => $formattedValue, 'description' => $compareDescriptions[$i], 'title' => $this->resolveMetricTitle($columnToUse[$i], $compareDescriptions[$i], $metricTranslations), 'group' => $periodPretty]; if (isset($evolutions[$i])) { $comparisonIndex = $periodIndex === 0 ? 1 : 0; $comparisonRow = $comparePeriods[$comparisonIndex] ?? \false; @@ -252,7 +287,7 @@ private function fetchConfiguredSparklines() if (!isset($column[$i])) { continue; } - $newMetric = ['value' => $this->formatSparklineMetricValue($value, $column[$i], $columnMetrics, $metricFormatter, $idSite), 'description' => $descriptions[$i], 'title' => $metricTranslations[$column[$i]] ?? $descriptions[$i]]; + $newMetric = ['value' => $this->formatSparklineMetricValue($value, $column[$i], $columnMetrics, $metricFormatter, $idSite), 'description' => $descriptions[$i], 'title' => $this->resolveMetricTitle($column[$i], $descriptions[$i], $metricTranslations)]; $metrics[] = $newMetric; } $evolution = null; @@ -305,6 +340,27 @@ private function getValuesAndDescriptions($firstRow, $columns, $evolutionColumnN } return [$values, $descriptions, $evolutions]; } + /** + * Resolves the card title shown for a sparkline metric in the redesigned grid. + * + * By default the card title is the generic metric name from + * Metrics::getDefaultMetricTranslations() (falling back to the per-metric description). When a + * view opts in via {@link Config::$use_metric_labels_as_titles} — e.g. Ecommerce, which relabels + * shared columns per section and renders no block title — the view's own metric translation is + * used instead, so sections that reuse the same columns stay distinguishable. + * + * @param string $column + * @param string $description already-resolved per-metric description (label, else raw column) + * @param array $metricTranslations Metrics::getDefaultMetricTranslations() + * @return string + */ + private function resolveMetricTitle($column, $description, array $metricTranslations) + { + if ($this->config->use_metric_labels_as_titles) { + return $this->config->translations[$column] ?? $metricTranslations[$column] ?? $description; + } + return $metricTranslations[$column] ?? $description; + } private function removeUniqueVisitorsIfNotEnabledForPeriod($columns, $period) { if (SettingsPiwik::isUniqueVisitorsEnabled($period)) { diff --git a/app/plugins/CoreVisualizations/Visualizations/Sparklines/Config.php b/app/plugins/CoreVisualizations/Visualizations/Sparklines/Config.php index a024a09d9..5710be0ee 100644 --- a/app/plugins/CoreVisualizations/Visualizations/Sparklines/Config.php +++ b/app/plugins/CoreVisualizations/Visualizations/Sparklines/Config.php @@ -58,6 +58,14 @@ class Config extends \Piwik\ViewDataTable\Config * @var callable */ public $compute_evolution = null; + /** + * When true, the redesigned sparkline card uses this view's own metric translations as the card + * title instead of the generic Metrics::getDefaultMetricTranslations() names. Intended for views + * that relabel shared columns with section-specific names and render no per-section block title + * (show_title = false), e.g. Ecommerce Overview. Default false keeps the generic card titles. + * @var bool + */ + public $use_metric_labels_as_titles = \false; public function __construct() { parent::__construct(); @@ -249,7 +257,19 @@ public function addSparkline($requestParamsForSparkline, $metricInfos, $descript $groupedMetrics[$metricGroup][] = $metricInfo; } $tooltip = $this->generateSparklineTooltip($requestParamsForSparkline); - $sparkline = array('url' => $this->getUrlSparkline($requestParamsForSparkline), 'tooltip' => $tooltip, 'metrics' => $groupedMetrics, 'order' => $this->getSparklineOrder($order), 'title' => $title, 'group' => $group, 'seriesIndices' => $seriesIndices, 'graphParams' => $graphParams); + $sparkline = array( + 'url' => $this->getUrlSparkline($requestParamsForSparkline), + 'tooltip' => $tooltip, + 'metrics' => $groupedMetrics, + // Ordered `metrics` group keys = the Vue grid's column order. Sent as an array because JS + // re-sorts integer-like object keys (eg year "2025"/"2026"), losing the backend order. + 'metricsOrder' => array_map('strval', array_keys($groupedMetrics)), + 'order' => $this->getSparklineOrder($order), + 'title' => $title, + 'group' => $group, + 'seriesIndices' => $seriesIndices, + 'graphParams' => $graphParams, + ); if (!empty($evolution)) { if (!is_array($evolution) || !array_key_exists('currentValue', $evolution) || !array_key_exists('pastValue', $evolution)) { throw new \Exception('In order to show an evolution in the sparklines view a currentValue and pastValue array key needs to be present'); diff --git a/app/plugins/CoreVisualizations/javascripts/jqplot.js b/app/plugins/CoreVisualizations/javascripts/jqplot.js index 6254663ac..d23b7c60e 100644 --- a/app/plugins/CoreVisualizations/javascripts/jqplot.js +++ b/app/plugins/CoreVisualizations/javascripts/jqplot.js @@ -262,6 +262,7 @@ function applyFooterLegendRowLimit($dataTable) metricsToPlot: _pk_translate('General_MetricsToPlot'), metricToPlot: _pk_translate('General_MetricToPlot'), recordsToPlot: _pk_translate('General_RecordsToPlot'), + forecast: _pk_translate('General_Forecast'), incompletePeriod: _pk_translate('General_IncompletePeriod'), invalidatedPeriod: _pk_translate('General_InvalidatedPeriod') }; @@ -280,6 +281,7 @@ function applyFooterLegendRowLimit($dataTable) this.data = graphData.data; this._setJqplotParameters(graphData.params); this._setDataStates(graphData.dataStates); + this._setForecastData(graphData.forecastData); if (this.props.display_percentage_in_tooltip) { this._setTooltipPercentages(); @@ -309,6 +311,14 @@ function applyFooterLegendRowLimit($dataTable) } }, + _setForecastData: function (forecastData) { + this.jqplotParams.forecastData = []; + + if (Array.isArray(forecastData)) { + this.jqplotParams.forecastData = forecastData; + } + }, + _setJqplotParameters: function (params) { defaultParams = { grid: { @@ -851,18 +861,25 @@ function applyFooterLegendRowLimit($dataTable) }, setYTicksForAxis: function (axisName, axis) { - // calculate maximum x value of all data sets - var maxCrossDataSets = 0; + // calculate maximum y value of all data sets + var maxDataValue = 0; for (var i = 0; i < this.data.length; i++) { if (this.jqplotParams.series[i].yaxis == axisName) { var maxValue = Math.max.apply(Math, this.data[i]); - if (maxValue > maxCrossDataSets) { - maxCrossDataSets = maxValue; + if (maxValue > maxDataValue) { + maxDataValue = maxValue; } - maxCrossDataSets = parseFloat(maxCrossDataSets); + maxDataValue = parseFloat(maxDataValue); } } + // forecast values live in a parallel array invisible to jqplot's auto-axis, + // so widen the tick span only when a forecast point would otherwise fall + // above every actual data point. axis.max is set further down only in that case. + var maxForecastValue = this.getMaxForecastValueForAxis(axisName); + var forecastExceedsData = maxForecastValue > maxDataValue; + var maxCrossDataSets = forecastExceedsData ? maxForecastValue : maxDataValue; + // add little padding on top maxCrossDataSets += Math.max(1, Math.round(maxCrossDataSets * .03)); @@ -893,6 +910,38 @@ function applyFooterLegendRowLimit($dataTable) ticks.push(i * tickDistance); } axis.ticks = ticks; + + if (forecastExceedsData) { + // jqplot would otherwise auto-cap axis.max at the actual data max and + // the forecast renderer's series_u2p clamp would pin the marker at the + // top edge instead of plotting it at its real value. + axis.max = ticks[ticks.length - 1]; + } + }, + + getMaxForecastValueForAxis: function (axisName) { + var forecastData = this.jqplotParams.forecastData; + if (!Array.isArray(forecastData)) { + return 0; + } + + var maxForecastValue = 0; + for (var i = 0; i < forecastData.length; i++) { + var series = this.jqplotParams.series && this.jqplotParams.series[i]; + if (!series || series.yaxis !== axisName) { + continue; + } + + var seriesForecast = forecastData[i] || []; + for (var j = 0; j < seriesForecast.length; j++) { + var value = seriesForecast[j]; + if (Number.isFinite(value) && value > maxForecastValue) { + maxForecastValue = value; + } + } + } + + return maxForecastValue; }, /** Get a formatted y values (with unit) */ @@ -1116,6 +1165,7 @@ JQPlotExternalSeriesToggle.prototype = { config.params.series = []; config.params.axes = {xaxis: this.originalAxes.xaxis}; config.params.seriesColors = []; + config.params.forecastData = []; for (var j = 0; j < this.activated.length; j++) { // find index of series and data @@ -1130,6 +1180,11 @@ JQPlotExternalSeriesToggle.prototype = { config.data.push(this.originalData[k]); config.params.seriesColors.push(this.originalSeriesColors[k]); config.params.series.push($.extend(true, {}, this.originalSeries[k])); + config.params.forecastData.push( + (this.originalParams.forecastData && this.originalParams.forecastData[k]) + ? this.originalParams.forecastData[k] + : [] + ); // build array of used axes var axis = this.originalSeries[k].yaxis; if ($.inArray(axis, usedAxes) == -1) { @@ -1402,6 +1457,40 @@ RowEvolutionSeriesToggle.prototype.beforeReplot = function () { plot.plugins.piwikTicks.currentXTick = false; } + function drawForecastHighlightMarker(ctx, x, y, color, backgroundColor) { + var outerSize = 3; + var haloOuterSize = 7; + var rgba = $.jqplot.getColorComponents(color); + var alpha = rgba[3] * .4; + var haloColor = 'rgba(' + rgba[0] + ',' + rgba[1] + ',' + rgba[2] + ',' + alpha + ')'; + + ctx.save(); + ctx.lineWidth = 2; + ctx.strokeStyle = color; + ctx.fillStyle = backgroundColor; + + // subtle hover background in diamond shape + ctx.beginPath(); + ctx.fillStyle = haloColor; + ctx.moveTo(x, y - haloOuterSize); + ctx.lineTo(x + haloOuterSize, y); + ctx.lineTo(x, y + haloOuterSize); + ctx.lineTo(x - haloOuterSize, y); + ctx.closePath(); + ctx.fill(); + + ctx.beginPath(); + ctx.fillStyle = backgroundColor; + ctx.moveTo(x, y - outerSize); + ctx.lineTo(x + outerSize, y); + ctx.lineTo(x, y + outerSize); + ctx.lineTo(x - outerSize, y); + ctx.closePath(); + ctx.stroke(); + + ctx.restore(); + } + // highlight a marker function highlight(plot, tick) { var c = plot.plugins.piwikTicks; @@ -1428,6 +1517,30 @@ RowEvolutionSeriesToggle.prototype.beforeReplot = function () { var position = series.gridData[tick]; if (typeof position !== 'undefined') { c.markerRenderer.draw(position[0], position[1], c.piwikHighlightCanvas._ctx); + + var forecastValues = plot.options.forecastData && plot.options.forecastData[i]; + var dataState = plot.options.dataStates && plot.options.dataStates[tick]; + var forecastValue = Array.isArray(forecastValues) ? forecastValues[tick] : null; + + if ( + dataState === 'incomplete' + && Number.isFinite(forecastValue) + && series._yaxis + ) { + var boundedForecastValue = Math.min( + series._yaxis.max, + Math.max(series._yaxis.min, forecastValue) + ); + var forecastY = series._yaxis.series_u2p(boundedForecastValue); + + drawForecastHighlightMarker( + c.piwikHighlightCanvas._ctx, + position[0], + forecastY, + seriesMarkerRenderer.color, + plot.grid.background + ); + } } } } @@ -1731,6 +1844,54 @@ RowEvolutionSeriesToggle.prototype.beforeReplot = function () { // ------------------------------------------------------------ (function ($) { + function drawForecastMarker(ctx, x, y, color, backgroundColor) { + var outerSize = 3; + var innerSize = 2; + + ctx.save(); + ctx.lineWidth = 2; + ctx.strokeStyle = color; + ctx.fillStyle = backgroundColor; + + ctx.beginPath(); + ctx.moveTo(x, y - outerSize); + ctx.lineTo(x + outerSize, y); + ctx.lineTo(x, y + outerSize); + ctx.lineTo(x - outerSize, y); + ctx.closePath(); + ctx.stroke(); + + ctx.beginPath(); + ctx.moveTo(x, y - innerSize); + ctx.lineTo(x + innerSize, y); + ctx.lineTo(x, y + innerSize); + ctx.lineTo(x - innerSize, y); + ctx.closePath(); + ctx.fill(); + + ctx.restore(); + } + + function drawForecastConnector(ctx, fromX, fromY, toX, toY, color) { + var deltaX = toX - fromX; + var deltaY = toY - fromY; + var distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + + if (distance < 1) { + return; + } + + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = 1; + ctx.setLineDash([2, 2]); + ctx.beginPath(); + ctx.moveTo(fromX, fromY); + ctx.lineTo(toX, toY); + ctx.stroke(); + ctx.closePath(); + ctx.restore(); + } $.jqplot.LineRenderer.prototype.draw = function(ctx, gd, options, plot) { var i; @@ -1747,6 +1908,14 @@ RowEvolutionSeriesToggle.prototype.beforeReplot = function () { opts.dataStates = plot.options.dataStates; } + if ( + plot.options.hasOwnProperty('forecastData') + && Array.isArray(plot.options.forecastData) + && Array.isArray(plot.options.forecastData[this.index]) + ) { + opts.forecastData = plot.options.forecastData[this.index]; + } + if (!Array.isArray(opts.dataStates)) { opts.dataStates = []; } @@ -1956,6 +2125,7 @@ RowEvolutionSeriesToggle.prototype.beforeReplot = function () { if (this.renderer.smooth) { gd = this.gridData; } + for (i = 0; i < gd.length; i++) { if (gd[i][0] === null || gd[i][1] === null) { continue; @@ -1969,6 +2139,89 @@ RowEvolutionSeriesToggle.prototype.beforeReplot = function () { this.markerRenderer.draw(gd[i][0], gd[i][1], ctx, markerOptions); } } + + // Draw the forecast indicator independently of the regular markers. The dashed + // connector to the forecast value is always rendered when a forecast is available, + // so it stays visible even though evolution line graphs hide the per-point markers. + // The static diamond is only drawn alongside the regular markers; when markers are + // hidden the diamond is reserved for the hover highlight. + if (!fill) { + if (this.renderer.smooth) { + gd = this.gridData; + } + let previousForecastPoint = null; + + for (i = 0; i < gd.length; i++) { + if (gd[i][0] === null || gd[i][1] === null) { + previousForecastPoint = null; + continue; + } + + const forecastValue = Array.isArray(opts.forecastData) ? opts.forecastData[i] : null; + + if (opts.dataStates[i] === 'incomplete' && Number.isFinite(forecastValue)) { + const forecastX = gd[i][0]; + const boundedForecastValue = Math.min( + this._yaxis.max, + Math.max(this._yaxis.min, forecastValue) + ); + const forecastY = this._yaxis.series_u2p(boundedForecastValue); + + // When the forecast coincides with the tick's tracked value (e.g. an + // additive metric rendered "flat at current"), the incomplete-period + // dashed segment already covers this exact path, so the forecast + // connector and diamond would only overdraw it. Skip the drawing, but + // keep the point in the chain so a later diverging forecast still + // connects from here -- the tooltip continues to report the value. + if (Math.abs(forecastY - gd[i][1]) < 0.5) { + previousForecastPoint = [forecastX, forecastY]; + continue; + } + + const forecastColor = opts.color || this.color; + let connectorStart = previousForecastPoint; + + if (!connectorStart) { + let previousPointIndex = i - 1; + while ( + previousPointIndex >= 0 + && (gd[previousPointIndex][0] === null || gd[previousPointIndex][1] === null) + ) { + previousPointIndex -= 1; + } + + if (previousPointIndex >= 0) { + connectorStart = gd[previousPointIndex]; + } + } + + if (connectorStart) { + drawForecastConnector( + ctx, + connectorStart[0], + connectorStart[1], + forecastX, + forecastY, + forecastColor + ); + } + + if (this.markerRenderer.show) { + drawForecastMarker( + ctx, + forecastX, + forecastY, + forecastColor, + plot.grid.background + ); + } + + previousForecastPoint = [forecastX, forecastY]; + } else { + previousForecastPoint = null; + } + } + } } ctx.restore(); diff --git a/app/plugins/CoreVisualizations/javascripts/jqplotEvolutionGraph.js b/app/plugins/CoreVisualizations/javascripts/jqplotEvolutionGraph.js index bd028e0bb..f264c624c 100644 --- a/app/plugins/CoreVisualizations/javascripts/jqplotEvolutionGraph.js +++ b/app/plugins/CoreVisualizations/javascripts/jqplotEvolutionGraph.js @@ -122,11 +122,25 @@ const value = self.formatY(valueUnformatted, d); const series = self.jqplotParams.series[d].label; const seriesColor = self.jqplotParams.seriesColors[d]; + const forecastValueUnformatted = + self.jqplotParams.forecastData + && self.jqplotParams.forecastData[d] + ? self.jqplotParams.forecastData[d][tick] + : null; - dataByAxis[axis].push( + let valueContent = `` + - `${value} ${piwikHelper.htmlEntities(series)}` - ); + `${value} ${piwikHelper.htmlEntities(series)}`; + + if ( + self.jqplotParams.dataStates[tick] === 'incomplete' + && Number.isFinite(forecastValueUnformatted) + ) { + const forecastValue = self.formatY(forecastValueUnformatted, d); + valueContent += `
    ${self._lang.forecast}: ${forecastValue}`; + } + + dataByAxis[axis].push(valueContent); } let xAxisCount = 0; diff --git a/app/plugins/CoreVisualizations/stylesheets/dataTableVisualizations.less b/app/plugins/CoreVisualizations/stylesheets/dataTableVisualizations.less index 98707768a..4cf1d1f19 100644 --- a/app/plugins/CoreVisualizations/stylesheets/dataTableVisualizations.less +++ b/app/plugins/CoreVisualizations/stylesheets/dataTableVisualizations.less @@ -52,6 +52,21 @@ a.rowevolution-startmulti { display: none; } +// Left-align the "Choose metrics" picker on pie graphs: pie hides its legend items, so the picker +// is the only flex child and the footer's justify-content:center centered it. Bar and evolution +// graphs are intentionally excluded — bar's legend items (flex:auto) already pin the picker left, +// so justify-content is a no-op there, and evolution keeps its legend where it is. +// (:not(.is-narrow) leaves the stacked, full-width narrow/dashboard layout untouched.) +.dataTableVizPie .jqplot-legend-footer.has-picker:not(.is-narrow) { + justify-content: flex-start; +} + +// Dashboard widgets do not inherit the report page's left inset. +.widget .dataTableVizBar .jqplot-legend-footer.has-picker:not(.is-narrow), +.widget .dataTableVizPie .jqplot-legend-footer.has-picker:not(.is-narrow) { + padding-left: 20px; +} + // When evolution annotations are shown they sit above the legend footer and add // their own spacing, so drop the default margin to avoid doubling the gap. .evolution-annotations ~ .jqplot-legend-footer { diff --git a/app/plugins/CoreVisualizations/stylesheets/jqplot.less b/app/plugins/CoreVisualizations/stylesheets/jqplot.less index 40373a9d3..358062214 100644 --- a/app/plugins/CoreVisualizations/stylesheets/jqplot.less +++ b/app/plugins/CoreVisualizations/stylesheets/jqplot.less @@ -294,4 +294,16 @@ a.rowevolution-startmulti { .jqplot-seriespicker-popover p.pickRow:hover { background-color: @theme-color-background-tinyContrast; } + + .rowevolution table.metrics td.text { + color: @theme-color-text-light; + } + + .rowevolution table.metrics td.text span.details { + color: @theme-color-text; + } + + .rowevolution .metric-selectbox select { + color: @theme-color-text; + } }); diff --git a/app/plugins/CoreVisualizations/templates/_dataTableViz_htmlTable.twig b/app/plugins/CoreVisualizations/templates/_dataTableViz_htmlTable.twig index f0b8a8720..677fb52f2 100644 --- a/app/plugins/CoreVisualizations/templates/_dataTableViz_htmlTable.twig +++ b/app/plugins/CoreVisualizations/templates/_dataTableViz_htmlTable.twig @@ -76,11 +76,27 @@ {% if dataTable.getTotalsRow and properties.show_totals_row %} {% set row = dataTable.getTotalsRow %} {% set rowId = 'totalsRow' %} - {% for column in properties.columns_to_display %} {% include "@CoreHome/_dataTableCell.twig" with properties %} + {% if isFilteredTotals %} + {% if column == 'label' %} + {{ 'General_FilteredTotalMatchingFilter'|translate }} + {% elseif filteredTotalsRowContext[column] is defined %} + {% set reportTotal = filteredTotalsRowContext[column].reportTotal|number(2,0) %} + + {%- if filteredTotalsRowContext[column].treatment == additiveTreatment -%} + {{ 'General_FilteredTotalOfReportTotal'|translate(reportTotal) }} + {%- else -%} + {{ 'General_FilteredTotalOverall'|translate(reportTotal) }} + {%- endif -%} + + {% endif %} + {% endif %} {% endfor %} diff --git a/app/plugins/CoreVisualizations/templates/_dataTableViz_htmlTable_comparisons.twig b/app/plugins/CoreVisualizations/templates/_dataTableViz_htmlTable_comparisons.twig index 0ba49ea99..38a7f95ed 100644 --- a/app/plugins/CoreVisualizations/templates/_dataTableViz_htmlTable_comparisons.twig +++ b/app/plugins/CoreVisualizations/templates/_dataTableViz_htmlTable_comparisons.twig @@ -63,6 +63,18 @@ {% set columnChange = row.getColumn(column ~ '_change')|default('+0%') %} {% set comparisonTooltipSuffix = 'General_ComparisonRatioTooltip'|translate(columnChange, row.getMetadata('compareSegmentPretty'), comparedPeriodPretty) %} {% endif %} + {%- set showPercentageValues = properties.show_percentage_values|default(false) + and column in properties.report_ratio_columns|default([]) + and rowComparisonTotals and column in rowComparisonTotals|keys -%} + {%- set rowPercentage = '' -%} + {%- if showPercentageValues -%} + {%- if row.getMetadata(column ~ '_row_percentage') != false -%} + {%- set rowPercentage = row.getMetadata(column ~ '_row_percentage') -%} + {%- elseif columnValue|default(0) is numeric and rowComparisonTotals[column]|default(0) is numeric -%} + {%- set rowPercentage = columnValue|percentage(rowComparisonTotals[column], 1) -%} + {%- endif -%} + {%- set showPercentageValues = rowPercentage is not empty -%} + {%- endif %} {% include "@CoreVisualizations/_dataTableViz_htmlTable_ratio.twig" with { 'changePercentage': columnChange, @@ -73,9 +85,17 @@ 'tooltipSuffix': comparisonTooltipSuffix, 'translations': properties.translations, 'segmentTitlePretty': row.getMetadata('compareSegmentPretty'), - 'periodTitlePretty': row.getMetadata('comparePeriodPretty') + 'periodTitlePretty': row.getMetadata('comparePeriodPretty'), + 'rowPercentage': rowPercentage, + 'showAbsoluteValueOnHover': showPercentageValues } %} - {{ columnValue|default(0)|number(2,0)|rawSafeDecoded }} + + {%- if showPercentageValues -%} + {{- rowPercentage -}} + {%- else -%} + {{- columnValue|default(0)|number(2,0)|rawSafeDecoded -}} + {%- endif -%} + {% endif %} {% endfor %} diff --git a/app/plugins/CoreVisualizations/templates/_dataTableViz_htmlTable_ratio.twig b/app/plugins/CoreVisualizations/templates/_dataTableViz_htmlTable_ratio.twig index 5bae880ad..f8e5aa635 100644 --- a/app/plugins/CoreVisualizations/templates/_dataTableViz_htmlTable_ratio.twig +++ b/app/plugins/CoreVisualizations/templates/_dataTableViz_htmlTable_ratio.twig @@ -37,5 +37,16 @@  {{ rowPercentage }} {% if changePercentage|default is not empty %}({{ changePercentage }}){% endif %} + >  + {%- if showAbsoluteValueOnHover|default -%} + {%- if row.getMetadata('html_column_' ~ column ~ '_prefix') -%} + {{ row.getMetadata('html_column_' ~ column ~ '_prefix') | raw }} + {%- endif -%} + {{- row.getColumn(column)|default(0)|number(2,0)|rawSafeDecoded -}} + {%- if row.getMetadata('html_column_' ~ column ~ '_suffix') -%} + {{ row.getMetadata('html_column_' ~ column ~ '_suffix') | raw }} + {%- endif -%} + {%- else -%} + {{- rowPercentage -}} + {%- endif %} {% if changePercentage|default is not empty %}({{ changePercentage }}){% endif %} {%- endif %} diff --git a/app/plugins/CoreVisualizations/templates/_dataTableViz_sparklines.twig b/app/plugins/CoreVisualizations/templates/_dataTableViz_sparklines.twig index 3855ae3d2..fa18c51ef 100644 --- a/app/plugins/CoreVisualizations/templates/_dataTableViz_sparklines.twig +++ b/app/plugins/CoreVisualizations/templates/_dataTableViz_sparklines.twig @@ -11,7 +11,8 @@ sparklines="{{ sparklines|json_encode|e('html_attr') }}" all-metrics-documentation="{{ allMetricsDocumentation|json_encode|e('html_attr') }}" are-sparklines-linkable="{{ areSparklinesLinkable|json_encode|e('html_attr') }}" - is-widget="{{ isWidget|json_encode|e('html_attr') }}">
    + comparison-mode="{{ sparklinesComparisonMode|json_encode|e('html_attr') }}" + is-widget="{{ (isWidget == 1)|json_encode|e('html_attr') }}">
  • {% else %} {% if not isWidget %}
    diff --git a/app/plugins/CoreVisualizations/templates/macros.twig b/app/plugins/CoreVisualizations/templates/macros.twig index 8336fec40..a96a27e99 100644 --- a/app/plugins/CoreVisualizations/templates/macros.twig +++ b/app/plugins/CoreVisualizations/templates/macros.twig @@ -38,7 +38,8 @@ {% for metric in group %} {% if '%s' in metric.description -%} - {{ metric.description|translate(""~metric.value|number(2)~"")|raw }} + {%- set descriptionParts = metric.description|split('%s', 2) -%} + {{ descriptionParts[0] }}{{ metric.value|number(2) }}{{ descriptionParts[1] }} {%- else %} {{ metric.value|number(2) }} {{ metric.description }} {%- endif %}{% if not loop.last %}, {% endif %} diff --git a/app/plugins/CoreVisualizations/vue/dist/CoreVisualizations.umd.js b/app/plugins/CoreVisualizations/vue/dist/CoreVisualizations.umd.js index 3c1674158..4f3a31d46 100644 --- a/app/plugins/CoreVisualizations/vue/dist/CoreVisualizations.umd.js +++ b/app/plugins/CoreVisualizations/vue/dist/CoreVisualizations.umd.js @@ -308,42 +308,44 @@ EvolutionTrendIconvue_type_script_lang_ts.render = EvolutionTrendIconvue_type_te EvolutionBadgevue_type_script_lang_ts.render = render /* harmony default export */ var EvolutionBadge = (EvolutionBadgevue_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/CoreVisualizations/vue/src/MetricValue/MetricValue.vue?vue&type=template&id=20798b0f +// 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/CoreVisualizations/vue/src/MetricValue/MetricValue.vue?vue&type=template&id=04f47fe6 -const MetricValuevue_type_template_id_20798b0f_hoisted_1 = { +const MetricValuevue_type_template_id_04f47fe6_hoisted_1 = { class: "metricValue" }; -const MetricValuevue_type_template_id_20798b0f_hoisted_2 = ["title"]; -const MetricValuevue_type_template_id_20798b0f_hoisted_3 = { +const MetricValuevue_type_template_id_04f47fe6_hoisted_2 = ["title"]; +const MetricValuevue_type_template_id_04f47fe6_hoisted_3 = { class: "metricValue__primary" }; -const MetricValuevue_type_template_id_20798b0f_hoisted_4 = { - class: "metricValue__number" -}; -const MetricValuevue_type_template_id_20798b0f_hoisted_5 = { - key: 0, +const MetricValuevue_type_template_id_04f47fe6_hoisted_4 = ["title"]; +const MetricValuevue_type_template_id_04f47fe6_hoisted_5 = { + key: 1, class: "metricValue__secondary" }; -const MetricValuevue_type_template_id_20798b0f_hoisted_6 = { - class: "metricValue__secondaryValue" -}; -const MetricValuevue_type_template_id_20798b0f_hoisted_7 = { - key: 0, - class: "metricValue__secondaryLabel" +const MetricValuevue_type_template_id_04f47fe6_hoisted_6 = { + class: "metricValue__secondaryLine" }; -function MetricValuevue_type_template_id_20798b0f_render(_ctx, _cache, $props, $setup, $data, $options) { +function MetricValuevue_type_template_id_04f47fe6_render(_ctx, _cache, $props, $setup, $data, $options) { + var _ctx$displayValue; const _directive_tooltips = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveDirective"])("tooltips"); - return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", MetricValuevue_type_template_id_20798b0f_hoisted_1, [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", { + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", MetricValuevue_type_template_id_04f47fe6_hoisted_1, [_ctx.displayTitle ? 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", { + key: 0, class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(["metricValue__title", { 'metricValue__title--documented': !!_ctx.documentation }]), - title: _ctx.documentation || _ctx.title - }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createTextVNode"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.title), 1)], 10, MetricValuevue_type_template_id_20798b0f_hoisted_2)), [[_directive_tooltips, { + title: _ctx.documentation || _ctx.displayTitle + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createTextVNode"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.displayTitle), 1)], 10, MetricValuevue_type_template_id_04f47fe6_hoisted_2)), [[_directive_tooltips, { + duration: 200, + delay: 200 + }]]) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", MetricValuevue_type_template_id_04f47fe6_hoisted_3, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])((Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", { + class: "metricValue__number", + title: (_ctx$displayValue = _ctx.displayValue) === null || _ctx$displayValue === void 0 ? void 0 : _ctx$displayValue.toString() + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createTextVNode"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.displayValue), 1)], 8, MetricValuevue_type_template_id_04f47fe6_hoisted_4)), [[_directive_tooltips, { duration: 200, delay: 200 - }]]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", MetricValuevue_type_template_id_20798b0f_hoisted_3, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", MetricValuevue_type_template_id_20798b0f_hoisted_4, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.value), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["renderSlot"])(_ctx.$slots, "evolution")]), _ctx.hasSecondary ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", MetricValuevue_type_template_id_20798b0f_hoisted_5, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", MetricValuevue_type_template_id_20798b0f_hoisted_6, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.secondaryValue), 1), _ctx.secondaryLabel ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", MetricValuevue_type_template_id_20798b0f_hoisted_7, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.secondaryLabel), 1)) : 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_["renderSlot"])(_ctx.$slots, "evolution")]), _ctx.hasSecondary ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", MetricValuevue_type_template_id_04f47fe6_hoisted_5, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", MetricValuevue_type_template_id_04f47fe6_hoisted_6, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.displaySecondaryLine), 1)])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)]); } -// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.vue?vue&type=template&id=20798b0f +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.vue?vue&type=template&id=04f47fe6 // EXTERNAL MODULE: external "CoreHome" var external_CoreHome_ = __webpack_require__("19dc"); @@ -357,18 +359,20 @@ var external_CoreHome_ = __webpack_require__("19dc"); Tooltips: external_CoreHome_["Tooltips"] }, props: { + // Optional: the date-comparison card reuses MetricValue for a value column with no title + // (the date label is rendered separately by DateAtom). The title div is skipped when empty. title: { type: String, - required: true + default: '' }, - // Pre-formatted value (e.g. "9,527" or "4min 22s"); rendered verbatim, no formatting here. + // Metric value: a raw number is locale-formatted here; an already-formatted string + // (e.g. "50%" or "4min 22s") is rendered as-is. value: { type: [String, Number], required: true }, - // Optional secondary line. Value and label are kept separate so they can be - // styled independently (e.g. "9,527" darker, "unique visitors" grey). Matomo - // hands these out separately as metric.value + metric.description. + // Optional secondary line: value and label, combined into one string for display. + // Matomo provides these separately as metric.value + metric.description. secondaryValue: [String, Number], secondaryLabel: String, // Optional metric documentation; when set it is shown as the title tooltip (otherwise the @@ -376,9 +380,39 @@ var external_CoreHome_ = __webpack_require__("19dc"); documentation: String }, computed: { + displayTitle() { + return Object(external_CoreHome_["ucfirst"])(this.title, document.documentElement.lang); + }, + displayValue() { + return this.formatValue(this.value); + }, + displaySecondaryValue() { + return this.formatValue(this.secondaryValue); + }, + displaySecondaryLine() { + const value = this.displaySecondaryValue; + const valueText = value === undefined || value === null ? '' : String(value); + const label = this.secondaryLabel; + if (!label) { + return valueText; + } + // Merge the value into the label at its printf `%s` slot so word order holds across locales + // (some put `%s` last), otherwise prepend it. The replace callback keeps a `$` in the value + // (e.g. '$12') from acting as a regex backreference. + if (/%(?:\d+\$)?s/.test(label)) { + return label.replace(/%(?:\d+\$)?s/g, () => valueText); + } + return `${valueText} ${label}`; + }, hasSecondary() { return this.secondaryValue !== undefined && this.secondaryValue !== null && this.secondaryValue !== ''; } + }, + methods: { + // Locale-format raw numbers (plain metrics); leave already-formatted strings untouched. + formatValue(value) { + return typeof value === 'number' ? external_CoreHome_["NumberFormatter"].formatNumber(value, 2) : value; + } } })); // CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.vue?vue&type=script&lang=ts @@ -387,7 +421,7 @@ var external_CoreHome_ = __webpack_require__("19dc"); -MetricValuevue_type_script_lang_ts.render = MetricValuevue_type_template_id_20798b0f_render +MetricValuevue_type_script_lang_ts.render = MetricValuevue_type_template_id_04f47fe6_render /* harmony default export */ var MetricValue = (MetricValuevue_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/CoreVisualizations/vue/src/SeriesPicker/SeriesPicker.vue?vue&type=template&id=7c1adaf7 @@ -745,36 +779,36 @@ MetricsPickerOptionsvue_type_script_lang_ts.render = MetricsPickerOptionsvue_typ MetricsPickervue_type_script_lang_ts.render = MetricsPickervue_type_template_id_5b298d15_render /* harmony default export */ var MetricsPicker = (MetricsPickervue_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/CoreVisualizations/vue/src/SingleMetricView/SingleMetricView.vue?vue&type=template&id=21624034 +// 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/CoreVisualizations/vue/src/SingleMetricView/SingleMetricView.vue?vue&type=template&id=20c744f6 -const SingleMetricViewvue_type_template_id_21624034_hoisted_1 = { +const SingleMetricViewvue_type_template_id_20c744f6_hoisted_1 = { class: "metric-sparkline" }; -const SingleMetricViewvue_type_template_id_21624034_hoisted_2 = { +const SingleMetricViewvue_type_template_id_20c744f6_hoisted_2 = { class: "metric-value" }; -const SingleMetricViewvue_type_template_id_21624034_hoisted_3 = ["title"]; -const SingleMetricViewvue_type_template_id_21624034_hoisted_4 = ["title"]; -function SingleMetricViewvue_type_template_id_21624034_render(_ctx, _cache, $props, $setup, $data, $options) { +const SingleMetricViewvue_type_template_id_20c744f6_hoisted_3 = ["title"]; +const SingleMetricViewvue_type_template_id_20c744f6_hoisted_4 = ["title"]; +function SingleMetricViewvue_type_template_id_20c744f6_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", { 'loading': _ctx.isLoading }]), ref: "root" - }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", SingleMetricViewvue_type_template_id_21624034_hoisted_1, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_Sparkline, { + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", SingleMetricViewvue_type_template_id_20c744f6_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", SingleMetricViewvue_type_template_id_21624034_hoisted_2, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { + }, null, 8, ["params"])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", SingleMetricViewvue_type_template_id_20c744f6_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, SingleMetricViewvue_type_template_id_21624034_hoisted_3), _ctx.pastValue !== null ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", { + }, [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, SingleMetricViewvue_type_template_id_20c744f6_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, SingleMetricViewvue_type_template_id_21624034_hoisted_4)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)])], 2); + }, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.metricChangePercent), 3)], 8, SingleMetricViewvue_type_template_id_20c744f6_hoisted_4)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)])], 2); } -// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/SingleMetricView/SingleMetricView.vue?vue&type=template&id=21624034 +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/SingleMetricView/SingleMetricView.vue?vue&type=template&id=20c744f6 // 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/CoreVisualizations/vue/src/SingleMetricView/SingleMetricView.vue?vue&type=script&lang=ts @@ -940,7 +974,7 @@ const { const goalName = ((_props$goals$actualId = props.goals[actualIdGoal.value]) === null || _props$goals$actualId === void 0 ? void 0 : _props$goals$actualId.name) || Object(external_CoreHome_["translate"])('General_Unknown'); title = `${goalName} - ${title}`; } - $(root.value).closest('div.widget').find('.widgetTop > .widgetName > span').text(title); + $(root.value).closest('div.widget').find('.widgetName > span').text(title); } function getLastPeriodDate() { const range = external_CoreHome_["Range"].getLastNRange(external_CoreHome_["Matomo"].period, 2, external_CoreHome_["Matomo"].currentDateString); @@ -1014,7 +1048,7 @@ const { } function createSeriesPicker() { const element = $(root.value); - const $widgetName = element.closest('div.widget').find('.widgetTop > .widgetName'); + const $widgetName = element.closest('div.widget').find('.widgetName'); const $seriesPickerElem = $('
    '); const app = Object(external_CoreHome_["createVueApp"])({ render: () => Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(SeriesPicker, { @@ -1073,64 +1107,89 @@ const { -SingleMetricViewvue_type_script_lang_ts.render = SingleMetricViewvue_type_template_id_21624034_render +SingleMetricViewvue_type_script_lang_ts.render = SingleMetricViewvue_type_template_id_20c744f6_render /* harmony default export */ var SingleMetricView = (SingleMetricViewvue_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/CoreVisualizations/vue/src/SparklinesGrid/SparklinesGrid.vue?vue&type=template&id=708ec4a6 +// 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/CoreVisualizations/vue/src/SparklinesGrid/SparklinesGrid.vue?vue&type=template&id=15709986 -const SparklinesGridvue_type_template_id_708ec4a6_hoisted_1 = { - class: "row sparklinesGrid" -}; -function SparklinesGridvue_type_template_id_708ec4a6_render(_ctx, _cache, $props, $setup, $data, $options) { +function SparklinesGridvue_type_template_id_15709986_render(_ctx, _cache, $props, $setup, $data, $options) { + const _component_SegmentComparisonCard = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("SegmentComparisonCard"); const _component_SparklineCard = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("SparklineCard"); - return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", SparklinesGridvue_type_template_id_708ec4a6_hoisted_1, [(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.flatSparklines, (sparkline, index) => { + 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"])(_ctx.gridClasses) + }, [_ctx.isSegmentMode ? (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"], { + key: 0 + }, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["renderList"])(_ctx.segmentGroups, (segments, index) => { return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", { key: index, - class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(_ctx.columnClasses) + class: "sparklinesGrid__item" + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_SegmentComparisonCard, { + segments: segments, + "are-sparklines-linkable": _ctx.areSparklinesLinkable, + "all-metrics-documentation": _ctx.allMetricsDocumentation + }, null, 8, ["segments", "are-sparklines-linkable", "all-metrics-documentation"])]); + }), 128)) : (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"], { + key: 1 + }, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["renderList"])(_ctx.flatSparklines, (sparkline, index) => { + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", { + key: index, + class: "sparklinesGrid__item" }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_SparklineCard, { sparkline: sparkline, "are-sparklines-linkable": _ctx.areSparklinesLinkable, "all-metrics-documentation": _ctx.allMetricsDocumentation - }, null, 8, ["sparkline", "are-sparklines-linkable", "all-metrics-documentation"])], 2); - }), 128))]); + }, null, 8, ["sparkline", "are-sparklines-linkable", "all-metrics-documentation"])]); + }), 128))], 2); } -// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/SparklinesGrid/SparklinesGrid.vue?vue&type=template&id=708ec4a6 +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/SparklinesGrid/SparklinesGrid.vue?vue&type=template&id=15709986 -// 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/CoreVisualizations/vue/src/Sparklines/SparklineCard.vue?vue&type=template&id=4bca8c84 +// 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/CoreVisualizations/vue/src/Sparklines/SparklineCard.vue?vue&type=template&id=4308735a -const SparklineCardvue_type_template_id_4bca8c84_hoisted_1 = ["data-graph-params", "data-series-indices"]; -const SparklineCardvue_type_template_id_4bca8c84_hoisted_2 = { +const SparklineCardvue_type_template_id_4308735a_hoisted_1 = ["data-graph-params", "data-series-indices"]; +const SparklineCardvue_type_template_id_4308735a_hoisted_2 = { key: 0, class: "sparklineCard__title" }; -function SparklineCardvue_type_template_id_4bca8c84_render(_ctx, _cache, $props, $setup, $data, $options) { +function SparklineCardvue_type_template_id_4308735a_render(_ctx, _cache, $props, $setup, $data, $options) { + const _component_DateComparison = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("DateComparison"); const _component_NoComparison = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("NoComparison"); + 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"])(["sparkline sparklineCard", { notLinkable: !_ctx.areSparklinesLinkable }]), "data-graph-params": _ctx.graphParamsAttr, "data-series-indices": _ctx.seriesIndicesAttr - }, [_ctx.sparkline.title ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", SparklineCardvue_type_template_id_4bca8c84_hoisted_2, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.sparkline.title), 1)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_NoComparison, { + }, [_ctx.sparkline.title ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", SparklineCardvue_type_template_id_4308735a_hoisted_2, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.sparkline.title), 1)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), _ctx.isComparison ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createBlock"])(_component_DateComparison, { + key: 1, + sparkline: _ctx.sparkline + }, null, 8, ["sparkline"])) : (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createBlock"])(_component_NoComparison, { + key: 2, sparkline: _ctx.sparkline, "all-metrics-documentation": _ctx.allMetricsDocumentation - }, null, 8, ["sparkline", "all-metrics-documentation"])], 10, SparklineCardvue_type_template_id_4bca8c84_hoisted_1); + }, null, 8, ["sparkline", "all-metrics-documentation"])), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", { + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(["sparklineCard__sparkline", { + 'sparklineCard__sparkline--wide': _ctx.isComparison + }]) + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_Sparkline, { + width: _ctx.sparklineWidth, + height: 40, + params: _ctx.sparkline.url, + "series-indices": _ctx.sparkline.seriesIndices + }, null, 8, ["width", "params", "series-indices"])], 2)], 10, SparklineCardvue_type_template_id_4308735a_hoisted_1); } -// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.vue?vue&type=template&id=4bca8c84 +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.vue?vue&type=template&id=4308735a -// 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/CoreVisualizations/vue/src/Sparklines/NoComparison.vue?vue&type=template&id=240ad3ea +// 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/CoreVisualizations/vue/src/Sparklines/NoComparison.vue?vue&type=template&id=44e340e0 -const NoComparisonvue_type_template_id_240ad3ea_hoisted_1 = { - class: "noComparison" -}; -const NoComparisonvue_type_template_id_240ad3ea_hoisted_2 = { - class: "sparklineSlot" +const NoComparisonvue_type_template_id_44e340e0_hoisted_1 = { + class: "sparklineNoComparison" }; -function NoComparisonvue_type_template_id_240ad3ea_render(_ctx, _cache, $props, $setup, $data, $options) { +function NoComparisonvue_type_template_id_44e340e0_render(_ctx, _cache, $props, $setup, $data, $options) { const _component_EvolutionBadge = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("EvolutionBadge"); const _component_MetricValue = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("MetricValue"); - 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", NoComparisonvue_type_template_id_240ad3ea_hoisted_1, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_MetricValue, { + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", NoComparisonvue_type_template_id_44e340e0_hoisted_1, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_MetricValue, { + class: "metricValue--fixedHeight", title: _ctx.title, value: _ctx.primaryValue, "secondary-value": _ctx.secondaryValue, @@ -1147,31 +1206,25 @@ function NoComparisonvue_type_template_id_240ad3ea_render(_ctx, _cache, $props, tooltip: _ctx.sparkline.evolution.tooltip || '' }, null, 8, ["percent", "trend", "is-lower-value-better", "tooltip"])]), key: "0" - } : undefined]), 1032, ["title", "value", "secondary-value", "secondary-label", "documentation"]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", NoComparisonvue_type_template_id_240ad3ea_hoisted_2, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_Sparkline, { - width: 380, - height: 40, - params: _ctx.sparkline.url, - "series-indices": _ctx.sparkline.seriesIndices - }, null, 8, ["params", "series-indices"])])]); + } : undefined]), 1032, ["title", "value", "secondary-value", "secondary-label", "documentation"])]); } -// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/NoComparison.vue?vue&type=template&id=240ad3ea +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/NoComparison.vue?vue&type=template&id=44e340e0 // 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/CoreVisualizations/vue/src/Sparklines/NoComparison.vue?vue&type=script&lang=ts - /** - * No-comparison body for a sparkline card. Composes the MetricValue + EvolutionBadge - * atoms and the reused Sparkline. In no-comparison mode the metrics live under the '' - * group key: the first is the primary value, an optional second is the "unique" line. + * No-comparison body for a sparkline card: the metric readout only (the shell renders the shared + * sparkline below it). Composes the MetricValue + EvolutionBadge atoms. In no-comparison mode the + * metrics live under the '' group key: the first is the primary value, an optional second is the + * "unique" line. */ /* harmony default export */ var NoComparisonvue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ name: 'NoComparison', components: { MetricValue: MetricValue, - EvolutionBadge: EvolutionBadge, - Sparkline: external_CoreHome_["Sparkline"] + EvolutionBadge: EvolutionBadge }, props: { sparkline: { @@ -1206,15 +1259,15 @@ function NoComparisonvue_type_template_id_240ad3ea_render(_ctx, _cache, $props, var _primaryMetric$value$, _primaryMetric$value3; return props.allMetricsDocumentation[(_primaryMetric$value$ = (_primaryMetric$value3 = primaryMetric.value) === null || _primaryMetric$value3 === void 0 ? void 0 : _primaryMetric$value3.column) !== null && _primaryMetric$value$ !== void 0 ? _primaryMetric$value$ : ''] || undefined; }); - // Format raw numbers (plain metrics) but leave already-formatted strings (eg "50%") untouched. - const formatValue = value => typeof value === 'number' ? external_CoreHome_["NumberFormatter"].formatNumber(value, 2) : value; + // Values are passed raw to MetricValue, which locale-formats numbers and renders + // already-formatted strings verbatim. const primaryValue = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { - var _formatValue, _primaryMetric$value4; - return (_formatValue = formatValue((_primaryMetric$value4 = primaryMetric.value) === null || _primaryMetric$value4 === void 0 ? void 0 : _primaryMetric$value4.value)) !== null && _formatValue !== void 0 ? _formatValue : ''; + var _primaryMetric$value$2, _primaryMetric$value4; + return (_primaryMetric$value$2 = (_primaryMetric$value4 = primaryMetric.value) === null || _primaryMetric$value4 === void 0 ? void 0 : _primaryMetric$value4.value) !== null && _primaryMetric$value$2 !== void 0 ? _primaryMetric$value$2 : ''; }); const secondaryValue = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { var _secondaryMetric$valu; - return formatValue((_secondaryMetric$valu = secondaryMetric.value) === null || _secondaryMetric$valu === void 0 ? void 0 : _secondaryMetric$valu.value); + return (_secondaryMetric$valu = secondaryMetric.value) === null || _secondaryMetric$valu === void 0 ? void 0 : _secondaryMetric$valu.value; }); const secondaryLabel = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { var _secondaryMetric$valu2; @@ -1235,22 +1288,287 @@ function NoComparisonvue_type_template_id_240ad3ea_render(_ctx, _cache, $props, -NoComparisonvue_type_script_lang_ts.render = NoComparisonvue_type_template_id_240ad3ea_render +NoComparisonvue_type_script_lang_ts.render = NoComparisonvue_type_template_id_44e340e0_render /* harmony default export */ var NoComparison = (NoComparisonvue_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/CoreVisualizations/vue/src/Sparklines/DateComparison.vue?vue&type=template&id=60a224a0 + +const DateComparisonvue_type_template_id_60a224a0_hoisted_1 = { + class: "sparklineDateComparison" +}; +const DateComparisonvue_type_template_id_60a224a0_hoisted_2 = ["title"]; +function DateComparisonvue_type_template_id_60a224a0_render(_ctx, _cache, $props, $setup, $data, $options) { + const _component_PeriodColumns = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("PeriodColumns"); + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", DateComparisonvue_type_template_id_60a224a0_hoisted_1, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", { + class: "sparklineDateComparison__title", + title: _ctx.metricTitle + }, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.metricTitle), 9, DateComparisonvue_type_template_id_60a224a0_hoisted_2), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_PeriodColumns, { + entry: _ctx.sparkline + }, null, 8, ["entry"])]); +} +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/DateComparison.vue?vue&type=template&id=60a224a0 + +// 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/CoreVisualizations/vue/src/Sparklines/PeriodColumns.vue?vue&type=template&id=eb849e72 + +const PeriodColumnsvue_type_template_id_eb849e72_hoisted_1 = { + class: "periodColumns" +}; +const PeriodColumnsvue_type_template_id_eb849e72_hoisted_2 = { + key: 0, + class: "periodColumns__separator" +}; +const PeriodColumnsvue_type_template_id_eb849e72_hoisted_3 = { + class: "periodColumns__column" +}; +function PeriodColumnsvue_type_template_id_eb849e72_render(_ctx, _cache, $props, $setup, $data, $options) { + const _component_DateAtom = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("DateAtom"); + const _component_EvolutionBadge = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("EvolutionBadge"); + const _component_MetricValue = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("MetricValue"); + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", PeriodColumnsvue_type_template_id_eb849e72_hoisted_1, [(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.periods, (period, index) => { + 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"], { + key: period.label + }, [index > 0 ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", PeriodColumnsvue_type_template_id_eb849e72_hoisted_2)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", PeriodColumnsvue_type_template_id_eb849e72_hoisted_3, [_ctx.showLabels ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createBlock"])(_component_DateAtom, { + key: 0, + label: period.label + }, null, 8, ["label"])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_MetricValue, { + class: "metricValue--noTitle", + value: period.primaryValue, + "secondary-value": period.secondaryValue, + "secondary-label": period.secondaryLabel + }, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createSlots"])({ + _: 2 + }, [period.evolution ? { + name: "evolution", + fn: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withCtx"])(() => [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_EvolutionBadge, { + percent: period.evolution.percent, + trend: period.evolution.trend, + "is-lower-value-better": period.evolution.isLowerValueBetter, + tooltip: period.evolution.tooltip || '' + }, null, 8, ["percent", "trend", "is-lower-value-better", "tooltip"])]), + key: "0" + } : undefined]), 1032, ["value", "secondary-value", "secondary-label"])])], 64); + }), 128))]); +} +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/PeriodColumns.vue?vue&type=template&id=eb849e72 + +// 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/CoreVisualizations/vue/src/Sparklines/DateAtom.vue?vue&type=template&id=c76a1e74 + +const DateAtomvue_type_template_id_c76a1e74_hoisted_1 = ["title"]; +function DateAtomvue_type_template_id_c76a1e74_render(_ctx, _cache, $props, $setup, $data, $options) { + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", { + class: "dateAtom", + title: _ctx.label + }, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.label), 9, DateAtomvue_type_template_id_c76a1e74_hoisted_1); +} +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/DateAtom.vue?vue&type=template&id=c76a1e74 + +// 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/CoreVisualizations/vue/src/Sparklines/DateAtom.vue?vue&type=script&lang=ts + +/** + * A compared date shown above its value column in a date-comparison sparkline card. The label is + * the backend's already-localised pretty period string (eg "Monday, May 4, 2026"); this atom only + * styles it. Kept a separate component so a series-colour indicator can be added later without + * touching DateComparison. + */ +/* harmony default export */ var DateAtomvue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + name: 'DateAtom', + props: { + label: { + type: String, + required: true + } + } +})); +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/DateAtom.vue?vue&type=script&lang=ts + +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/DateAtom.vue + + + +DateAtomvue_type_script_lang_ts.render = DateAtomvue_type_template_id_c76a1e74_render + +/* harmony default export */ var DateAtom = (DateAtomvue_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/CoreVisualizations/vue/src/Sparklines/PeriodColumns.vue?vue&type=script&lang=ts + + + + +/** + * Shared compared-period columns for comparison cards: takes a SparklineEntry and renders one + * column per compared period (a DateAtom label, the MetricValue readout with no title, and an + * EvolutionBadge when the period has evolution), split by dividers. Used by DateComparison (date + * comparison) and SegmentComparisonRow (segment + date). The host owns the outer spacing; this + * derives the columns from the entry's per-period metric groups (metricsOrder order) and lays + * them out. The date label shows only when comparing >1 period — a single column needs no label. + */ +/* harmony default export */ var PeriodColumnsvue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + name: 'PeriodColumns', + components: { + DateAtom: DateAtom, + MetricValue: MetricValue, + EvolutionBadge: EvolutionBadge + }, + props: { + entry: { + type: Object, + required: true + } + }, + setup(props) { + // One column per period, in backend order via `metricsOrder` (not Object.keys, which JS + // re-sorts integer-like year labels). Primary = big value + evolution; optional second = the + // "unique" sub-line. Values pass raw to MetricValue, which locale-formats numbers. + const periods = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + const metrics = props.entry.metrics || {}; + const order = props.entry.metricsOrder || []; + return order.map(label => { + var _primary$value; + const groupMetrics = metrics[label] || []; + const primary = groupMetrics[0]; + const secondary = groupMetrics[1]; + return { + label, + primaryValue: (_primary$value = primary === null || primary === void 0 ? void 0 : primary.value) !== null && _primary$value !== void 0 ? _primary$value : '', + evolution: primary === null || primary === void 0 ? void 0 : primary.evolution, + secondaryValue: secondary === null || secondary === void 0 ? void 0 : secondary.value, + secondaryLabel: secondary === null || secondary === void 0 ? void 0 : secondary.description + }; + }); + }); + // Label the columns only when comparing more than one period (segment-only has one column). + const showLabels = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => periods.value.length > 1); + return { + periods, + showLabels + }; + } +})); +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/PeriodColumns.vue?vue&type=script&lang=ts + +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/PeriodColumns.vue + + + +PeriodColumnsvue_type_script_lang_ts.render = PeriodColumnsvue_type_template_id_eb849e72_render + +/* harmony default export */ var PeriodColumns = (PeriodColumnsvue_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/CoreVisualizations/vue/src/Sparklines/DateComparison.vue?vue&type=script&lang=ts + + + +/** + * Date-comparison body for a sparkline card: metric name as title and one column per compared date + * (the shell renders the shared sparkline below, which draws a coloured series per date). Metrics + * arrive grouped by date label (one column each, in seriesIndices order). Only two-date comparison + * reaches here. The columns themselves are rendered by the shared PeriodColumns component. + */ +/* harmony default export */ var DateComparisonvue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + name: 'DateComparison', + components: { + PeriodColumns: PeriodColumns + }, + props: { + sparkline: { + type: Object, + required: true + } + }, + setup(props) { + // Read the metric name from the first column via metricsOrder, not Object.values, whose order + // JS shuffles for integer-like labels. The name is the same across columns, so this is for + // consistency, not correctness. + const metricTitle = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + var _, _metrics$firstLabel; + const metrics = props.sparkline.metrics || {}; + const firstLabel = (_ = (props.sparkline.metricsOrder || [])[0]) !== null && _ !== void 0 ? _ : Object.keys(metrics)[0]; + const primary = firstLabel !== undefined ? (_metrics$firstLabel = metrics[firstLabel]) === null || _metrics$firstLabel === void 0 ? void 0 : _metrics$firstLabel[0] : undefined; + return Object(external_CoreHome_["ucfirst"])((primary === null || primary === void 0 ? void 0 : primary.title) || (primary === null || primary === void 0 ? void 0 : primary.description), document.documentElement.lang); + }); + return { + metricTitle + }; + } +})); +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/DateComparison.vue?vue&type=script&lang=ts + +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/DateComparison.vue + + + +DateComparisonvue_type_script_lang_ts.render = DateComparisonvue_type_template_id_60a224a0_render + +/* harmony default export */ var DateComparison = (DateComparisonvue_type_script_lang_ts); +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/sparklineDataAttrs.ts +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +/** + * The `data-graph-params` value for a sparkline's `.sparkline` wrapper, or null when none can be + * derived. The legacy click-to-evolution wiring (window.initializeSparklines) reads it to open the + * metric's evolution graph, so it must be set whenever the sparkline is linkable. + * + * Prefers the explicit backend `graphParams`; otherwise derives the reload params (columns/rows/ + * idGoal) from the url — the reused Sparkline renders the image with `src` (no `data-src`), so + * sparkline.js can't read the columns off the img and we supply them here. + * + * Shared by SparklineCard and SegmentComparisonCard, where the whole card is one linkable + * sparkline, so these attributes ride on the card root. + */ +function sparklineGraphParamsAttr(entry) { + const { + graphParams, + url + } = entry; + if (graphParams && Object.keys(graphParams).length) { + return JSON.stringify(graphParams); + } + if (url) { + const parsed = external_CoreHome_["MatomoUrl"].parse(url.substring(url.indexOf('?') + 1)); + const derived = {}; + ['columns', 'rows', 'idGoal'].forEach(key => { + if (parsed[key]) { + derived[key] = parsed[key]; + } + }); + if (Object.keys(derived).length) { + return JSON.stringify(derived); + } + } + return null; +} +/** + * The `data-series-indices` value for a sparkline's `.sparkline` wrapper, or null when the entry + * carries no series indices (no-comparison sparklines). Comparison entries set one index per series + * so the evolution graph highlights the matching coloured line(s). + */ +function sparklineSeriesIndicesAttr(entry) { + const { + seriesIndices + } = entry; + return seriesIndices && seriesIndices.length ? JSON.stringify(seriesIndices) : null; +} // 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/CoreVisualizations/vue/src/Sparklines/SparklineCard.vue?vue&type=script&lang=ts + + /** * Card shell: the frame around a sparkline body. Owns the legacy `.sparkline` wrapper and - * its evolution-graph data attributes; delegates the content to a body component. Only the - * no-comparison body exists today; Phase 3 adds the comparison body behind the same shell. + * its evolution-graph data attributes; delegates the content to a body component. Comparison + * entries carry seriesIndices (one per compared date), so they get the DateComparison body; + * everything else gets the no-comparison body. */ /* harmony default export */ var SparklineCardvue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ name: 'SparklineCard', components: { - NoComparison: NoComparison + NoComparison: NoComparison, + DateComparison: DateComparison, + Sparkline: external_CoreHome_["Sparkline"] }, props: { sparkline: { @@ -1268,64 +1586,241 @@ NoComparisonvue_type_script_lang_ts.render = NoComparisonvue_type_template_id_24 } }, setup(props) { - // The legacy click-to-evolution wiring (window.initializeSparklines) reads these - // attributes off the .sparkline wrapper, so only emit them when populated. + // Comparison entries set seriesIndices (one series per compared date); no-comparison entries + // leave it null. This picks the card body — only two-date comparison reaches the Vue grid. + const isComparison = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + var _props$sparkline$seri; + return !!((_props$sparkline$seri = props.sparkline.seriesIndices) !== null && _props$sparkline$seri !== void 0 && _props$sparkline$seri.length); + }); + // The legacy click-to-evolution wiring (window.initializeSparklines) reads these attributes off + // the .sparkline wrapper. Shared with SegmentComparisonCard, which puts them on the card root. + const graphParamsAttr = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => sparklineGraphParamsAttr(props.sparkline)); + const seriesIndicesAttr = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => sparklineSeriesIndicesAttr(props.sparkline)); + // Displayed sparkline width; comparison cards are wider so their sparkline is too. Kept in sync + // with the .sparklineCard__sparkline max-width in the .less (Sparkline renders the PNG at 2x + // this, and the CSS cap stops it scaling past that crisp source). Height stays 40 for both. + const sparklineWidth = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => isComparison.value ? 760 : 380); + return { + isComparison, + graphParamsAttr, + seriesIndicesAttr, + sparklineWidth + }; + } +})); +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.vue?vue&type=script&lang=ts + +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.vue + + + +SparklineCardvue_type_script_lang_ts.render = SparklineCardvue_type_template_id_4308735a_render + +/* harmony default export */ var SparklineCard = (SparklineCardvue_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/CoreVisualizations/vue/src/Sparklines/SegmentComparisonCard.vue?vue&type=template&id=7f53d9a2 + +const SegmentComparisonCardvue_type_template_id_7f53d9a2_hoisted_1 = ["data-graph-params", "data-series-indices"]; +const SegmentComparisonCardvue_type_template_id_7f53d9a2_hoisted_2 = ["title"]; +const SegmentComparisonCardvue_type_template_id_7f53d9a2_hoisted_3 = { + class: "sparklineSegmentComparisonCard__rows" +}; +function SegmentComparisonCardvue_type_template_id_7f53d9a2_render(_ctx, _cache, $props, $setup, $data, $options) { + const _component_SegmentComparisonRow = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("SegmentComparisonRow"); + const _directive_tooltips = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveDirective"])("tooltips"); + 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"])(["sparkline sparklineSegmentComparisonCard", { + notLinkable: !_ctx.areSparklinesLinkable + }]), + "data-graph-params": _ctx.graphParamsAttr, + "data-series-indices": _ctx.seriesIndicesAttr + }, [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", { + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(["sparklineSegmentComparisonCard__title", { + 'sparklineSegmentComparisonCard__title--documented': !!_ctx.documentation + }]), + title: _ctx.documentation || _ctx.metricTitle + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createTextVNode"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.metricTitle), 1)], 10, SegmentComparisonCardvue_type_template_id_7f53d9a2_hoisted_2)), [[_directive_tooltips, { + duration: 200, + delay: 200 + }]]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", SegmentComparisonCardvue_type_template_id_7f53d9a2_hoisted_3, [(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.segments, (segment, index) => { + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createBlock"])(_component_SegmentComparisonRow, { + key: index, + segment: segment + }, null, 8, ["segment"]); + }), 128))])], 10, SegmentComparisonCardvue_type_template_id_7f53d9a2_hoisted_1); +} +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonCard.vue?vue&type=template&id=7f53d9a2 + +// 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/CoreVisualizations/vue/src/Sparklines/SegmentComparisonRow.vue?vue&type=template&id=fd8e0dde + +const SegmentComparisonRowvue_type_template_id_fd8e0dde_hoisted_1 = { + class: "sparklineSegmentComparisonRow" +}; +const SegmentComparisonRowvue_type_template_id_fd8e0dde_hoisted_2 = ["title"]; +function SegmentComparisonRowvue_type_template_id_fd8e0dde_render(_ctx, _cache, $props, $setup, $data, $options) { + const _component_PeriodColumns = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("PeriodColumns"); + 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", SegmentComparisonRowvue_type_template_id_fd8e0dde_hoisted_1, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { + class: "sparklineSegmentComparisonRow__chip", + title: _ctx.segmentLabel + }, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.segmentLabel), 9, SegmentComparisonRowvue_type_template_id_fd8e0dde_hoisted_2), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_PeriodColumns, { + entry: _ctx.segment + }, null, 8, ["entry"]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", { + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(["sparklineSegmentComparisonRow__sparkline", { + 'sparklineSegmentComparisonRow__sparkline--wide': _ctx.isMultiPeriod + }]) + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_Sparkline, { + width: _ctx.sparklineWidth, + height: 40, + params: _ctx.segment.url, + "series-indices": _ctx.segment.seriesIndices + }, null, 8, ["width", "params", "series-indices"])], 2)]); +} +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonRow.vue?vue&type=template&id=fd8e0dde + +// 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/CoreVisualizations/vue/src/Sparklines/SegmentComparisonRow.vue?vue&type=script&lang=ts + + + +/** + * One compared segment inside a segment-comparison card: a presentational block with a segment-name + * chip, one value column per compared date (a bare value for segment-only, or a labelled column + * with an evolution badge per date for segment + date, rendered by the shared PeriodColumns), and + * its own single- or multi-series sparkline. The row is not itself a link — the whole card is the + * single `.sparkline` click-to-evolution unit (SegmentComparisonCard); every segment reloads the + * same evolution graph. + */ +/* harmony default export */ var SegmentComparisonRowvue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + name: 'SegmentComparisonRow', + components: { + PeriodColumns: PeriodColumns, + Sparkline: external_CoreHome_["Sparkline"] + }, + props: { + segment: { + type: Object, + required: true + } + }, + setup(props) { + // Segment name (compareSegmentPretty); always populated in segment comparison. + const segmentLabel = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => props.segment.title || ''); + // More than one compared date (segment + date) → widen the sparkline. The period columns + // themselves are derived and rendered by PeriodColumns from the same entry. + const isMultiPeriod = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => (props.segment.metricsOrder || []).length > 1); + // Displayed sparkline width; segment + date rows draw one series per date so they are wider, + // matching the date-comparison card. Kept in sync with the `--wide` max-width in the .less + // (Sparkline renders the PNG at 2x this; the CSS cap stops it scaling past that crisp source). + const sparklineWidth = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => isMultiPeriod.value ? 760 : 380); + return { + segmentLabel, + isMultiPeriod, + sparklineWidth + }; + } +})); +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonRow.vue?vue&type=script&lang=ts + +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonRow.vue + + + +SegmentComparisonRowvue_type_script_lang_ts.render = SegmentComparisonRowvue_type_template_id_fd8e0dde_render + +/* harmony default export */ var SegmentComparisonRow = (SegmentComparisonRowvue_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/CoreVisualizations/vue/src/Sparklines/SegmentComparisonCard.vue?vue&type=script&lang=ts + + + + +/** + * Segment-comparison card: one card per metric, showing the metric name once and a stacked block + * per compared segment (SegmentComparisonRow). The whole card is a single `.sparkline` + * click-to-evolution link — every segment reloads the same evolution graph (same metric columns, + * segments plotted as its series), so the card, not each row, is the clickable/hover unit. Rows are + * presentational. + */ +/* harmony default export */ var SegmentComparisonCardvue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + name: 'SegmentComparisonCard', + directives: { + Tooltips: external_CoreHome_["Tooltips"] + }, + components: { + SegmentComparisonRow: SegmentComparisonRow + }, + props: { + // One entry per compared segment for this metric (same metric, different segment). + segments: { + type: Array, + required: true + }, + areSparklinesLinkable: { + type: Boolean, + default: true + }, + // Backend map of metric column -> documentation string, for the card-title tooltip. + allMetricsDocumentation: { + type: Object, + default: () => ({}) + } + }, + setup(props) { + // Same metric across segments; read its name + column from the first segment's first period + // group. In segment-only comparison the column is populated so the doc tooltip resolves; in + // segment + date it comes back empty (two periods, so Config::addSparkline can't map columns), + // so no tooltip shows — correct parity with date comparison, not a regression. + const primaryMetric = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + var _, _metrics$label; + const first = props.segments[0]; + const metrics = (first === null || first === void 0 ? void 0 : first.metrics) || {}; + const label = (_ = ((first === null || first === void 0 ? void 0 : first.metricsOrder) || [])[0]) !== null && _ !== void 0 ? _ : Object.keys(metrics)[0]; + return label !== undefined ? (_metrics$label = metrics[label]) === null || _metrics$label === void 0 ? void 0 : _metrics$label[0] : undefined; + }); + const metricTitle = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + var _primaryMetric$value, _primaryMetric$value2; + const label = ((_primaryMetric$value = primaryMetric.value) === null || _primaryMetric$value === void 0 ? void 0 : _primaryMetric$value.title) || ((_primaryMetric$value2 = primaryMetric.value) === null || _primaryMetric$value2 === void 0 ? void 0 : _primaryMetric$value2.description); + return Object(external_CoreHome_["ucfirst"])(label, document.documentElement.lang); + }); + const documentation = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + var _primaryMetric$value$, _primaryMetric$value3; + return props.allMetricsDocumentation[(_primaryMetric$value$ = (_primaryMetric$value3 = primaryMetric.value) === null || _primaryMetric$value3 === void 0 ? void 0 : _primaryMetric$value3.column) !== null && _primaryMetric$value$ !== void 0 ? _primaryMetric$value$ : ''] || undefined; + }); + // The whole card is one click-to-evolution link (window.initializeSparklines reads these off + // the `.sparkline` root). Segments share the metric's columns, so derive reload params from the + // first segment; series indices are the union of the card's segments. const graphParamsAttr = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { - const { - graphParams, - url - } = props.sparkline; - // Prefer explicit backend graphParams (set for comparison/segment sparklines). - if (graphParams && Object.keys(graphParams).length) { - return JSON.stringify(graphParams); - } - // Otherwise derive the evolution-graph reload params from the sparkline url. The reused - // CoreHome Sparkline renders the image with `src` (no `data-src`), so the click handler's - // own url fallback (sparkline.js) can't read the columns off the img — we supply them here - // so data-graph-params is always populated. Mirrors the columns/rows/idGoal the legacy - // fallback would have parsed. - if (url) { - const parsed = external_CoreHome_["MatomoUrl"].parse(url.substring(url.indexOf('?') + 1)); - const derived = {}; - ['columns', 'rows', 'idGoal'].forEach(key => { - if (parsed[key]) { - derived[key] = parsed[key]; - } - }); - if (Object.keys(derived).length) { - return JSON.stringify(derived); - } - } - return null; + const first = props.segments[0]; + return first ? sparklineGraphParamsAttr(first) : null; }); const seriesIndicesAttr = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { - const { - seriesIndices - } = props.sparkline; - return seriesIndices && seriesIndices.length ? JSON.stringify(seriesIndices) : null; + const indices = props.segments.flatMap(segment => segment.seriesIndices || []); + return indices.length ? JSON.stringify(indices) : null; }); return { + metricTitle, + documentation, graphParamsAttr, seriesIndicesAttr }; } })); -// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.vue?vue&type=script&lang=ts +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonCard.vue?vue&type=script&lang=ts -// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.vue +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonCard.vue -SparklineCardvue_type_script_lang_ts.render = SparklineCardvue_type_template_id_4bca8c84_render +SegmentComparisonCardvue_type_script_lang_ts.render = SegmentComparisonCardvue_type_template_id_7f53d9a2_render -/* harmony default export */ var SparklineCard = (SparklineCardvue_type_script_lang_ts); +/* harmony default export */ var SegmentComparisonCard = (SegmentComparisonCardvue_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/CoreVisualizations/vue/src/SparklinesGrid/SparklinesGrid.vue?vue&type=script&lang=ts + /* harmony default export */ var SparklinesGridvue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ name: 'SparklinesGrid', components: { - SparklineCard: SparklineCard + SparklineCard: SparklineCard, + SegmentComparisonCard: SegmentComparisonCard }, props: { sparklines: { @@ -1345,27 +1840,54 @@ SparklineCardvue_type_script_lang_ts.render = SparklineCardvue_type_template_id_ isWidget: { type: Boolean, default: false + }, + // Comparison layout from the backend: 'none', 'date', 'segment' or 'segmentDate'. Date and + // segment+date cards are wider (value columns + a full-width sparkline) so use a lower density; + // segment / segment+date group a metric's per-segment entries into one taller card. + comparisonMode: { + type: String, + default: 'none' } }, setup(props) { + // Both segment modes render one SegmentComparisonCard per metric group (a row per segment); + // they differ only in how many date columns each row shows and in card width (isWideLayout). + const isSegmentMode = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => props.comparisonMode === 'segment' || props.comparisonMode === 'segmentDate'); // `order` is the backend's source of truth for display order: a total order across // all cards (even comparison metrics/segments). Flatten every group and sort by it. // Drop placeholders (Config::addPlaceholder()): no url, they only padded the legacy - // 2-column layout and would render as empty cards here. + // 2-column layout and would render as empty cards here. Used by 'none' and 'date'. const flatSparklines = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => [].concat(...Object.values(props.sparklines || {})).filter(sparkline => !!sparkline.url).sort((a, b) => a.order - b.order)); - // Widgets show two columns; reporting pages use a responsive grid (2/3/4/5 cols). - // Keep xl3 so SparklinesGrid.less can widen it to 5 cols above 1920px. - const columnClasses = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => props.isWidget ? 'col s6' : 'col s6 m6 l4 xl3'); + // Segment (and segment + date) comparison emits one entry per (metric x segment), grouped by + // metric in `sparklines`. One card per group (stacking per-segment rows); drop placeholders + // (no url) and order groups by their lowest entry `order`. + const segmentGroups = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => Object.values(props.sparklines || {}).map(group => group.filter(sparkline => !!sparkline.url)).filter(group => group.length > 0).sort((a, b) => Math.min(...a.map(s => s.order)) - Math.min(...b.map(s => s.order)))); + // date / segment+date cards are wider (value columns + a full-width sparkline), so the grid + // gives them a lower density. (isSegmentMode = segment || segmentDate is a different split.) + const isWideLayout = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => props.comparisonMode === 'date' || props.comparisonMode === 'segmentDate'); + // Container classes drive the CSS grid (see the .less). Every layout reflows via auto-fill. + // Standard cards use --compact (a denser 200px minimum) in a widget; the wide comparison + // layouts (date / segment+date) use --wide (350px) on reporting pages and widgets alike. + // --framed layers the widget frame (tighter gutter, even padding) over the column modifier. + const gridClasses = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => ({ + sparklinesGrid: true, + 'sparklinesGrid--wide': isWideLayout.value, + 'sparklinesGrid--framed': props.isWidget, + 'sparklinesGrid--compact': props.isWidget && !isWideLayout.value + })); Object(external_commonjs_vue_commonjs2_vue_root_Vue_["onMounted"])(() => { - // Re-wire each sparkline to its evolution graph once the cards are in the DOM. - // Safe to re-run (it unbinds first); CoreHome ships sparkline.js in the global JS bundle. + // Wire each sparkline to its evolution graph once the cards are in the DOM (per-segment row + // in segment mode, per card otherwise). Safe to re-run (it unbinds first); CoreHome's + // sparkline.js is in the global JS bundle. Object(external_commonjs_vue_commonjs2_vue_root_Vue_["nextTick"])(() => { window.initializeSparklines(); }); }); return { + isSegmentMode, flatSparklines, - columnClasses + segmentGroups, + gridClasses }; } })); @@ -1375,7 +1897,7 @@ SparklineCardvue_type_script_lang_ts.render = SparklineCardvue_type_template_id_ -SparklinesGridvue_type_script_lang_ts.render = SparklinesGridvue_type_template_id_708ec4a6_render +SparklinesGridvue_type_script_lang_ts.render = SparklinesGridvue_type_template_id_15709986_render /* harmony default export */ var SparklinesGrid = (SparklinesGridvue_type_script_lang_ts); // CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/index.ts diff --git a/app/plugins/CoreVisualizations/vue/dist/CoreVisualizations.umd.min.js b/app/plugins/CoreVisualizations/vue/dist/CoreVisualizations.umd.min.js index 6dfa6f32e..b408fb046 100644 --- a/app/plugins/CoreVisualizations/vue/dist/CoreVisualizations.umd.min.js +++ b/app/plugins/CoreVisualizations/vue/dist/CoreVisualizations.umd.min.js @@ -1,4 +1,10 @@ -(function(e,t){"object"===typeof exports&&"object"===typeof module?module.exports=t(require("CoreHome"),require("vue")):"function"===typeof define&&define.amd?define(["CoreHome"],t):"object"===typeof exports?exports["CoreVisualizations"]=t(require("CoreHome"),require("vue")):e["CoreVisualizations"]=t(e["CoreHome"],e["Vue"])})("undefined"!==typeof self?self:this,(function(e,t){return function(e){var t={};function n(o){if(t[o])return t[o].exports;var l=t[o]={i:o,l:!1,exports:{}};return e[o].call(l.exports,l,l.exports,n),l.l=!0,l.exports}return n.m=e,n.c=t,n.d=function(e,t,o){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:o})},n.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"===typeof e&&e&&e.__esModule)return e;var o=Object.create(null);if(n.r(o),Object.defineProperty(o,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var l in e)n.d(o,l,function(t){return e[t]}.bind(null,l));return o},n.n=function(e){var t=e&&e.__esModule?function(){return e["default"]}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="plugins/CoreVisualizations/vue/dist/",n(n.s="fae3")}({"19dc":function(t,n){t.exports=e},"8bbf":function(e,n){e.exports=t},fae3:function(e,t,n){"use strict";if(n.r(t),n.d(t,"EvolutionBadge",(function(){return S})),n.d(t,"MetricValue",(function(){return P})),n.d(t,"SeriesPicker",(function(){return J})),n.d(t,"MetricsPicker",(function(){return be})),n.d(t,"SingleMetricView",(function(){return Se})),n.d(t,"SparklinesGrid",(function(){return Re})),"undefined"!==typeof window){var o=window.document.currentScript,l=o&&o.src.match(/(.+\/)[^/]+\.js(\?.*)?$/);l&&(n.p=l[1])}var r=n("8bbf");const c=["title"],a={class:"evolutionBadge__icon","aria-hidden":"true"},i={class:"evolutionBadge__value"};function s(e,t,n,o,l,s){const u=Object(r["resolveComponent"])("EvolutionTrendIcon");return Object(r["openBlock"])(),Object(r["createElementBlock"])("span",{class:Object(r["normalizeClass"])(["evolutionBadge",e.directionClass]),title:e.tooltip||void 0},[Object(r["createElementVNode"])("span",a,[Object(r["createVNode"])(u,{class:"evolutionTrendIcon",direction:e.direction},null,8,["direction"])]),Object(r["createElementVNode"])("span",i,Object(r["toDisplayString"])(e.formattedPercent),1)],10,c)}const u={key:0,viewBox:"0 0 16 16"},d=Object(r["createElementVNode"])("path",{d:"M3.77344 11L8.27344 5L12.7734 11H3.77344Z",fill:"currentColor"},null,-1),p=[d],m={key:1,viewBox:"0 0 16 16"},b=Object(r["createElementVNode"])("path",{d:"M3.77344 6L8.27344 12L12.7734 6H3.77344Z",fill:"currentColor"},null,-1),v=[b],O={key:2,viewBox:"0 0 16 16"},j=Object(r["createElementVNode"])("rect",{x:"3",y:"7",width:"10",height:"2",fill:"currentColor"},null,-1),f=[j];function k(e,t,n,o,l,c){return"up"===e.direction?(Object(r["openBlock"])(),Object(r["createElementBlock"])("svg",u,p)):"down"===e.direction?(Object(r["openBlock"])(),Object(r["createElementBlock"])("svg",m,v)):(Object(r["openBlock"])(),Object(r["createElementBlock"])("svg",O,f))}var g=Object(r["defineComponent"])({name:"EvolutionTrendIcon",props:{direction:{type:String,required:!0,validator:e=>-1!==["up","down","neutral"].indexOf(e)}}});g.render=k;var y=g,h=Object(r["defineComponent"])({name:"EvolutionBadge",components:{EvolutionTrendIcon:y},props:{percent:{type:[Number,String],required:!0},isLowerValueBetter:{type:Boolean,default:!1},trend:{type:Number,default:void 0},tooltip:{type:String,default:""}},setup(e){const t=Object(r["computed"])(()=>{if("number"===typeof e.trend&&!Number.isNaN(e.trend))return e.trend;const t=parseFloat(String(e.percent).replace("−","-").replace(",",".").replace(/[^0-9.+-]/g,""));return Number.isNaN(t)?0:t}),n=Object(r["computed"])(()=>t.value>0?"up":t.value<0?"down":"neutral"),o=Object(r["computed"])(()=>{if("neutral"===n.value)return"evolutionBadge--neutral";const t="up"===n.value,o=e.isLowerValueBetter?!t:t;return o?"evolutionBadge--positive":"evolutionBadge--negative"}),l=Object(r["computed"])(()=>{const n="number"===typeof e.percent?e.percent+"%":String(e.percent).trim(),o=n.charAt(0);return t.value>0&&"+"!==o&&"-"!==o?"+"+n:n});return{direction:n,directionClass:o,formattedPercent:l}}});h.render=s;var S=h;const C={class:"metricValue"},w=["title"],V={class:"metricValue__primary"},B={class:"metricValue__number"},E={key:0,class:"metricValue__secondary"},N={class:"metricValue__secondaryValue"},_={key:0,class:"metricValue__secondaryLabel"};function M(e,t,n,o,l,c){const a=Object(r["resolveDirective"])("tooltips");return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",C,[Object(r["withDirectives"])((Object(r["openBlock"])(),Object(r["createElementBlock"])("div",{class:Object(r["normalizeClass"])(["metricValue__title",{"metricValue__title--documented":!!e.documentation}]),title:e.documentation||e.title},[Object(r["createTextVNode"])(Object(r["toDisplayString"])(e.title),1)],10,w)),[[a,{duration:200,delay:200}]]),Object(r["createElementVNode"])("div",V,[Object(r["createElementVNode"])("span",B,Object(r["toDisplayString"])(e.value),1),Object(r["renderSlot"])(e.$slots,"evolution")]),e.hasSecondary?(Object(r["openBlock"])(),Object(r["createElementBlock"])("div",E,[Object(r["createElementVNode"])("span",N,Object(r["toDisplayString"])(e.secondaryValue),1),e.secondaryLabel?(Object(r["openBlock"])(),Object(r["createElementBlock"])("span",_,Object(r["toDisplayString"])(e.secondaryLabel),1)):Object(r["createCommentVNode"])("",!0)])):Object(r["createCommentVNode"])("",!0)])}var D=n("19dc"),x=Object(r["defineComponent"])({name:"MetricValue",directives:{Tooltips:D["Tooltips"]},props:{title:{type:String,required:!0},value:{type:[String,Number],required:!0},secondaryValue:[String,Number],secondaryLabel:String,documentation:String},computed:{hasSecondary(){return void 0!==this.secondaryValue&&null!==this.secondaryValue&&""!==this.secondaryValue}}});x.render=M;var P=x;const L={key:0,class:"jqplot-seriespicker-popover"},A={class:"headline"},R=["onClick"],T=["type","checked"],$={key:0,class:"headline recordsToPlot"},G=["onClick"],q=["type","checked"];function I(e,t,n,o,l,c){return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",{class:Object(r["normalizeClass"])(["jqplot-seriespicker",{open:e.isPopupVisible}]),onMouseenter:t[1]||(t[1]=t=>e.isPopupVisible=!0),onMouseleave:t[2]||(t[2]=t=>e.onLeavePopup())},[Object(r["createElementVNode"])("a",{href:"#",onClick:t[0]||(t[0]=Object(r["withModifiers"])(()=>{},["prevent","stop"]))}," + "),e.isPopupVisible?(Object(r["openBlock"])(),Object(r["createElementBlock"])("div",L,[Object(r["createElementVNode"])("p",A,Object(r["toDisplayString"])(e.translate(e.multiselect?"General_MetricsToPlot":"General_MetricToPlot")),1),(Object(r["openBlock"])(!0),Object(r["createElementBlock"])(r["Fragment"],null,Object(r["renderList"])(e.selectableColumns,t=>(Object(r["openBlock"])(),Object(r["createElementBlock"])("p",{class:"pickColumn",onClick:n=>e.optionSelected(t.column,e.columnStates),key:t.column},[Object(r["createElementVNode"])("label",null,[Object(r["createElementVNode"])("input",{class:"select",type:e.multiselect?"checkbox":"radio",checked:!!e.columnStates[t.column]},null,8,T),Object(r["createElementVNode"])("span",null,Object(r["toDisplayString"])(t.translation),1)])],8,R))),128)),e.selectableRows.length?(Object(r["openBlock"])(),Object(r["createElementBlock"])("p",$,Object(r["toDisplayString"])(e.translate("General_RecordsToPlot")),1)):Object(r["createCommentVNode"])("",!0),(Object(r["openBlock"])(!0),Object(r["createElementBlock"])(r["Fragment"],null,Object(r["renderList"])(e.selectableRows,t=>(Object(r["openBlock"])(),Object(r["createElementBlock"])("p",{class:"pickRow",onClick:n=>e.optionSelected(t.matcher,e.rowStates),key:t.matcher},[Object(r["createElementVNode"])("label",null,[Object(r["createElementVNode"])("input",{class:"select",type:e.multiselect?"checkbox":"radio",checked:!!e.rowStates[t.matcher]},null,8,q),Object(r["createElementVNode"])("span",null,Object(r["toDisplayString"])(t.label),1)])],8,G))),128))])):Object(r["createCommentVNode"])("",!0)],34)}function z(e,t){const n={};return e.forEach(e=>{const t=e.column||e.matcher;n[t]=!1}),t.forEach(e=>{n[e]=!0}),n}function F(e,t){return e.length===t.length&&0===e.filter(e=>-1===t.indexOf(e)).length}function H(e){Object.keys(e).forEach(t=>{e[t]=!1})}function K(e){return Object.keys(e).filter(t=>!!e[t])}var U=Object(r["defineComponent"])({props:{multiselect:Boolean,selectableColumns:{type:Array,default:()=>[]},selectableRows:{type:Array,default:()=>[]},selectedColumns:{type:Array,default:()=>[]},selectedRows:{type:Array,default:()=>[]}},data(){return{isPopupVisible:!1,columnStates:z(this.selectableColumns,this.selectedColumns),rowStates:z(this.selectableRows,this.selectedRows)}},emits:["select"],created(){this.optionSelected=Object(D["debounce"])(this.optionSelected,0)},methods:{optionSelected(e,t){this.multiselect||(H(this.columnStates),H(this.rowStates)),t[e]=!t[e],this.triggerOnSelectAndClose()},onLeavePopup(){this.isPopupVisible=!1,this.optionsChanged()&&this.triggerOnSelectAndClose()},triggerOnSelectAndClose(){this.isPopupVisible=!1,this.$emit("select",{columns:K(this.columnStates),rows:K(this.rowStates)})},optionsChanged(){return!F(K(this.columnStates),this.selectedColumns)||!F(K(this.rowStates),this.selectedRows)}}});U.render=I;var J=U;const W={ref:"root",class:"metrics-picker"},Z={ref:"expander",type:"button",class:"metrics-picker__toggle"},Q={class:"metrics-picker__toggle-label"},X=Object(r["createElementVNode"])("span",{class:"icon-chevron-down metrics-picker__chevron"},null,-1),Y={class:"metrics-picker__dropdown"};function ee(e,t,n,o,l,c){const a=Object(r["resolveComponent"])("MetricsPickerOptions"),i=Object(r["resolveDirective"])("expand-on-click");return Object(r["withDirectives"])((Object(r["openBlock"])(),Object(r["createElementBlock"])("div",W,[Object(r["createElementVNode"])("button",Z,[Object(r["createElementVNode"])("span",Q,Object(r["toDisplayString"])(e.translate("General_ChooseMetrics")),1),X],512),Object(r["createElementVNode"])("div",Y,[Object(r["createVNode"])(a,{multiselect:e.multiselect,"selectable-columns":e.selectableColumns,"selectable-rows":e.selectableRows,"selected-columns":e.selectedColumns,"selected-rows":e.selectedRows,onSelect:t[0]||(t[0]=t=>e.onSelect(t))},null,8,["multiselect","selectable-columns","selectable-rows","selected-columns","selected-rows"])])])),[[i,{expander:"expander"}]])}const te=["role","aria-label"],ne=["type","checked","onChange","onKeydown"],oe=Object(r["createElementVNode"])("span",{"aria-hidden":"true"},null,-1),le={class:"metrics-picker__title"},re={key:0,class:"metrics-picker__headline"},ce=["type","checked","onChange","onKeydown"],ae=Object(r["createElementVNode"])("span",{"aria-hidden":"true"},null,-1),ie={class:"metrics-picker__title"};function se(e,t,n,o,l,c){return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",{class:"metrics-picker__options",role:e.multiselect?"group":"radiogroup","aria-label":e.translate("General_ChooseMetrics")},[(Object(r["openBlock"])(!0),Object(r["createElementBlock"])(r["Fragment"],null,Object(r["renderList"])(e.selectableColumns,t=>(Object(r["openBlock"])(),Object(r["createElementBlock"])("label",{class:"metrics-picker__column metrics-picker__label",key:t.column},[Object(r["createElementVNode"])("input",{class:"filled-in",type:e.multiselect?"checkbox":"radio",checked:!!e.columnStates[t.column],onChange:n=>e.optionSelected(t.column,e.columnStates),onKeydown:Object(r["withKeys"])(Object(r["withModifiers"])(n=>e.optionSelected(t.column,e.columnStates),["prevent"]),["enter"])},null,40,ne),oe,Object(r["createElementVNode"])("span",le,Object(r["toDisplayString"])(t.translation),1)]))),128)),e.selectableRows.length?(Object(r["openBlock"])(),Object(r["createElementBlock"])("p",re,Object(r["toDisplayString"])(e.translate("General_RecordsToPlot")),1)):Object(r["createCommentVNode"])("",!0),(Object(r["openBlock"])(!0),Object(r["createElementBlock"])(r["Fragment"],null,Object(r["renderList"])(e.selectableRows,t=>(Object(r["openBlock"])(),Object(r["createElementBlock"])("label",{class:"metrics-picker__row metrics-picker__label",key:t.matcher},[Object(r["createElementVNode"])("input",{class:"filled-in",type:e.multiselect?"checkbox":"radio",checked:!!e.rowStates[t.matcher],onChange:n=>e.optionSelected(t.matcher,e.rowStates),onKeydown:Object(r["withKeys"])(Object(r["withModifiers"])(n=>e.optionSelected(t.matcher,e.rowStates),["prevent"]),["enter"])},null,40,ce),ae,Object(r["createElementVNode"])("span",ie,Object(r["toDisplayString"])(t.label),1)]))),128))],8,te)}function ue(e,t){const n={};return e.forEach(e=>{const t=e.column||e.matcher;n[t]=!1}),t.forEach(e=>{n[e]=!0}),n}var de=Object(r["defineComponent"])({props:{multiselect:Boolean,selectableColumns:{type:Array,default:()=>[]},selectableRows:{type:Array,default:()=>[]},selectedColumns:{type:Array,default:()=>[]},selectedRows:{type:Array,default:()=>[]}},data(){return{columnStates:ue(this.selectableColumns,this.selectedColumns),rowStates:ue(this.selectableRows,this.selectedRows)}},emits:["select"],methods:{unselectOptions(e){Object.keys(e).forEach(t=>{e[t]=!1})},getSelected(e){return Object.keys(e).filter(t=>!!e[t])},optionSelected(e,t){this.multiselect||(this.unselectOptions(this.columnStates),this.unselectOptions(this.rowStates)),t[e]=!t[e],this.$emit("select",{columns:this.getSelected(this.columnStates),rows:this.getSelected(this.rowStates)})}}});de.render=se;var pe=de,me=Object(r["defineComponent"])({props:{multiselect:Boolean,selectableColumns:{type:Array,default:()=>[]},selectableRows:{type:Array,default:()=>[]},selectedColumns:{type:Array,default:()=>[]},selectedRows:{type:Array,default:()=>[]}},components:{MetricsPickerOptions:pe},directives:{ExpandOnClick:D["ExpandOnClick"]},emits:["select"],methods:{onSelect(e){this.$emit("select",e),this.$refs.root.classList.remove("expanded")}}});me.render=ee;var be=me;const ve={class:"metric-sparkline"},Oe={class:"metric-value"},je=["title"],fe=["title"];function ke(e,t,n,o,l,c){const a=Object(r["resolveComponent"])("Sparkline");return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",{class:Object(r["normalizeClass"])(["singleMetricView",{loading:e.isLoading}]),ref:"root"},[Object(r["createElementVNode"])("div",ve,[Object(r["createVNode"])(a,{params:e.sparklineParams},null,8,["params"])]),Object(r["createElementVNode"])("div",Oe,[Object(r["createElementVNode"])("span",{title:e.metricDocumentation},[Object(r["createElementVNode"])("strong",null,Object(r["toDisplayString"])(e.metricValue),1),Object(r["createTextVNode"])(" "+Object(r["toDisplayString"])((e.metricTranslation||"").toLowerCase()),1)],8,je),null!==e.pastValue?(Object(r["openBlock"])(),Object(r["createElementBlock"])("span",{key:0,class:"metricEvolution",title:e.translate("General_EvolutionSummaryGeneric",e.metricValue,e.currentPeriod,e.pastValue,e.pastPeriod,e.metricChangePercent)},[Object(r["createElementVNode"])("span",{class:Object(r["normalizeClass"])(e.evolutionClass)},Object(r["toDisplayString"])(e.metricChangePercent),3)],8,fe)):Object(r["createCommentVNode"])("",!0)])],2)}function ge(){const{startDate:e}=D["Range"].getLastNRange(D["Matomo"].period,2,D["Matomo"].currentDateString),t=D["Periods"].get(D["Matomo"].period).parse(e).getDateRange();return`${Object(D["format"])(t[0])},${Object(D["format"])(t[1])}`}const{$:ye}=window;var he=Object(r["defineComponent"])({props:{metric:{type:String,required:!0},idGoal:[String,Number],metricTranslations:{type:Object,required:!0},metricDocumentations:Object,goals:{type:Object,required:!0},goalMetrics:Array,lowerIsBetterMetrics:{type:Array,default:()=>[]}},components:{Sparkline:D["Sparkline"]},setup(e){const t=Object(r["ref"])(null),n=Object(r["ref"])(!1),o=Object(r["ref"])(null),l=Object(r["ref"])(e.metric),c=Object(r["ref"])(e.idGoal),a=Object(r["computed"])(()=>[c.value?`goal${c.value}_${l.value}`:l.value]),i=Object(r["computed"])(()=>{var e;return null!==(e=o.value)&&void 0!==e&&e[1]?o.value[1][l.value]||0:null}),s=Object(r["computed"])(()=>{var e;return null!==(e=o.value)&&void 0!==e&&e[2]?o.value[2][l.value]||0:null}),u=Object(r["computed"])(()=>-1!==e.lowerIsBetterMetrics.indexOf(l.value)),d=Object(r["computed"])(()=>{if(null===i.value||null===s.value||i.value===s.value)return[];const e=i.value>s.value,t=u.value?!e:e;return[e?"evolution-up":"evolution-down",t?"positive-evolution":"negative-evolution"]}),p=Object(r["computed"])(()=>{if(null===i.value||void 0===i.value||null===s.value||void 0===s.value)return null;const e="string"===typeof i.value?parseFloat(i.value):i.value,t="string"===typeof s.value?parseFloat(s.value):s.value,n=D["Matomo"].helper.calculateEvolution(e,t);return(100*n).toFixed(2)+" %"}),m=Object(r["computed"])(()=>{var e;if(null===(e=o.value)||void 0===e||!e[3])return null;const t=o.value[3];return t[l.value]||0}),b=Object(r["computed"])(()=>{var e;if(null===(e=o.value)||void 0===e||!e[0])return null;const t=o.value[0];return t[l.value]||0}),v=Object(r["computed"])(()=>{var t;return null!==(t=e.metricTranslations)&&void 0!==t&&t[l.value]?e.metricTranslations[l.value]:""}),O=Object(r["computed"])(()=>{var t;return null!==(t=e.metricDocumentations)&&void 0!==t&&t[l.value]?e.metricDocumentations[l.value]:""}),j=Object(r["computed"])(()=>D["Matomo"].startDateString===D["Matomo"].endDateString?D["Matomo"].endDateString:`${D["Matomo"].startDateString}, ${D["Matomo"].endDateString}`);function f(){return c.value||0===c.value}const k=Object(r["computed"])(()=>{const e={module:"API",action:"get",columns:l.value};return f()&&(e.idGoal=c.value,e.module="Goals"),e}),g=Object(r["computed"])(()=>{if("range"!==D["Matomo"].period)return ge()}),y=Object(r["computed"])(()=>{const t=[];return Object.keys(e.metricTranslations).forEach(n=>{t.push({column:n,translation:e.metricTranslations[n]})}),Object.values(e.goals||{}).forEach(n=>{e.goalMetrics.forEach(o=>{t.push({column:`goal${n.idgoal}_${o}`,translation:`${n.name} - ${e.metricTranslations[o]}`})})}),t});function h(){let n=v.value;if(f()){var o;const t=(null===(o=e.goals[c.value])||void 0===o?void 0:o.name)||Object(D["translate"])("General_Unknown");n=`${t} - ${n}`}ye(t.value).closest("div.widget").find(".widgetTop > .widgetName > span").text(n)}function S(){const e=D["Range"].getLastNRange(D["Matomo"].period,2,D["Matomo"].currentDateString);return Object(D["format"])(e.startDate)}function C(){n.value=!0;const e=[];let t="API",l="get";const r={};f()&&(r.idGoal=c.value,r.filter_add_columns_when_show_all_columns=0,t="Goals",l="get");const a=`${t}.${l}`;return e.push(D["AjaxHelper"].fetch(Object.assign({method:a,format_metrics:"all"},r))),"range"!==D["Matomo"].period&&(e.push(D["AjaxHelper"].fetch(Object.assign({method:a,format_metrics:"0"},r))),e.push(D["AjaxHelper"].fetch(Object.assign({method:a,date:S(),format_metrics:"0"},r))),e.push(D["AjaxHelper"].fetch(Object.assign({method:a,date:S(),format_metrics:"all"},r)))),Promise.all(e).then(e=>{o.value=e,n.value=!1})}function w(e){l.value=e,C().then(h),ye(t.value).closest("[widgetId]").trigger("setParameters",{column:l.value,idGoal:c.value})}function V(e){let t=void 0,n=e;const o=e.match(/^goal([0-9]+)_(.*)/);o&&(t=+o[1],[,,n]=o),l.value===n&&t===c.value||(l.value=n,c.value=t,w(n))}function B(){const e=ye(t.value),n=e.closest("div.widget").find(".widgetTop > .widgetName"),o=ye('
    '),l=Object(D["createVueApp"])({render:()=>Object(r["createVNode"])(J,{multiselect:!1,selectableColumns:y.value,selectableRows:[],selectedColumns:a.value,selectedRows:[],onSelect:({columns:e})=>{V(e[0])}})});return n.append(o),l.mount(o.children()[0]),l}let E;return Object(r["onMounted"])(()=>{E=B()}),Object(r["onBeforeUnmount"])(()=>{ye(t.value).closest(".widgetContent").off("widget:destroy").off("widget:reload"),ye(t.value).closest("div.widget").find(".single-metric-view-picker").remove(),E.unmount()}),Object(r["watch"])(()=>e.metric,()=>{w(e.metric)}),w(e.metric),{root:t,metricValue:b,isLoading:n,selectedColumns:a,responses:o,metricValueUnformatted:i,pastValueUnformatted:s,evolutionClass:d,metricChangePercent:p,pastValue:m,metricTranslation:v,metricDocumentation:O,sparklineParams:k,pastPeriod:g,selectableColumns:y,currentPeriod:j}}});he.render=ke;var Se=he;const Ce={class:"row sparklinesGrid"};function we(e,t,n,o,l,c){const a=Object(r["resolveComponent"])("SparklineCard");return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",Ce,[(Object(r["openBlock"])(!0),Object(r["createElementBlock"])(r["Fragment"],null,Object(r["renderList"])(e.flatSparklines,(t,n)=>(Object(r["openBlock"])(),Object(r["createElementBlock"])("div",{key:n,class:Object(r["normalizeClass"])(e.columnClasses)},[Object(r["createVNode"])(a,{sparkline:t,"are-sparklines-linkable":e.areSparklinesLinkable,"all-metrics-documentation":e.allMetricsDocumentation},null,8,["sparkline","are-sparklines-linkable","all-metrics-documentation"])],2))),128))])}const Ve=["data-graph-params","data-series-indices"],Be={key:0,class:"sparklineCard__title"};function Ee(e,t,n,o,l,c){const a=Object(r["resolveComponent"])("NoComparison");return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",{class:Object(r["normalizeClass"])(["sparkline sparklineCard",{notLinkable:!e.areSparklinesLinkable}]),"data-graph-params":e.graphParamsAttr,"data-series-indices":e.seriesIndicesAttr},[e.sparkline.title?(Object(r["openBlock"])(),Object(r["createElementBlock"])("div",Be,Object(r["toDisplayString"])(e.sparkline.title),1)):Object(r["createCommentVNode"])("",!0),Object(r["createVNode"])(a,{sparkline:e.sparkline,"all-metrics-documentation":e.allMetricsDocumentation},null,8,["sparkline","all-metrics-documentation"])],10,Ve)}const Ne={class:"noComparison"},_e={class:"sparklineSlot"};function Me(e,t,n,o,l,c){const a=Object(r["resolveComponent"])("EvolutionBadge"),i=Object(r["resolveComponent"])("MetricValue"),s=Object(r["resolveComponent"])("Sparkline");return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",Ne,[Object(r["createVNode"])(i,{title:e.title,value:e.primaryValue,"secondary-value":e.secondaryValue,"secondary-label":e.secondaryLabel,documentation:e.documentation},Object(r["createSlots"])({_:2},[e.sparkline.evolution?{name:"evolution",fn:Object(r["withCtx"])(()=>[Object(r["createVNode"])(a,{percent:e.sparkline.evolution.percent,trend:e.sparkline.evolution.trend,"is-lower-value-better":e.sparkline.evolution.isLowerValueBetter,tooltip:e.sparkline.evolution.tooltip||""},null,8,["percent","trend","is-lower-value-better","tooltip"])]),key:"0"}:void 0]),1032,["title","value","secondary-value","secondary-label","documentation"]),Object(r["createElementVNode"])("div",_e,[Object(r["createVNode"])(s,{width:380,height:40,params:e.sparkline.url,"series-indices":e.sparkline.seriesIndices},null,8,["params","series-indices"])])])}var De=Object(r["defineComponent"])({name:"NoComparison",components:{MetricValue:P,EvolutionBadge:S,Sparkline:D["Sparkline"]},props:{sparkline:{type:Object,required:!0},allMetricsDocumentation:{type:Object,default:()=>({})}},setup(e){const t=Object(r["computed"])(()=>{var t;return null===(t=e.sparkline.metrics)||void 0===t||null===(t=t[""])||void 0===t?void 0:t[0]}),n=Object(r["computed"])(()=>{var t;return null===(t=e.sparkline.metrics)||void 0===t||null===(t=t[""])||void 0===t?void 0:t[1]}),o=Object(r["computed"])(()=>{var e,n;return(null===(e=t.value)||void 0===e?void 0:e.title)||(null===(n=t.value)||void 0===n?void 0:n.description)||""}),l=Object(r["computed"])(()=>{var n,o;return e.allMetricsDocumentation[null!==(n=null===(o=t.value)||void 0===o?void 0:o.column)&&void 0!==n?n:""]||void 0}),c=e=>"number"===typeof e?D["NumberFormatter"].formatNumber(e,2):e,a=Object(r["computed"])(()=>{var e,n;return null!==(e=c(null===(n=t.value)||void 0===n?void 0:n.value))&&void 0!==e?e:""}),i=Object(r["computed"])(()=>{var e;return c(null===(e=n.value)||void 0===e?void 0:e.value)}),s=Object(r["computed"])(()=>{var e;return null===(e=n.value)||void 0===e?void 0:e.description});return{title:o,documentation:l,primaryValue:a,secondaryValue:i,secondaryLabel:s}}});De.render=Me;var xe=De,Pe=Object(r["defineComponent"])({name:"SparklineCard",components:{NoComparison:xe},props:{sparkline:{type:Object,required:!0},areSparklinesLinkable:{type:Boolean,default:!0},allMetricsDocumentation:{type:Object,default:()=>({})}},setup(e){const t=Object(r["computed"])(()=>{const{graphParams:t,url:n}=e.sparkline;if(t&&Object.keys(t).length)return JSON.stringify(t);if(n){const e=D["MatomoUrl"].parse(n.substring(n.indexOf("?")+1)),t={};if(["columns","rows","idGoal"].forEach(n=>{e[n]&&(t[n]=e[n])}),Object.keys(t).length)return JSON.stringify(t)}return null}),n=Object(r["computed"])(()=>{const{seriesIndices:t}=e.sparkline;return t&&t.length?JSON.stringify(t):null});return{graphParamsAttr:t,seriesIndicesAttr:n}}});Pe.render=Ee;var Le=Pe,Ae=Object(r["defineComponent"])({name:"SparklinesGrid",components:{SparklineCard:Le},props:{sparklines:{type:Object,required:!0},areSparklinesLinkable:{type:Boolean,default:!0},allMetricsDocumentation:{type:Object,default:()=>({})},isWidget:{type:Boolean,default:!1}},setup(e){const t=Object(r["computed"])(()=>[].concat(...Object.values(e.sparklines||{})).filter(e=>!!e.url).sort((e,t)=>e.order-t.order)),n=Object(r["computed"])(()=>e.isWidget?"col s6":"col s6 m6 l4 xl3");return Object(r["onMounted"])(()=>{Object(r["nextTick"])(()=>{window.initializeSparklines()})}),{flatSparklines:t,columnClasses:n}}});Ae.render=we;var Re=Ae; +(function(e,t){"object"===typeof exports&&"object"===typeof module?module.exports=t(require("CoreHome"),require("vue")):"function"===typeof define&&define.amd?define(["CoreHome"],t):"object"===typeof exports?exports["CoreVisualizations"]=t(require("CoreHome"),require("vue")):e["CoreVisualizations"]=t(e["CoreHome"],e["Vue"])})("undefined"!==typeof self?self:this,(function(e,t){return function(e){var t={};function n(o){if(t[o])return t[o].exports;var l=t[o]={i:o,l:!1,exports:{}};return e[o].call(l.exports,l,l.exports,n),l.l=!0,l.exports}return n.m=e,n.c=t,n.d=function(e,t,o){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:o})},n.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"===typeof e&&e&&e.__esModule)return e;var o=Object.create(null);if(n.r(o),Object.defineProperty(o,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var l in e)n.d(o,l,function(t){return e[t]}.bind(null,l));return o},n.n=function(e){var t=e&&e.__esModule?function(){return e["default"]}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="plugins/CoreVisualizations/vue/dist/",n(n.s="fae3")}({"19dc":function(t,n){t.exports=e},"8bbf":function(e,n){e.exports=t},fae3:function(e,t,n){"use strict";if(n.r(t),n.d(t,"EvolutionBadge",(function(){return C})),n.d(t,"MetricValue",(function(){return P})),n.d(t,"SeriesPicker",(function(){return K})),n.d(t,"MetricsPicker",(function(){return me})),n.d(t,"SingleMetricView",(function(){return he})),n.d(t,"SparklinesGrid",(function(){return it})),"undefined"!==typeof window){var o=window.document.currentScript,l=o&&o.src.match(/(.+\/)[^/]+\.js(\?.*)?$/);l&&(n.p=l[1])}var r=n("8bbf");const c=["title"],a={class:"evolutionBadge__icon","aria-hidden":"true"},i={class:"evolutionBadge__value"};function s(e,t,n,o,l,s){const u=Object(r["resolveComponent"])("EvolutionTrendIcon");return Object(r["openBlock"])(),Object(r["createElementBlock"])("span",{class:Object(r["normalizeClass"])(["evolutionBadge",e.directionClass]),title:e.tooltip||void 0},[Object(r["createElementVNode"])("span",a,[Object(r["createVNode"])(u,{class:"evolutionTrendIcon",direction:e.direction},null,8,["direction"])]),Object(r["createElementVNode"])("span",i,Object(r["toDisplayString"])(e.formattedPercent),1)],10,c)}const u={key:0,viewBox:"0 0 16 16"},d=Object(r["createElementVNode"])("path",{d:"M3.77344 11L8.27344 5L12.7734 11H3.77344Z",fill:"currentColor"},null,-1),p=[d],m={key:1,viewBox:"0 0 16 16"},b=Object(r["createElementVNode"])("path",{d:"M3.77344 6L8.27344 12L12.7734 6H3.77344Z",fill:"currentColor"},null,-1),v=[b],O={key:2,viewBox:"0 0 16 16"},j=Object(r["createElementVNode"])("rect",{x:"3",y:"7",width:"10",height:"2",fill:"currentColor"},null,-1),k=[j];function g(e,t,n,o,l,c){return"up"===e.direction?(Object(r["openBlock"])(),Object(r["createElementBlock"])("svg",u,p)):"down"===e.direction?(Object(r["openBlock"])(),Object(r["createElementBlock"])("svg",m,v)):(Object(r["openBlock"])(),Object(r["createElementBlock"])("svg",O,k))}var f=Object(r["defineComponent"])({name:"EvolutionTrendIcon",props:{direction:{type:String,required:!0,validator:e=>-1!==["up","down","neutral"].indexOf(e)}}});f.render=g;var y=f,h=Object(r["defineComponent"])({name:"EvolutionBadge",components:{EvolutionTrendIcon:y},props:{percent:{type:[Number,String],required:!0},isLowerValueBetter:{type:Boolean,default:!1},trend:{type:Number,default:void 0},tooltip:{type:String,default:""}},setup(e){const t=Object(r["computed"])(()=>{if("number"===typeof e.trend&&!Number.isNaN(e.trend))return e.trend;const t=parseFloat(String(e.percent).replace("−","-").replace(",",".").replace(/[^0-9.+-]/g,""));return Number.isNaN(t)?0:t}),n=Object(r["computed"])(()=>t.value>0?"up":t.value<0?"down":"neutral"),o=Object(r["computed"])(()=>{if("neutral"===n.value)return"evolutionBadge--neutral";const t="up"===n.value,o=e.isLowerValueBetter?!t:t;return o?"evolutionBadge--positive":"evolutionBadge--negative"}),l=Object(r["computed"])(()=>{const n="number"===typeof e.percent?e.percent+"%":String(e.percent).trim(),o=n.charAt(0);return t.value>0&&"+"!==o&&"-"!==o?"+"+n:n});return{direction:n,directionClass:o,formattedPercent:l}}});h.render=s;var C=h;const S={class:"metricValue"},V=["title"],B={class:"metricValue__primary"},w=["title"],E={key:1,class:"metricValue__secondary"},_={class:"metricValue__secondaryLine"};function N(e,t,n,o,l,c){var a;const i=Object(r["resolveDirective"])("tooltips");return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",S,[e.displayTitle?Object(r["withDirectives"])((Object(r["openBlock"])(),Object(r["createElementBlock"])("div",{key:0,class:Object(r["normalizeClass"])(["metricValue__title",{"metricValue__title--documented":!!e.documentation}]),title:e.documentation||e.displayTitle},[Object(r["createTextVNode"])(Object(r["toDisplayString"])(e.displayTitle),1)],10,V)),[[i,{duration:200,delay:200}]]):Object(r["createCommentVNode"])("",!0),Object(r["createElementVNode"])("div",B,[Object(r["withDirectives"])((Object(r["openBlock"])(),Object(r["createElementBlock"])("span",{class:"metricValue__number",title:null===(a=e.displayValue)||void 0===a?void 0:a.toString()},[Object(r["createTextVNode"])(Object(r["toDisplayString"])(e.displayValue),1)],8,w)),[[i,{duration:200,delay:200}]]),Object(r["renderSlot"])(e.$slots,"evolution")]),e.hasSecondary?(Object(r["openBlock"])(),Object(r["createElementBlock"])("div",E,[Object(r["createElementVNode"])("span",_,Object(r["toDisplayString"])(e.displaySecondaryLine),1)])):Object(r["createCommentVNode"])("",!0)])}var M=n("19dc"),D=Object(r["defineComponent"])({name:"MetricValue",directives:{Tooltips:M["Tooltips"]},props:{title:{type:String,default:""},value:{type:[String,Number],required:!0},secondaryValue:[String,Number],secondaryLabel:String,documentation:String},computed:{displayTitle(){return Object(M["ucfirst"])(this.title,document.documentElement.lang)},displayValue(){return this.formatValue(this.value)},displaySecondaryValue(){return this.formatValue(this.secondaryValue)},displaySecondaryLine(){const e=this.displaySecondaryValue,t=void 0===e||null===e?"":String(e),n=this.secondaryLabel;return n?/%(?:\d+\$)?s/.test(n)?n.replace(/%(?:\d+\$)?s/g,()=>t):`${t} ${n}`:t},hasSecondary(){return void 0!==this.secondaryValue&&null!==this.secondaryValue&&""!==this.secondaryValue}},methods:{formatValue(e){return"number"===typeof e?M["NumberFormatter"].formatNumber(e,2):e}}});D.render=N;var P=D;const L={key:0,class:"jqplot-seriespicker-popover"},x={class:"headline"},T=["onClick"],A=["type","checked"],R={key:0,class:"headline recordsToPlot"},G=["onClick"],$=["type","checked"];function q(e,t,n,o,l,c){return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",{class:Object(r["normalizeClass"])(["jqplot-seriespicker",{open:e.isPopupVisible}]),onMouseenter:t[1]||(t[1]=t=>e.isPopupVisible=!0),onMouseleave:t[2]||(t[2]=t=>e.onLeavePopup())},[Object(r["createElementVNode"])("a",{href:"#",onClick:t[0]||(t[0]=Object(r["withModifiers"])(()=>{},["prevent","stop"]))}," + "),e.isPopupVisible?(Object(r["openBlock"])(),Object(r["createElementBlock"])("div",L,[Object(r["createElementVNode"])("p",x,Object(r["toDisplayString"])(e.translate(e.multiselect?"General_MetricsToPlot":"General_MetricToPlot")),1),(Object(r["openBlock"])(!0),Object(r["createElementBlock"])(r["Fragment"],null,Object(r["renderList"])(e.selectableColumns,t=>(Object(r["openBlock"])(),Object(r["createElementBlock"])("p",{class:"pickColumn",onClick:n=>e.optionSelected(t.column,e.columnStates),key:t.column},[Object(r["createElementVNode"])("label",null,[Object(r["createElementVNode"])("input",{class:"select",type:e.multiselect?"checkbox":"radio",checked:!!e.columnStates[t.column]},null,8,A),Object(r["createElementVNode"])("span",null,Object(r["toDisplayString"])(t.translation),1)])],8,T))),128)),e.selectableRows.length?(Object(r["openBlock"])(),Object(r["createElementBlock"])("p",R,Object(r["toDisplayString"])(e.translate("General_RecordsToPlot")),1)):Object(r["createCommentVNode"])("",!0),(Object(r["openBlock"])(!0),Object(r["createElementBlock"])(r["Fragment"],null,Object(r["renderList"])(e.selectableRows,t=>(Object(r["openBlock"])(),Object(r["createElementBlock"])("p",{class:"pickRow",onClick:n=>e.optionSelected(t.matcher,e.rowStates),key:t.matcher},[Object(r["createElementVNode"])("label",null,[Object(r["createElementVNode"])("input",{class:"select",type:e.multiselect?"checkbox":"radio",checked:!!e.rowStates[t.matcher]},null,8,$),Object(r["createElementVNode"])("span",null,Object(r["toDisplayString"])(t.label),1)])],8,G))),128))])):Object(r["createCommentVNode"])("",!0)],34)}function I(e,t){const n={};return e.forEach(e=>{const t=e.column||e.matcher;n[t]=!1}),t.forEach(e=>{n[e]=!0}),n}function z(e,t){return e.length===t.length&&0===e.filter(e=>-1===t.indexOf(e)).length}function F(e){Object.keys(e).forEach(t=>{e[t]=!1})}function H(e){return Object.keys(e).filter(t=>!!e[t])}var W=Object(r["defineComponent"])({props:{multiselect:Boolean,selectableColumns:{type:Array,default:()=>[]},selectableRows:{type:Array,default:()=>[]},selectedColumns:{type:Array,default:()=>[]},selectedRows:{type:Array,default:()=>[]}},data(){return{isPopupVisible:!1,columnStates:I(this.selectableColumns,this.selectedColumns),rowStates:I(this.selectableRows,this.selectedRows)}},emits:["select"],created(){this.optionSelected=Object(M["debounce"])(this.optionSelected,0)},methods:{optionSelected(e,t){this.multiselect||(F(this.columnStates),F(this.rowStates)),t[e]=!t[e],this.triggerOnSelectAndClose()},onLeavePopup(){this.isPopupVisible=!1,this.optionsChanged()&&this.triggerOnSelectAndClose()},triggerOnSelectAndClose(){this.isPopupVisible=!1,this.$emit("select",{columns:H(this.columnStates),rows:H(this.rowStates)})},optionsChanged(){return!z(H(this.columnStates),this.selectedColumns)||!z(H(this.rowStates),this.selectedRows)}}});W.render=q;var K=W;const U={ref:"root",class:"metrics-picker"},J={ref:"expander",type:"button",class:"metrics-picker__toggle"},Z={class:"metrics-picker__toggle-label"},Q=Object(r["createElementVNode"])("span",{class:"icon-chevron-down metrics-picker__chevron"},null,-1),X={class:"metrics-picker__dropdown"};function Y(e,t,n,o,l,c){const a=Object(r["resolveComponent"])("MetricsPickerOptions"),i=Object(r["resolveDirective"])("expand-on-click");return Object(r["withDirectives"])((Object(r["openBlock"])(),Object(r["createElementBlock"])("div",U,[Object(r["createElementVNode"])("button",J,[Object(r["createElementVNode"])("span",Z,Object(r["toDisplayString"])(e.translate("General_ChooseMetrics")),1),Q],512),Object(r["createElementVNode"])("div",X,[Object(r["createVNode"])(a,{multiselect:e.multiselect,"selectable-columns":e.selectableColumns,"selectable-rows":e.selectableRows,"selected-columns":e.selectedColumns,"selected-rows":e.selectedRows,onSelect:t[0]||(t[0]=t=>e.onSelect(t))},null,8,["multiselect","selectable-columns","selectable-rows","selected-columns","selected-rows"])])])),[[i,{expander:"expander"}]])}const ee=["role","aria-label"],te=["type","checked","onChange","onKeydown"],ne=Object(r["createElementVNode"])("span",{"aria-hidden":"true"},null,-1),oe={class:"metrics-picker__title"},le={key:0,class:"metrics-picker__headline"},re=["type","checked","onChange","onKeydown"],ce=Object(r["createElementVNode"])("span",{"aria-hidden":"true"},null,-1),ae={class:"metrics-picker__title"};function ie(e,t,n,o,l,c){return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",{class:"metrics-picker__options",role:e.multiselect?"group":"radiogroup","aria-label":e.translate("General_ChooseMetrics")},[(Object(r["openBlock"])(!0),Object(r["createElementBlock"])(r["Fragment"],null,Object(r["renderList"])(e.selectableColumns,t=>(Object(r["openBlock"])(),Object(r["createElementBlock"])("label",{class:"metrics-picker__column metrics-picker__label",key:t.column},[Object(r["createElementVNode"])("input",{class:"filled-in",type:e.multiselect?"checkbox":"radio",checked:!!e.columnStates[t.column],onChange:n=>e.optionSelected(t.column,e.columnStates),onKeydown:Object(r["withKeys"])(Object(r["withModifiers"])(n=>e.optionSelected(t.column,e.columnStates),["prevent"]),["enter"])},null,40,te),ne,Object(r["createElementVNode"])("span",oe,Object(r["toDisplayString"])(t.translation),1)]))),128)),e.selectableRows.length?(Object(r["openBlock"])(),Object(r["createElementBlock"])("p",le,Object(r["toDisplayString"])(e.translate("General_RecordsToPlot")),1)):Object(r["createCommentVNode"])("",!0),(Object(r["openBlock"])(!0),Object(r["createElementBlock"])(r["Fragment"],null,Object(r["renderList"])(e.selectableRows,t=>(Object(r["openBlock"])(),Object(r["createElementBlock"])("label",{class:"metrics-picker__row metrics-picker__label",key:t.matcher},[Object(r["createElementVNode"])("input",{class:"filled-in",type:e.multiselect?"checkbox":"radio",checked:!!e.rowStates[t.matcher],onChange:n=>e.optionSelected(t.matcher,e.rowStates),onKeydown:Object(r["withKeys"])(Object(r["withModifiers"])(n=>e.optionSelected(t.matcher,e.rowStates),["prevent"]),["enter"])},null,40,re),ce,Object(r["createElementVNode"])("span",ae,Object(r["toDisplayString"])(t.label),1)]))),128))],8,ee)}function se(e,t){const n={};return e.forEach(e=>{const t=e.column||e.matcher;n[t]=!1}),t.forEach(e=>{n[e]=!0}),n}var ue=Object(r["defineComponent"])({props:{multiselect:Boolean,selectableColumns:{type:Array,default:()=>[]},selectableRows:{type:Array,default:()=>[]},selectedColumns:{type:Array,default:()=>[]},selectedRows:{type:Array,default:()=>[]}},data(){return{columnStates:se(this.selectableColumns,this.selectedColumns),rowStates:se(this.selectableRows,this.selectedRows)}},emits:["select"],methods:{unselectOptions(e){Object.keys(e).forEach(t=>{e[t]=!1})},getSelected(e){return Object.keys(e).filter(t=>!!e[t])},optionSelected(e,t){this.multiselect||(this.unselectOptions(this.columnStates),this.unselectOptions(this.rowStates)),t[e]=!t[e],this.$emit("select",{columns:this.getSelected(this.columnStates),rows:this.getSelected(this.rowStates)})}}});ue.render=ie;var de=ue,pe=Object(r["defineComponent"])({props:{multiselect:Boolean,selectableColumns:{type:Array,default:()=>[]},selectableRows:{type:Array,default:()=>[]},selectedColumns:{type:Array,default:()=>[]},selectedRows:{type:Array,default:()=>[]}},components:{MetricsPickerOptions:de},directives:{ExpandOnClick:M["ExpandOnClick"]},emits:["select"],methods:{onSelect(e){this.$emit("select",e),this.$refs.root.classList.remove("expanded")}}});pe.render=Y;var me=pe;const be={class:"metric-sparkline"},ve={class:"metric-value"},Oe=["title"],je=["title"];function ke(e,t,n,o,l,c){const a=Object(r["resolveComponent"])("Sparkline");return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",{class:Object(r["normalizeClass"])(["singleMetricView",{loading:e.isLoading}]),ref:"root"},[Object(r["createElementVNode"])("div",be,[Object(r["createVNode"])(a,{params:e.sparklineParams},null,8,["params"])]),Object(r["createElementVNode"])("div",ve,[Object(r["createElementVNode"])("span",{title:e.metricDocumentation},[Object(r["createElementVNode"])("strong",null,Object(r["toDisplayString"])(e.metricValue),1),Object(r["createTextVNode"])(" "+Object(r["toDisplayString"])((e.metricTranslation||"").toLowerCase()),1)],8,Oe),null!==e.pastValue?(Object(r["openBlock"])(),Object(r["createElementBlock"])("span",{key:0,class:"metricEvolution",title:e.translate("General_EvolutionSummaryGeneric",e.metricValue,e.currentPeriod,e.pastValue,e.pastPeriod,e.metricChangePercent)},[Object(r["createElementVNode"])("span",{class:Object(r["normalizeClass"])(e.evolutionClass)},Object(r["toDisplayString"])(e.metricChangePercent),3)],8,je)):Object(r["createCommentVNode"])("",!0)])],2)}function ge(){const{startDate:e}=M["Range"].getLastNRange(M["Matomo"].period,2,M["Matomo"].currentDateString),t=M["Periods"].get(M["Matomo"].period).parse(e).getDateRange();return`${Object(M["format"])(t[0])},${Object(M["format"])(t[1])}`}const{$:fe}=window;var ye=Object(r["defineComponent"])({props:{metric:{type:String,required:!0},idGoal:[String,Number],metricTranslations:{type:Object,required:!0},metricDocumentations:Object,goals:{type:Object,required:!0},goalMetrics:Array,lowerIsBetterMetrics:{type:Array,default:()=>[]}},components:{Sparkline:M["Sparkline"]},setup(e){const t=Object(r["ref"])(null),n=Object(r["ref"])(!1),o=Object(r["ref"])(null),l=Object(r["ref"])(e.metric),c=Object(r["ref"])(e.idGoal),a=Object(r["computed"])(()=>[c.value?`goal${c.value}_${l.value}`:l.value]),i=Object(r["computed"])(()=>{var e;return null!==(e=o.value)&&void 0!==e&&e[1]?o.value[1][l.value]||0:null}),s=Object(r["computed"])(()=>{var e;return null!==(e=o.value)&&void 0!==e&&e[2]?o.value[2][l.value]||0:null}),u=Object(r["computed"])(()=>-1!==e.lowerIsBetterMetrics.indexOf(l.value)),d=Object(r["computed"])(()=>{if(null===i.value||null===s.value||i.value===s.value)return[];const e=i.value>s.value,t=u.value?!e:e;return[e?"evolution-up":"evolution-down",t?"positive-evolution":"negative-evolution"]}),p=Object(r["computed"])(()=>{if(null===i.value||void 0===i.value||null===s.value||void 0===s.value)return null;const e="string"===typeof i.value?parseFloat(i.value):i.value,t="string"===typeof s.value?parseFloat(s.value):s.value,n=M["Matomo"].helper.calculateEvolution(e,t);return(100*n).toFixed(2)+" %"}),m=Object(r["computed"])(()=>{var e;if(null===(e=o.value)||void 0===e||!e[3])return null;const t=o.value[3];return t[l.value]||0}),b=Object(r["computed"])(()=>{var e;if(null===(e=o.value)||void 0===e||!e[0])return null;const t=o.value[0];return t[l.value]||0}),v=Object(r["computed"])(()=>{var t;return null!==(t=e.metricTranslations)&&void 0!==t&&t[l.value]?e.metricTranslations[l.value]:""}),O=Object(r["computed"])(()=>{var t;return null!==(t=e.metricDocumentations)&&void 0!==t&&t[l.value]?e.metricDocumentations[l.value]:""}),j=Object(r["computed"])(()=>M["Matomo"].startDateString===M["Matomo"].endDateString?M["Matomo"].endDateString:`${M["Matomo"].startDateString}, ${M["Matomo"].endDateString}`);function k(){return c.value||0===c.value}const g=Object(r["computed"])(()=>{const e={module:"API",action:"get",columns:l.value};return k()&&(e.idGoal=c.value,e.module="Goals"),e}),f=Object(r["computed"])(()=>{if("range"!==M["Matomo"].period)return ge()}),y=Object(r["computed"])(()=>{const t=[];return Object.keys(e.metricTranslations).forEach(n=>{t.push({column:n,translation:e.metricTranslations[n]})}),Object.values(e.goals||{}).forEach(n=>{e.goalMetrics.forEach(o=>{t.push({column:`goal${n.idgoal}_${o}`,translation:`${n.name} - ${e.metricTranslations[o]}`})})}),t});function h(){let n=v.value;if(k()){var o;const t=(null===(o=e.goals[c.value])||void 0===o?void 0:o.name)||Object(M["translate"])("General_Unknown");n=`${t} - ${n}`}fe(t.value).closest("div.widget").find(".widgetName > span").text(n)}function C(){const e=M["Range"].getLastNRange(M["Matomo"].period,2,M["Matomo"].currentDateString);return Object(M["format"])(e.startDate)}function S(){n.value=!0;const e=[];let t="API",l="get";const r={};k()&&(r.idGoal=c.value,r.filter_add_columns_when_show_all_columns=0,t="Goals",l="get");const a=`${t}.${l}`;return e.push(M["AjaxHelper"].fetch(Object.assign({method:a,format_metrics:"all"},r))),"range"!==M["Matomo"].period&&(e.push(M["AjaxHelper"].fetch(Object.assign({method:a,format_metrics:"0"},r))),e.push(M["AjaxHelper"].fetch(Object.assign({method:a,date:C(),format_metrics:"0"},r))),e.push(M["AjaxHelper"].fetch(Object.assign({method:a,date:C(),format_metrics:"all"},r)))),Promise.all(e).then(e=>{o.value=e,n.value=!1})}function V(e){l.value=e,S().then(h),fe(t.value).closest("[widgetId]").trigger("setParameters",{column:l.value,idGoal:c.value})}function B(e){let t=void 0,n=e;const o=e.match(/^goal([0-9]+)_(.*)/);o&&(t=+o[1],[,,n]=o),l.value===n&&t===c.value||(l.value=n,c.value=t,V(n))}function w(){const e=fe(t.value),n=e.closest("div.widget").find(".widgetName"),o=fe('
    '),l=Object(M["createVueApp"])({render:()=>Object(r["createVNode"])(K,{multiselect:!1,selectableColumns:y.value,selectableRows:[],selectedColumns:a.value,selectedRows:[],onSelect:({columns:e})=>{B(e[0])}})});return n.append(o),l.mount(o.children()[0]),l}let E;return Object(r["onMounted"])(()=>{E=w()}),Object(r["onBeforeUnmount"])(()=>{fe(t.value).closest(".widgetContent").off("widget:destroy").off("widget:reload"),fe(t.value).closest("div.widget").find(".single-metric-view-picker").remove(),E.unmount()}),Object(r["watch"])(()=>e.metric,()=>{V(e.metric)}),V(e.metric),{root:t,metricValue:b,isLoading:n,selectedColumns:a,responses:o,metricValueUnformatted:i,pastValueUnformatted:s,evolutionClass:d,metricChangePercent:p,pastValue:m,metricTranslation:v,metricDocumentation:O,sparklineParams:g,pastPeriod:f,selectableColumns:y,currentPeriod:j}}});ye.render=ke;var he=ye;function Ce(e,t,n,o,l,c){const a=Object(r["resolveComponent"])("SegmentComparisonCard"),i=Object(r["resolveComponent"])("SparklineCard");return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",{class:Object(r["normalizeClass"])(e.gridClasses)},[e.isSegmentMode?(Object(r["openBlock"])(!0),Object(r["createElementBlock"])(r["Fragment"],{key:0},Object(r["renderList"])(e.segmentGroups,(t,n)=>(Object(r["openBlock"])(),Object(r["createElementBlock"])("div",{key:n,class:"sparklinesGrid__item"},[Object(r["createVNode"])(a,{segments:t,"are-sparklines-linkable":e.areSparklinesLinkable,"all-metrics-documentation":e.allMetricsDocumentation},null,8,["segments","are-sparklines-linkable","all-metrics-documentation"])]))),128)):(Object(r["openBlock"])(!0),Object(r["createElementBlock"])(r["Fragment"],{key:1},Object(r["renderList"])(e.flatSparklines,(t,n)=>(Object(r["openBlock"])(),Object(r["createElementBlock"])("div",{key:n,class:"sparklinesGrid__item"},[Object(r["createVNode"])(i,{sparkline:t,"are-sparklines-linkable":e.areSparklinesLinkable,"all-metrics-documentation":e.allMetricsDocumentation},null,8,["sparkline","are-sparklines-linkable","all-metrics-documentation"])]))),128))],2)}const Se=["data-graph-params","data-series-indices"],Ve={key:0,class:"sparklineCard__title"};function Be(e,t,n,o,l,c){const a=Object(r["resolveComponent"])("DateComparison"),i=Object(r["resolveComponent"])("NoComparison"),s=Object(r["resolveComponent"])("Sparkline");return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",{class:Object(r["normalizeClass"])(["sparkline sparklineCard",{notLinkable:!e.areSparklinesLinkable}]),"data-graph-params":e.graphParamsAttr,"data-series-indices":e.seriesIndicesAttr},[e.sparkline.title?(Object(r["openBlock"])(),Object(r["createElementBlock"])("div",Ve,Object(r["toDisplayString"])(e.sparkline.title),1)):Object(r["createCommentVNode"])("",!0),e.isComparison?(Object(r["openBlock"])(),Object(r["createBlock"])(a,{key:1,sparkline:e.sparkline},null,8,["sparkline"])):(Object(r["openBlock"])(),Object(r["createBlock"])(i,{key:2,sparkline:e.sparkline,"all-metrics-documentation":e.allMetricsDocumentation},null,8,["sparkline","all-metrics-documentation"])),Object(r["createElementVNode"])("div",{class:Object(r["normalizeClass"])(["sparklineCard__sparkline",{"sparklineCard__sparkline--wide":e.isComparison}])},[Object(r["createVNode"])(s,{width:e.sparklineWidth,height:40,params:e.sparkline.url,"series-indices":e.sparkline.seriesIndices},null,8,["width","params","series-indices"])],2)],10,Se)}const we={class:"sparklineNoComparison"};function Ee(e,t,n,o,l,c){const a=Object(r["resolveComponent"])("EvolutionBadge"),i=Object(r["resolveComponent"])("MetricValue");return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",we,[Object(r["createVNode"])(i,{class:"metricValue--fixedHeight",title:e.title,value:e.primaryValue,"secondary-value":e.secondaryValue,"secondary-label":e.secondaryLabel,documentation:e.documentation},Object(r["createSlots"])({_:2},[e.sparkline.evolution?{name:"evolution",fn:Object(r["withCtx"])(()=>[Object(r["createVNode"])(a,{percent:e.sparkline.evolution.percent,trend:e.sparkline.evolution.trend,"is-lower-value-better":e.sparkline.evolution.isLowerValueBetter,tooltip:e.sparkline.evolution.tooltip||""},null,8,["percent","trend","is-lower-value-better","tooltip"])]),key:"0"}:void 0]),1032,["title","value","secondary-value","secondary-label","documentation"])])}var _e=Object(r["defineComponent"])({name:"NoComparison",components:{MetricValue:P,EvolutionBadge:C},props:{sparkline:{type:Object,required:!0},allMetricsDocumentation:{type:Object,default:()=>({})}},setup(e){const t=Object(r["computed"])(()=>{var t;return null===(t=e.sparkline.metrics)||void 0===t||null===(t=t[""])||void 0===t?void 0:t[0]}),n=Object(r["computed"])(()=>{var t;return null===(t=e.sparkline.metrics)||void 0===t||null===(t=t[""])||void 0===t?void 0:t[1]}),o=Object(r["computed"])(()=>{var e,n;return(null===(e=t.value)||void 0===e?void 0:e.title)||(null===(n=t.value)||void 0===n?void 0:n.description)||""}),l=Object(r["computed"])(()=>{var n,o;return e.allMetricsDocumentation[null!==(n=null===(o=t.value)||void 0===o?void 0:o.column)&&void 0!==n?n:""]||void 0}),c=Object(r["computed"])(()=>{var e,n;return null!==(e=null===(n=t.value)||void 0===n?void 0:n.value)&&void 0!==e?e:""}),a=Object(r["computed"])(()=>{var e;return null===(e=n.value)||void 0===e?void 0:e.value}),i=Object(r["computed"])(()=>{var e;return null===(e=n.value)||void 0===e?void 0:e.description});return{title:o,documentation:l,primaryValue:c,secondaryValue:a,secondaryLabel:i}}});_e.render=Ee;var Ne=_e;const Me={class:"sparklineDateComparison"},De=["title"];function Pe(e,t,n,o,l,c){const a=Object(r["resolveComponent"])("PeriodColumns");return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",Me,[Object(r["createElementVNode"])("div",{class:"sparklineDateComparison__title",title:e.metricTitle},Object(r["toDisplayString"])(e.metricTitle),9,De),Object(r["createVNode"])(a,{entry:e.sparkline},null,8,["entry"])])}const Le={class:"periodColumns"},xe={key:0,class:"periodColumns__separator"},Te={class:"periodColumns__column"};function Ae(e,t,n,o,l,c){const a=Object(r["resolveComponent"])("DateAtom"),i=Object(r["resolveComponent"])("EvolutionBadge"),s=Object(r["resolveComponent"])("MetricValue");return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",Le,[(Object(r["openBlock"])(!0),Object(r["createElementBlock"])(r["Fragment"],null,Object(r["renderList"])(e.periods,(t,n)=>(Object(r["openBlock"])(),Object(r["createElementBlock"])(r["Fragment"],{key:t.label},[n>0?(Object(r["openBlock"])(),Object(r["createElementBlock"])("div",xe)):Object(r["createCommentVNode"])("",!0),Object(r["createElementVNode"])("div",Te,[e.showLabels?(Object(r["openBlock"])(),Object(r["createBlock"])(a,{key:0,label:t.label},null,8,["label"])):Object(r["createCommentVNode"])("",!0),Object(r["createVNode"])(s,{class:"metricValue--noTitle",value:t.primaryValue,"secondary-value":t.secondaryValue,"secondary-label":t.secondaryLabel},Object(r["createSlots"])({_:2},[t.evolution?{name:"evolution",fn:Object(r["withCtx"])(()=>[Object(r["createVNode"])(i,{percent:t.evolution.percent,trend:t.evolution.trend,"is-lower-value-better":t.evolution.isLowerValueBetter,tooltip:t.evolution.tooltip||""},null,8,["percent","trend","is-lower-value-better","tooltip"])]),key:"0"}:void 0]),1032,["value","secondary-value","secondary-label"])])],64))),128))])}const Re=["title"];function Ge(e,t,n,o,l,c){return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",{class:"dateAtom",title:e.label},Object(r["toDisplayString"])(e.label),9,Re)}var $e=Object(r["defineComponent"])({name:"DateAtom",props:{label:{type:String,required:!0}}});$e.render=Ge;var qe=$e,Ie=Object(r["defineComponent"])({name:"PeriodColumns",components:{DateAtom:qe,MetricValue:P,EvolutionBadge:C},props:{entry:{type:Object,required:!0}},setup(e){const t=Object(r["computed"])(()=>{const t=e.entry.metrics||{},n=e.entry.metricsOrder||[];return n.map(e=>{var n;const o=t[e]||[],l=o[0],r=o[1];return{label:e,primaryValue:null!==(n=null===l||void 0===l?void 0:l.value)&&void 0!==n?n:"",evolution:null===l||void 0===l?void 0:l.evolution,secondaryValue:null===r||void 0===r?void 0:r.value,secondaryLabel:null===r||void 0===r?void 0:r.description}})}),n=Object(r["computed"])(()=>t.value.length>1);return{periods:t,showLabels:n}}});Ie.render=Ae;var ze=Ie,Fe=Object(r["defineComponent"])({name:"DateComparison",components:{PeriodColumns:ze},props:{sparkline:{type:Object,required:!0}},setup(e){const t=Object(r["computed"])(()=>{var t,n;const o=e.sparkline.metrics||{},l=null!==(t=(e.sparkline.metricsOrder||[])[0])&&void 0!==t?t:Object.keys(o)[0],r=void 0!==l?null===(n=o[l])||void 0===n?void 0:n[0]:void 0;return Object(M["ucfirst"])((null===r||void 0===r?void 0:r.title)||(null===r||void 0===r?void 0:r.description),document.documentElement.lang)});return{metricTitle:t}}});Fe.render=Pe;var He=Fe; +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */function We(e){const{graphParams:t,url:n}=e;if(t&&Object.keys(t).length)return JSON.stringify(t);if(n){const e=M["MatomoUrl"].parse(n.substring(n.indexOf("?")+1)),t={};if(["columns","rows","idGoal"].forEach(n=>{e[n]&&(t[n]=e[n])}),Object.keys(t).length)return JSON.stringify(t)}return null}function Ke(e){const{seriesIndices:t}=e;return t&&t.length?JSON.stringify(t):null}var Ue=Object(r["defineComponent"])({name:"SparklineCard",components:{NoComparison:Ne,DateComparison:He,Sparkline:M["Sparkline"]},props:{sparkline:{type:Object,required:!0},areSparklinesLinkable:{type:Boolean,default:!0},allMetricsDocumentation:{type:Object,default:()=>({})}},setup(e){const t=Object(r["computed"])(()=>{var t;return!(null===(t=e.sparkline.seriesIndices)||void 0===t||!t.length)}),n=Object(r["computed"])(()=>We(e.sparkline)),o=Object(r["computed"])(()=>Ke(e.sparkline)),l=Object(r["computed"])(()=>t.value?760:380);return{isComparison:t,graphParamsAttr:n,seriesIndicesAttr:o,sparklineWidth:l}}});Ue.render=Be;var Je=Ue;const Ze=["data-graph-params","data-series-indices"],Qe=["title"],Xe={class:"sparklineSegmentComparisonCard__rows"};function Ye(e,t,n,o,l,c){const a=Object(r["resolveComponent"])("SegmentComparisonRow"),i=Object(r["resolveDirective"])("tooltips");return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",{class:Object(r["normalizeClass"])(["sparkline sparklineSegmentComparisonCard",{notLinkable:!e.areSparklinesLinkable}]),"data-graph-params":e.graphParamsAttr,"data-series-indices":e.seriesIndicesAttr},[Object(r["withDirectives"])((Object(r["openBlock"])(),Object(r["createElementBlock"])("div",{class:Object(r["normalizeClass"])(["sparklineSegmentComparisonCard__title",{"sparklineSegmentComparisonCard__title--documented":!!e.documentation}]),title:e.documentation||e.metricTitle},[Object(r["createTextVNode"])(Object(r["toDisplayString"])(e.metricTitle),1)],10,Qe)),[[i,{duration:200,delay:200}]]),Object(r["createElementVNode"])("div",Xe,[(Object(r["openBlock"])(!0),Object(r["createElementBlock"])(r["Fragment"],null,Object(r["renderList"])(e.segments,(e,t)=>(Object(r["openBlock"])(),Object(r["createBlock"])(a,{key:t,segment:e},null,8,["segment"]))),128))])],10,Ze)}const et={class:"sparklineSegmentComparisonRow"},tt=["title"];function nt(e,t,n,o,l,c){const a=Object(r["resolveComponent"])("PeriodColumns"),i=Object(r["resolveComponent"])("Sparkline");return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",et,[Object(r["createElementVNode"])("span",{class:"sparklineSegmentComparisonRow__chip",title:e.segmentLabel},Object(r["toDisplayString"])(e.segmentLabel),9,tt),Object(r["createVNode"])(a,{entry:e.segment},null,8,["entry"]),Object(r["createElementVNode"])("div",{class:Object(r["normalizeClass"])(["sparklineSegmentComparisonRow__sparkline",{"sparklineSegmentComparisonRow__sparkline--wide":e.isMultiPeriod}])},[Object(r["createVNode"])(i,{width:e.sparklineWidth,height:40,params:e.segment.url,"series-indices":e.segment.seriesIndices},null,8,["width","params","series-indices"])],2)])}var ot=Object(r["defineComponent"])({name:"SegmentComparisonRow",components:{PeriodColumns:ze,Sparkline:M["Sparkline"]},props:{segment:{type:Object,required:!0}},setup(e){const t=Object(r["computed"])(()=>e.segment.title||""),n=Object(r["computed"])(()=>(e.segment.metricsOrder||[]).length>1),o=Object(r["computed"])(()=>n.value?760:380);return{segmentLabel:t,isMultiPeriod:n,sparklineWidth:o}}});ot.render=nt;var lt=ot,rt=Object(r["defineComponent"])({name:"SegmentComparisonCard",directives:{Tooltips:M["Tooltips"]},components:{SegmentComparisonRow:lt},props:{segments:{type:Array,required:!0},areSparklinesLinkable:{type:Boolean,default:!0},allMetricsDocumentation:{type:Object,default:()=>({})}},setup(e){const t=Object(r["computed"])(()=>{var t,n;const o=e.segments[0],l=(null===o||void 0===o?void 0:o.metrics)||{},r=null!==(t=((null===o||void 0===o?void 0:o.metricsOrder)||[])[0])&&void 0!==t?t:Object.keys(l)[0];return void 0!==r?null===(n=l[r])||void 0===n?void 0:n[0]:void 0}),n=Object(r["computed"])(()=>{var e,n;const o=(null===(e=t.value)||void 0===e?void 0:e.title)||(null===(n=t.value)||void 0===n?void 0:n.description);return Object(M["ucfirst"])(o,document.documentElement.lang)}),o=Object(r["computed"])(()=>{var n,o;return e.allMetricsDocumentation[null!==(n=null===(o=t.value)||void 0===o?void 0:o.column)&&void 0!==n?n:""]||void 0}),l=Object(r["computed"])(()=>{const t=e.segments[0];return t?We(t):null}),c=Object(r["computed"])(()=>{const t=e.segments.flatMap(e=>e.seriesIndices||[]);return t.length?JSON.stringify(t):null});return{metricTitle:n,documentation:o,graphParamsAttr:l,seriesIndicesAttr:c}}});rt.render=Ye;var ct=rt,at=Object(r["defineComponent"])({name:"SparklinesGrid",components:{SparklineCard:Je,SegmentComparisonCard:ct},props:{sparklines:{type:Object,required:!0},areSparklinesLinkable:{type:Boolean,default:!0},allMetricsDocumentation:{type:Object,default:()=>({})},isWidget:{type:Boolean,default:!1},comparisonMode:{type:String,default:"none"}},setup(e){const t=Object(r["computed"])(()=>"segment"===e.comparisonMode||"segmentDate"===e.comparisonMode),n=Object(r["computed"])(()=>[].concat(...Object.values(e.sparklines||{})).filter(e=>!!e.url).sort((e,t)=>e.order-t.order)),o=Object(r["computed"])(()=>Object.values(e.sparklines||{}).map(e=>e.filter(e=>!!e.url)).filter(e=>e.length>0).sort((e,t)=>Math.min(...e.map(e=>e.order))-Math.min(...t.map(e=>e.order)))),l=Object(r["computed"])(()=>"date"===e.comparisonMode||"segmentDate"===e.comparisonMode),c=Object(r["computed"])(()=>({sparklinesGrid:!0,"sparklinesGrid--wide":l.value,"sparklinesGrid--framed":e.isWidget,"sparklinesGrid--compact":e.isWidget&&!l.value}));return Object(r["onMounted"])(()=>{Object(r["nextTick"])(()=>{window.initializeSparklines()})}),{isSegmentMode:t,flatSparklines:n,segmentGroups:o,gridClasses:c}}});at.render=Ce;var it=at; /*! * Matomo - free/libre analytics platform * diff --git a/app/plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionBadge.less b/app/plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionBadge.less index 8116e1c5c..7ba3eca61 100644 --- a/app/plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionBadge.less +++ b/app/plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionBadge.less @@ -5,21 +5,24 @@ display: inline-flex; align-items: center; - padding-left: 2px; - padding-right: 6px; + // Fluid pill: font-size scales with the card/column width via cqi, like the metric readout (see + // MetricValue.less); icon/height/line-height/padding are em ratios of it so the whole pill scales as + // one unit. ~"" escaped so less.php emits the clamp()/cqi verbatim. + font-size: ~"clamp(10px, 7.5px + 1.25cqi, 12px)"; + padding-left: 0.1667em; // 2px + padding-right: 0.5em; // 6px border-radius: 40px; - font-size: 12px; font-variant: tabular-nums; - line-height: 20px; + line-height: 1.6667; // 20px white-space: nowrap; vertical-align: middle; - height: 20px; + height: 1.6667em; // 20px .evolutionBadge__icon { display: inline-flex; flex: none; - width: 16px; - height: 16px; + width: 1.3333em; // 16px + height: 1.3333em; svg { display: block; diff --git a/app/plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.less b/app/plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.less index ffb771ae0..5cdd9a60f 100644 --- a/app/plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.less +++ b/app/plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.less @@ -1,38 +1,78 @@ +// Container-relative readout sizing: the number and secondary line scale with the width of their nearest +// `container-type: inline-size` ancestor — the card frame (standalone) or period column (comparison) — +// via the cqi clamps below. Anchors are PROVISIONAL (tuned to today's float-grid widths), to retune when +// the CSS Grid PR fixes column widths. Values are ~"" escaped so less.php emits the clamp()/cqi verbatim. .metricValue { - &__title { + @_number-line-height: ~"clamp(24px, 14px + 5cqi, 32px)"; + @_secondary-line: 16px; // tighter than the 24px default so a wrapped secondary's two + @_secondary-block: (@_secondary-line * 2); // lines sit close; reserve + cap 2 so a 1- vs 2-line + // secondary doesn't change the card height + + --metric-value-content-height: ~"calc(@{_number-line-height} + @{_secondary-block})"; + + .metricValue__title { color: @theme-color-text; font-size: 18px; line-height: 24px; + } - // Signals the title carries a documentation tooltip. - &--documented { - cursor: help; - } + // Signals the title carries a documentation tooltip. + .metricValue__title--documented { + cursor: help; } - &__primary { + .metricValue__primary { display: flex; - align-items: baseline; + align-items: safe center; gap: 8px; } - &__number { - font-size: 28px; + .metricValue__number { + font-size: ~"clamp(16px, 6px + 5cqi, 24px)"; font-weight: 600; - line-height: 36px; + line-height: @_number-line-height; color: @theme-color-text; - } - &__secondaryValue { - color: @theme-color-text; - font-size: 14px; - line-height: 24px; + // Truncate with an ellipsis if the value can't fit even at the smallest fluid size (a long number or + // duration in a narrow container), mirroring the title. min-width:0 lets it shrink inside the flex + // row; the evolution badge holds its own width, so the number is the shrink target. Full value on hover. + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } - &__secondaryLabel { - margin-left: 4px; - font-size: 12px; - line-height: 24px; + // Secondary line (value + label as one string): one supporting-text style. Font-size tracks the + // container like the number, one band lower; the .metricValue__secondary block owns the height. + .metricValue__secondaryLine { color: @theme-color-text-lighter; + font-size: ~"clamp(12px, 9.5px + 1.25cqi, 14px)"; } + + // Cap the secondary at two tight lines: a long value/label wraps without growing the card; a shorter + // one just leaves the reserved space empty. + .metricValue__secondary { + line-height: @_secondary-line; + max-height: @_secondary-block; + overflow: hidden; + } +} + +// min-height reserves the full readout height so 1- vs 2-line secondaries — and secondary-less cards — +// all align. Tracks the fluid number line-height + fixed title (24px) + the 2-line secondary block. +.metricValue--noTitle { + min-height: var(--metric-value-content-height); +} + +.metricValue--fixedHeight { + min-height: ~"calc(var(--metric-value-content-height) + 24px)"; + margin-bottom: 12px; +} + +// Clamp the title to one line so a long metric name can't grow the card (full title shows on hover). +.metricValue--fixedHeight .metricValue__title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + height: 24px; } diff --git a/app/plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.spec.ts b/app/plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.spec.ts index 2b21310cb..d80bf3662 100644 --- a/app/plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.spec.ts +++ b/app/plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.spec.ts @@ -7,16 +7,35 @@ import { mount } from '@vue/test-utils'; -// CoreHome is a package-style cross-plugin import with no jest module mapping, -// so it must be virtually mocked. Tooltips is used only as a (no-op here) directive. +// CoreHome is a package-style cross-plugin import with no jest module mapping, so it must be +// virtually mocked. Tooltips is a (no-op here) directive; NumberFormatter formats raw numeric +// values (the mock echoes value + precision so tests can assert both). ucfirst is an identity +// spy here; its casing behavior is covered by CoreHome's own ucfirst.spec. jest.mock('CoreHome', () => ({ Tooltips: {}, + ucfirst: jest.fn((text?: string) => text ?? ''), + NumberFormatter: { + formatNumber: (value: number, precision: number) => `${value}#${precision}`, + }, }), { virtual: true }); // eslint-disable-next-line @typescript-eslint/no-var-requires const MetricValue = require('./MetricValue.vue').default; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const ucfirstMock = require('CoreHome').ucfirst as jest.Mock; describe('CoreVisualizations/MetricValue', () => { + const originalDocumentLanguage = document.documentElement.lang; + + beforeEach(() => { + document.documentElement.lang = 'en'; + ucfirstMock.mockClear(); + }); + + afterAll(() => { + document.documentElement.lang = originalDocumentLanguage; + }); + it('renders the title and the pre-formatted value', () => { const wrapper = mount(MetricValue as any, { props: { @@ -29,7 +48,46 @@ describe('CoreVisualizations/MetricValue', () => { expect(wrapper.find('.metricValue__number').text()).toBe('190'); }); - it('renders the secondary value and label as separate elements', () => { + it('capitalizes the title using the document language', () => { + document.documentElement.lang = 'tr'; + + mount(MetricValue as any, { + props: { + title: 'istanbul', + value: '190', + }, + }); + + expect(ucfirstMock).toHaveBeenCalledWith('istanbul', 'tr'); + }); + + it('locale-formats a raw numeric value (with precision 2) but leaves strings verbatim', () => { + const wrapper = mount(MetricValue as any, { + props: { + title: 'Visits', + value: 10558, + secondaryValue: 9527, + }, + }); + + expect(wrapper.find('.metricValue__number').text()).toBe('10558#2'); + expect(wrapper.find('.metricValue__secondaryLine').text()).toBe('9527#2'); + }); + + it('exposes the displayed value as the number tooltip (recoverable when truncated)', () => { + const wrapper = mount(MetricValue as any, { + props: { + title: 'Visits', + value: 10558, + }, + }); + + // The formatted value is mirrored into the title attribute so a clipped number stays readable on + // hover, matching the title element's own tooltip. + expect(wrapper.find('.metricValue__number').attributes('title')).toBe('10558#2'); + }); + + it('renders the secondary value and label as one line', () => { const wrapper = mount(MetricValue as any, { props: { title: 'Visits', @@ -40,8 +98,7 @@ describe('CoreVisualizations/MetricValue', () => { }); expect(wrapper.find('.metricValue__secondary').exists()).toBe(true); - expect(wrapper.find('.metricValue__secondaryValue').text()).toBe('9,527'); - expect(wrapper.find('.metricValue__secondaryLabel').text()).toBe('unique visitors'); + expect(wrapper.find('.metricValue__secondaryLine').text()).toBe('9,527 unique visitors'); }); it('renders the secondary value without a label when no label is given', () => { @@ -53,8 +110,7 @@ describe('CoreVisualizations/MetricValue', () => { }, }); - expect(wrapper.find('.metricValue__secondaryValue').text()).toBe('9,527'); - expect(wrapper.find('.metricValue__secondaryLabel').exists()).toBe(false); + expect(wrapper.find('.metricValue__secondaryLine').text()).toBe('9,527'); }); it('omits the secondary line entirely when no secondary value is provided', () => { @@ -95,6 +151,94 @@ describe('CoreVisualizations/MetricValue', () => { expect(title.classes()).not.toContain('metricValue__title--documented'); }); + it('omits the title element when no title is given (date-comparison value column)', () => { + const wrapper = mount(MetricValue as any, { + props: { + value: '10,558', + }, + }); + + expect(wrapper.find('.metricValue__title').exists()).toBe(false); + expect(wrapper.find('.metricValue__number').text()).toBe('10,558'); + }); + + it('renders the value at a leading %s placeholder in the secondary label', () => { + const wrapper = mount(MetricValue as any, { + props: { + title: 'Direct Entry', + value: '4,242', + secondaryValue: '12%', + secondaryLabel: '%s of visits', + }, + }); + + expect(wrapper.find('.metricValue__secondaryLine').text()).toBe('12% of visits'); + }); + + it('renders the value at a mid-string %s placeholder, preserving word order', () => { + const wrapper = mount(MetricValue as any, { + props: { + title: 'Plays', + value: '1,234', + secondaryValue: '567', + secondaryLabel: 'by %s unique visitors', + }, + }); + + expect(wrapper.find('.metricValue__secondaryLine').text()).toBe('by 567 unique visitors'); + }); + + it('renders the value at a trailing %s placeholder (non-leading locales)', () => { + const wrapper = mount(MetricValue as any, { + props: { + title: 'Direct Entry', + value: '4,242', + secondaryValue: '12%', + secondaryLabel: 'foo %s', + }, + }); + + expect(wrapper.find('.metricValue__secondaryLine').text()).toBe('foo 12%'); + }); + + it('substitutes a value containing $ without treating it as a regex backreference', () => { + const wrapper = mount(MetricValue as any, { + props: { + title: 'Revenue', + value: '4,242', + secondaryValue: '$12', + secondaryLabel: '%s of total', + }, + }); + + expect(wrapper.find('.metricValue__secondaryLine').text()).toBe('$12 of total'); + }); + + it('preserves locale-significant whitespace in the value and translated label', () => { + const wrapper = mount(MetricValue as any, { + props: { + title: 'Visits', + value: '10,558', + secondaryValue: '9\u202F527', + secondaryLabel: '%s\u00A0visiteurs uniques', + }, + }); + + expect(wrapper.find('.metricValue__secondaryLine').element.textContent) + .toBe('9\u202F527\u00A0visiteurs uniques'); + }); + + it('leaves a placeholder-free title unchanged', () => { + const wrapper = mount(MetricValue as any, { + props: { + title: 'Conversions', + value: '190', + }, + }); + + expect(wrapper.find('.metricValue__title').text()).toBe('Conversions'); + }); + it('renders content passed to the evolution slot next to the value', () => { const wrapper = mount(MetricValue as any, { props: { diff --git a/app/plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.vue b/app/plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.vue index efb53ec04..217f88448 100644 --- a/app/plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.vue +++ b/app/plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.vue @@ -8,31 +8,32 @@ diff --git a/app/plugins/CoreVisualizations/vue/src/SingleMetricView/SingleMetricView.vue b/app/plugins/CoreVisualizations/vue/src/SingleMetricView/SingleMetricView.vue index 2a37e8ea1..4779c54af 100644 --- a/app/plugins/CoreVisualizations/vue/src/SingleMetricView/SingleMetricView.vue +++ b/app/plugins/CoreVisualizations/vue/src/SingleMetricView/SingleMetricView.vue @@ -277,7 +277,7 @@ export default defineComponent({ $(root.value as HTMLElement) .closest('div.widget') - .find('.widgetTop > .widgetName > span') + .find('.widgetName > span') .text(title); } @@ -374,7 +374,7 @@ export default defineComponent({ function createSeriesPicker() { const element = $(root.value as HTMLElement); - const $widgetName = element.closest('div.widget').find('.widgetTop > .widgetName'); + const $widgetName = element.closest('div.widget').find('.widgetName'); const $seriesPickerElem = $('
    '); diff --git a/app/plugins/CoreVisualizations/vue/src/Sparklines/DateAtom.less b/app/plugins/CoreVisualizations/vue/src/Sparklines/DateAtom.less new file mode 100644 index 000000000..fec02e482 --- /dev/null +++ b/app/plugins/CoreVisualizations/vue/src/Sparklines/DateAtom.less @@ -0,0 +1,15 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +.dateAtom { + color: @theme-color-text-lighter; + font-size: 12px; + line-height: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/app/plugins/CoreVisualizations/vue/src/Sparklines/DateAtom.spec.ts b/app/plugins/CoreVisualizations/vue/src/Sparklines/DateAtom.spec.ts new file mode 100644 index 000000000..22841d1a1 --- /dev/null +++ b/app/plugins/CoreVisualizations/vue/src/Sparklines/DateAtom.spec.ts @@ -0,0 +1,23 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +import { mount } from '@vue/test-utils'; +import DateAtom from './DateAtom.vue'; + +describe('CoreVisualizations/DateAtom', () => { + it('renders the label text', () => { + const wrapper = mount(DateAtom as any, { props: { label: 'Monday, May 4, 2026' } }); + + expect(wrapper.find('.dateAtom').text()).toBe('Monday, May 4, 2026'); + }); + + it('exposes the full label as a title so a clipped date stays recoverable on hover', () => { + const wrapper = mount(DateAtom as any, { props: { label: 'Sunday, May 3, 2026' } }); + + expect(wrapper.find('.dateAtom').attributes('title')).toBe('Sunday, May 3, 2026'); + }); +}); diff --git a/app/plugins/CoreVisualizations/vue/src/Sparklines/DateAtom.vue b/app/plugins/CoreVisualizations/vue/src/Sparklines/DateAtom.vue new file mode 100644 index 000000000..0a77cb0f4 --- /dev/null +++ b/app/plugins/CoreVisualizations/vue/src/Sparklines/DateAtom.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/app/plugins/CoreVisualizations/vue/src/Sparklines/DateComparison.less b/app/plugins/CoreVisualizations/vue/src/Sparklines/DateComparison.less new file mode 100644 index 000000000..95151c85e --- /dev/null +++ b/app/plugins/CoreVisualizations/vue/src/Sparklines/DateComparison.less @@ -0,0 +1,28 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +// Date-comparison body: a metric-name title and two compared-date columns side by side (split by a +// divider). The full-width sparkline below the columns is rendered by the shell (SparklineCard). + +// Card title = the metric name, matching MetricValue's title so both card bodies read the same. +// Clamped to one line so a long metric name can't grow the card. +.sparklineDateComparison__title { + color: @theme-color-text; + font-size: 18px; + line-height: 24px; + height: 24px; + margin-bottom: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +// The date columns come from the shared PeriodColumns component (PeriodColumns.less). It is +// margin-free, so this body owns the gap between the columns and the shell-rendered sparkline below. +.sparklineDateComparison .periodColumns { + margin-bottom: 12px; +} diff --git a/app/plugins/CoreVisualizations/vue/src/Sparklines/DateComparison.spec.ts b/app/plugins/CoreVisualizations/vue/src/Sparklines/DateComparison.spec.ts new file mode 100644 index 000000000..d864e4043 --- /dev/null +++ b/app/plugins/CoreVisualizations/vue/src/Sparklines/DateComparison.spec.ts @@ -0,0 +1,201 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +import { mount } from '@vue/test-utils'; + +// CoreHome has no jest module mapping, so virtual-mock it: Tooltips the directive the real +// MetricValue registers, and NumberFormatter to format numbers. The sparkline itself is rendered +// by the shell, not this body. ucfirst is an identity spy here; its casing behavior is +// covered by ucfirst.spec. +jest.mock('CoreHome', () => ({ + Tooltips: {}, + ucfirst: jest.fn((text?: string) => text ?? ''), + NumberFormatter: { + formatNumber: (value: number) => String(value), + }, +}), { virtual: true }); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const DateComparison = require('./DateComparison.vue').default; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const ucfirstMock = require('CoreHome').ucfirst as jest.Mock; + +function createWrapper(overrides = {}) { + const sparkline = { + url: '?module=API&action=get&columns=nb_visits&compareDates[]=2026-05-03', + metrics: { + 'Monday, May 4, 2026': [ + { + value: '10,558', + description: 'Visits', + title: 'Visits', + evolution: { + percent: '+0.5%', trend: 53, isLowerValueBetter: false, tooltip: 'since last period', + }, + }, + { value: '9,527', description: 'unique visitors', title: 'Unique visitors' }, + ], + 'Sunday, May 3, 2026': [ + { value: '12,558', description: 'Visits', title: 'Visits' }, + { value: '10,527', description: 'unique visitors', title: 'Unique visitors' }, + ], + }, + metricsOrder: ['Monday, May 4, 2026', 'Sunday, May 3, 2026'], + order: 1, + title: null, + group: '0', + seriesIndices: [0, 1], + graphParams: null, + ...overrides, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return mount(DateComparison as any, { props: { sparkline } }); +} + +describe('CoreVisualizations/DateComparison', () => { + const originalDocumentLanguage = document.documentElement.lang; + + beforeEach(() => { + document.documentElement.lang = 'en'; + ucfirstMock.mockClear(); + }); + + afterAll(() => { + document.documentElement.lang = originalDocumentLanguage; + }); + + it('renders the metric name as the card title', () => { + const wrapper = createWrapper(); + + expect(wrapper.find('.sparklineDateComparison__title').text()).toBe('Visits'); + }); + + it('capitalizes the metric title using the document language', () => { + document.documentElement.lang = 'tr'; + + createWrapper({ + metrics: { + 'Monday, May 4, 2026': [{ value: '10,558', description: 'istanbul', title: 'istanbul' }], + }, + metricsOrder: ['Monday, May 4, 2026'], + seriesIndices: [0], + }); + + expect(ucfirstMock).toHaveBeenCalledWith('istanbul', 'tr'); + }); + + it('exposes the full title as a title attribute so a clipped metric name stays recoverable', () => { + // The title is clamped to one line in DateComparison.less; the title attribute is the hover + // fallback, matching MetricValue on the no-comparison card. + const wrapper = createWrapper(); + + expect(wrapper.find('.sparklineDateComparison__title').attributes('title')).toBe('Visits'); + }); + + it('reads the card title from the first column in metricsOrder, not object-key order', () => { + // Bare-integer labels: JS reorders Object.keys to ['2025','2026'], so Object.values()[0] would + // read the 2025 column; metricsOrder pins the backend's first column (2026). (Real cards share + // one title across columns; distinct titles here only to prove which column is read.) + const wrapper = createWrapper({ + metrics: { + 2026: [{ value: '10,558', description: 'Visits', title: 'Visits 2026' }], + 2025: [{ value: '9,000', description: 'Visits', title: 'Visits 2025' }], + }, + metricsOrder: ['2026', '2025'], + seriesIndices: [0, 1], + }); + + expect(wrapper.find('.sparklineDateComparison__title').text()).toBe('Visits 2026'); + }); + + it('renders one column per compared date with its date label', () => { + const wrapper = createWrapper(); + + const labels = wrapper.findAll('.dateAtom').map((node) => node.text()); + expect(labels).toEqual(['Monday, May 4, 2026', 'Sunday, May 3, 2026']); + expect(wrapper.findAll('.periodColumns__column').length).toBe(2); + }); + + it('renders the primary and secondary value of each date column', () => { + const wrapper = createWrapper(); + + const columns = wrapper.findAll('.periodColumns__column'); + expect(columns[0].find('.metricValue__number').text()).toBe('10,558'); + expect(columns[0].find('.metricValue__secondaryLine').text()).toBe('9,527 unique visitors'); + expect(columns[1].find('.metricValue__number').text()).toBe('12,558'); + expect(columns[1].find('.metricValue__secondaryLine').text()).toBe('10,527 unique visitors'); + }); + + it('renders an EvolutionBadge only for the date that has evolution data', () => { + const wrapper = createWrapper(); + + const badges = wrapper.findAllComponents({ name: 'EvolutionBadge' }); + expect(badges.length).toBe(1); + expect(badges[0].props('percent')).toBe('+0.5%'); + expect(badges[0].props('trend')).toBe(53); + expect(badges[0].props('tooltip')).toBe('since last period'); + + // ...and it belongs to the first column. + const columns = wrapper.findAll('.periodColumns__column'); + expect(columns[0].findComponent({ name: 'EvolutionBadge' }).exists()).toBe(true); + expect(columns[1].findComponent({ name: 'EvolutionBadge' }).exists()).toBe(false); + }); + + it('coerces a null evolution tooltip to an empty string for the badge', () => { + const wrapper = createWrapper({ + metrics: { + 'Monday, May 4, 2026': [ + { + value: '10,558', + description: 'Visits', + title: 'Visits', + evolution: { + percent: '-2%', trend: -10, isLowerValueBetter: false, tooltip: null, + }, + }, + ], + }, + metricsOrder: ['Monday, May 4, 2026'], + seriesIndices: [0], + }); + + expect(wrapper.findComponent({ name: 'EvolutionBadge' }).props('tooltip')).toBe(''); + }); + + it('formats raw numeric metric values through NumberFormatter', () => { + const wrapper = createWrapper({ + metrics: { + 'Monday, May 4, 2026': [{ value: 10558, description: 'Visits', title: 'Visits' }], + }, + metricsOrder: ['Monday, May 4, 2026'], + seriesIndices: [0], + }); + + expect(wrapper.find('.metricValue__number').text()).toBe('10558'); + }); + + it('orders columns by metricsOrder, not object-key order, for bare-integer year labels', () => { + // Bare year labels: JS re-sorts integer-like keys, so Object.keys(metrics) yields + // ['2025','2026'] and swaps the columns; metricsOrder pins the backend order. + const wrapper = createWrapper({ + metrics: { + 2026: [{ value: '10,558', description: 'Visits', title: 'Visits' }], + 2025: [{ value: '9,000', description: 'Visits', title: 'Visits' }], + }, + metricsOrder: ['2026', '2025'], + seriesIndices: [0, 1], + }); + + const labels = wrapper.findAll('.dateAtom').map((node) => node.text()); + expect(labels).toEqual(['2026', '2025']); + + const columns = wrapper.findAll('.periodColumns__column'); + expect(columns[0].find('.metricValue__number').text()).toBe('10,558'); + expect(columns[1].find('.metricValue__number').text()).toBe('9,000'); + }); +}); diff --git a/app/plugins/CoreVisualizations/vue/src/Sparklines/DateComparison.vue b/app/plugins/CoreVisualizations/vue/src/Sparklines/DateComparison.vue new file mode 100644 index 000000000..59e83c1e7 --- /dev/null +++ b/app/plugins/CoreVisualizations/vue/src/Sparklines/DateComparison.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/app/plugins/CoreVisualizations/vue/src/Sparklines/NoComparison.spec.ts b/app/plugins/CoreVisualizations/vue/src/Sparklines/NoComparison.spec.ts index db675a6c1..f750cb63a 100644 --- a/app/plugins/CoreVisualizations/vue/src/Sparklines/NoComparison.spec.ts +++ b/app/plugins/CoreVisualizations/vue/src/Sparklines/NoComparison.spec.ts @@ -8,15 +8,12 @@ import { mount } from '@vue/test-utils'; // CoreHome is a package-style cross-plugin import with no jest module mapping, so it must be -// virtually mocked. Sparkline becomes a stub that declares its props (so they can be asserted), -// and Tooltips is the (no-op here) directive used by the real MetricValue this component mounts. +// virtually mocked. Tooltips is the (no-op here) directive used by the real MetricValue this +// component mounts. The sparkline itself is rendered by the shell, not this body. jest.mock('CoreHome', () => ({ Tooltips: {}, - Sparkline: { - name: 'Sparkline', - props: ['params', 'seriesIndices'], - template: '', - }, + // ucfirst is mocked as an identity passthrough; its capitalization is covered by ucfirst.spec. + ucfirst: (s?: string) => s ?? '', }), { virtual: true }); // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -49,6 +46,15 @@ describe('CoreVisualizations/NoComparison', () => { expect(metricValue.props('value')).toBe('10,558'); }); + it('gives MetricValue the fixed-height modifier so no-comparison cards line up', () => { + // The fixed height + one-line title clamp live on MetricValue via this modifier (defined in + // MetricValue.less), not reached into from here, so the class must ride on the MetricValue root. + const wrapper = createWrapper(makeSparkline()); + + expect(wrapper.findComponent({ name: 'MetricValue' }).classes()) + .toContain('metricValue--fixedHeight'); + }); + it('resolves the primary metric documentation by column and passes it to MetricValue', () => { const wrapper = createWrapper( makeSparkline(), @@ -81,7 +87,7 @@ describe('CoreVisualizations/NoComparison', () => { const metricValue = wrapper.findComponent({ name: 'MetricValue' }); expect(metricValue.props('secondaryValue')).toBe('9,527'); expect(metricValue.props('secondaryLabel')).toBe('unique'); - expect(wrapper.find('.metricValue__secondaryValue').text()).toBe('9,527'); + expect(wrapper.find('.metricValue__secondaryLine').text()).toBe('9,527 unique'); }); it('omits the secondary line when there is only one metric', () => { @@ -128,12 +134,4 @@ describe('CoreVisualizations/NoComparison', () => { expect(wrapper.findComponent({ name: 'EvolutionBadge' }).props('tooltip')).toBe(''); }); - - it('passes the sparkline url and series indices to the Sparkline', () => { - const wrapper = createWrapper(makeSparkline({ seriesIndices: [0, 1] })); - - const sparkline = wrapper.findComponent({ name: 'Sparkline' }); - expect(sparkline.props('params')).toBe('?module=API&action=get&columns=nb_visits'); - expect(sparkline.props('seriesIndices')).toEqual([0, 1]); - }); }); diff --git a/app/plugins/CoreVisualizations/vue/src/Sparklines/NoComparison.vue b/app/plugins/CoreVisualizations/vue/src/Sparklines/NoComparison.vue index 034c23a47..1c267f611 100644 --- a/app/plugins/CoreVisualizations/vue/src/Sparklines/NoComparison.vue +++ b/app/plugins/CoreVisualizations/vue/src/Sparklines/NoComparison.vue @@ -6,8 +6,9 @@ --> -
    - -
    diff --git a/app/plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonCard.less b/app/plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonCard.less new file mode 100644 index 000000000..54b28673b --- /dev/null +++ b/app/plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonCard.less @@ -0,0 +1,50 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +// Segment-comparison card: the metric name once, then a stacked block per compared segment. The card +// frame (border/padding/radius) is the shared `.sparklineCardFrame()` mixin (SparklineCard.less), applied +// to this block's own root; the per-segment rows are a nested block styled in SegmentComparisonRow.less. +// Frame selectors are qualified with `.sparkline` for the same reason as SparklineCard (JS contract + +// out-specifying the legacy global `.sparkline` spacing). +.widget .widgetContent .sparkline.sparklineSegmentComparisonCard, +.sparkline.sparklineSegmentComparisonCard { + .sparklineCardFrame(); +} + +// The whole card is the single click-to-evolution link (every segment reloads the same graph), so it +// carries the legacy `.sparkline` class for the JS wiring. Reuse the shared clickable-card treatment +// (block flow + focus-ring hover border) from SparklineCard.less — all sparkline `.less` compile in +// one pass and that file is registered first, so the mixin is in scope here. +.sparkline.sparklineSegmentComparisonCard { + .sparklineCardClickable(); +} + +// Card title = the metric name, matching the other card bodies (18px, clamped to one line so a long +// name can't grow the card; the full name shows on hover). +.sparklineSegmentComparisonCard__title { + color: @theme-color-text; + font-size: 18px; + line-height: 24px; + height: 24px; + margin-bottom: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +// Signals the title carries a documentation tooltip. +.sparklineSegmentComparisonCard__title--documented { + cursor: help; +} + +// Stacks the per-segment rows. The gap between rows is the card's layout concern (it owns the +// stack), so it lives here rather than on the row — no adjacent-sibling selector needed. +.sparklineSegmentComparisonCard__rows { + display: flex; + flex-direction: column; + gap: 16px; +} diff --git a/app/plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonCard.spec.ts b/app/plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonCard.spec.ts new file mode 100644 index 000000000..b5b63c83b --- /dev/null +++ b/app/plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonCard.spec.ts @@ -0,0 +1,225 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +import { mount } from '@vue/test-utils'; + +// The card mounts the real SegmentComparisonRow -> MetricValue + Sparkline chain and derives its +// data-graph-params via MatomoUrl.parse. CoreHome has no jest module mapping, so mock everything +// that chain pulls from it. ucfirst is an identity spy here; its casing behavior is covered by +// ucfirst.spec. +jest.mock('CoreHome', () => ({ + Tooltips: {}, + ucfirst: jest.fn((text?: string) => text ?? ''), + Sparkline: { + name: 'Sparkline', + props: ['params', 'seriesIndices', 'width', 'height'], + template: '', + }, + MatomoUrl: { + parse: (search: string) => { + const params: Record = {}; + new URLSearchParams(search).forEach((value, key) => { + params[key] = value; + }); + return params; + }, + }, + NumberFormatter: { + formatNumber: (value: number) => String(value), + }, +}), { virtual: true }); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const SegmentComparisonCard = require('./SegmentComparisonCard.vue').default; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const ucfirstMock = require('CoreHome').ucfirst as jest.Mock; + +function segment(title: string, seriesIndex: number, value: number) { + return { + url: '?module=API&action=get&columns=nb_visits', + metrics: { + 'Jan 12 - 17, 2012': [ + { + value, description: 'visits', title: 'Visits', column: 'nb_visits', + }, + { + value: 0, description: 'unique visitors', title: 'Unique visitors', column: 'nb_uniq_visitors', + }, + ], + }, + metricsOrder: ['Jan 12 - 17, 2012'], + order: seriesIndex, + title, + group: '0', + seriesIndices: [seriesIndex], + graphParams: null, + }; +} + +function createWrapper(props = {}) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return mount(SegmentComparisonCard as any, { + props: { + segments: [segment('All visits', 0, 10558), segment('Eu visitors', 1, 12558)], + ...props, + }, + }); +} + +describe('CoreVisualizations/SegmentComparisonCard', () => { + const originalDocumentLanguage = document.documentElement.lang; + + beforeEach(() => { + document.documentElement.lang = 'en'; + ucfirstMock.mockClear(); + }); + + afterAll(() => { + document.documentElement.lang = originalDocumentLanguage; + }); + + it('shows the metric name once as the card title, not repeated per row', () => { + const wrapper = createWrapper(); + + expect(wrapper.find('.sparklineSegmentComparisonCard__title').text()).toBe('Visits'); + // The rows use MetricValue with no title, so the metric name is not repeated. + expect(wrapper.findAll('.metricValue__title').length).toBe(0); + }); + + it('capitalizes the metric title using the document language', () => { + document.documentElement.lang = 'tr'; + const segments = [segment('All visits', 0, 10558), segment('Eu visitors', 1, 12558)]; + segments[0].metrics['Jan 12 - 17, 2012'][0].title = 'istanbul'; + + createWrapper({ segments }); + + expect(ucfirstMock).toHaveBeenCalledWith('istanbul', 'tr'); + }); + + it('is the single .sparkline click-to-evolution link, carrying the metric graph params', () => { + const wrapper = createWrapper(); + + expect(wrapper.classes()).toContain('sparkline'); + expect(wrapper.classes()).toContain('sparklineSegmentComparisonCard'); + expect(wrapper.classes()).not.toContain('notLinkable'); + // The rows are presentational, not links. + expect(wrapper.findAll('.sparklineSegmentComparisonRow.sparkline').length).toBe(0); + // Reload params come from the shared metric columns; series indices are the union per segment. + expect(wrapper.attributes('data-graph-params')).toBe('{"columns":"nb_visits"}'); + expect(wrapper.attributes('data-series-indices')).toBe('[0,1]'); + }); + + it('stacks one row per compared segment, each with its chip and value', () => { + const wrapper = createWrapper(); + + expect(wrapper.findAllComponents({ name: 'SegmentComparisonRow' }).length).toBe(2); + expect(wrapper.findAll('.sparklineSegmentComparisonRow__chip').map((node) => node.text())) + .toEqual(['All visits', 'Eu visitors']); + expect(wrapper.findAll('.metricValue__number').map((node) => node.text())) + .toEqual(['10558', '12558']); + }); + + it('surfaces the metric documentation as the card-title tooltip', () => { + // Columns are populated in segment comparison (unlike date comparison), so the doc resolves. + const wrapper = createWrapper({ allMetricsDocumentation: { nb_visits: 'The number of visits.' } }); + const title = wrapper.find('.sparklineSegmentComparisonCard__title'); + + expect(title.attributes('title')).toBe('The number of visits.'); + expect(title.classes()).toContain('sparklineSegmentComparisonCard__title--documented'); + }); + + it('falls back to the metric name as the title tooltip when undocumented', () => { + const title = createWrapper().find('.sparklineSegmentComparisonCard__title'); + + expect(title.attributes('title')).toBe('Visits'); + expect(title.classes()).not.toContain('sparklineSegmentComparisonCard__title--documented'); + }); + + it('marks the whole card notLinkable when sparklines are not linkable', () => { + const wrapper = createWrapper({ areSparklinesLinkable: false }); + + // The gate is on the card (the single link), not on individual rows. + expect(wrapper.classes()).toContain('notLinkable'); + expect(wrapper.findAll('.sparklineSegmentComparisonRow.notLinkable').length).toBe(0); + }); +}); + +// Segment + date: each segment entry carries two compared-date columns, and the entry's +// seriesIndices are period-major (segment s over periods -> [s, segmentCount + s]). +function segmentDate(title: string, seriesIndices: number[], values: [number, number]) { + return { + url: '?module=API&action=get&columns=nb_visits&comparePeriods[]=range', + metrics: { + 'Apr 23 - May 2, 2026': [ + { + value: values[0], + description: 'visits', + title: 'Visits', + column: '', + group: 'Apr 23 - May 2, 2026', + evolution: { + percent: '+28.5%', trend: 5000, isLowerValueBetter: false, tooltip: '', + }, + }, + ], + 'Mar 24 - Apr 2, 2026': [ + { + value: values[1], description: 'visits', title: 'Visits', column: '', group: 'Mar 24 - Apr 2, 2026', + }, + ], + }, + metricsOrder: ['Apr 23 - May 2, 2026', 'Mar 24 - Apr 2, 2026'], + order: seriesIndices[0], + title, + group: '0', + seriesIndices, + graphParams: null, + }; +} + +function createSegmentDateWrapper(props = {}) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return mount(SegmentComparisonCard as any, { + props: { + segments: [ + segmentDate('NZ visitors', [0, 2], [23558, 30119]), + segmentDate('Mobile users', [1, 3], [12049, 11748]), + ], + ...props, + }, + }); +} + +describe('CoreVisualizations/SegmentComparisonCard segment + date', () => { + it('stacks one row per segment, each with a value column per compared date', () => { + const wrapper = createSegmentDateWrapper(); + + expect(wrapper.findAllComponents({ name: 'SegmentComparisonRow' }).length).toBe(2); + // Two segments x two dates = four value columns; one date separator per row. + expect(wrapper.findAll('.periodColumns__column').length).toBe(4); + expect(wrapper.findAll('.periodColumns__separator').length).toBe(2); + expect(wrapper.findAll('.sparklineSegmentComparisonRow__chip').map((node) => node.text())) + .toEqual(['NZ visitors', 'Mobile users']); + }); + + it('carries the union of every segment\'s series indices for the one card-level link', () => { + const wrapper = createSegmentDateWrapper(); + + expect(wrapper.classes()).toContain('sparkline'); + expect(wrapper.attributes('data-series-indices')).toBe('[0,2,1,3]'); + }); + + it('shows no doc tooltip (column is empty in segment + date, matching date comparison)', () => { + const wrapper = createSegmentDateWrapper({ + allMetricsDocumentation: { nb_visits: 'The number of visits.' }, + }); + const title = wrapper.find('.sparklineSegmentComparisonCard__title'); + + expect(title.attributes('title')).toBe('Visits'); + expect(title.classes()).not.toContain('sparklineSegmentComparisonCard__title--documented'); + }); +}); diff --git a/app/plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonCard.vue b/app/plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonCard.vue new file mode 100644 index 000000000..2f7285525 --- /dev/null +++ b/app/plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonCard.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/app/plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonRow.less b/app/plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonRow.less new file mode 100644 index 000000000..1a3f5e11b --- /dev/null +++ b/app/plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonRow.less @@ -0,0 +1,61 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +// One compared segment: chip + value + its own sparkline. The row is presentational — the whole +// card is the single `.sparkline` click-to-evolution link (SegmentComparisonCard), so the row +// carries no legacy `.sparkline` class and needs none of its layout overrides. Inter-row spacing is +// owned by the card (SegmentComparisonCard.less `.sparklineSegmentComparisonCard__rows` gap). + +// Neutral pill around the compared segment's name, above the value readout. Hugs its text and +// clamps a long name to one line so it can't grow the card (full name on hover). No colour +// indicator — the sparkline PNG's own series colour carries the segment↔line link. +.sparklineSegmentComparisonRow__chip { + display: inline-block; + max-width: 100%; + box-sizing: border-box; + margin-bottom: 8px; + padding: 2px 8px; + border: 1px solid @theme-color-border-alternative; + border-radius: 6px; + color: @theme-color-text; + font-size: 12px; + line-height: 16px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: top; +} + +// The date columns come from the shared PeriodColumns component (PeriodColumns.less). It is +// margin-free; the gap before the sparkline is owned by `__sparkline`'s margin-top below, so +// segment-only spacing is unchanged. + +// Per-segment sparkline slot: same fixed height + fluid image cap as the no-comparison slot. The +// max-width must match in SegmentComparisonRow.vue (the PNG is rendered at 2x +// that): the base width for segment-only, the wider variant for segment + date. +.sparklineSegmentComparisonRow__sparkline { + height: 40px; + margin-top: 4px; + display: flex; + align-items: safe center; + + // override: the reused CoreHome Sparkline renders an external we can't + // rename; size it fluidly to the slot, capped at the base width. + .sparklineImg { + display: block; + width: 100%; + height: auto; + max-width: 380px; + } +} + +// Segment + date rows draw one series per compared date, so the sparkline is wider (matching the +// date-comparison card). Keep 380px / 760px in sync with sparklineWidth in SegmentComparisonRow.vue +// (the PNG is rendered at 2x these caps). +.sparklineSegmentComparisonRow__sparkline--wide .sparklineImg { + max-width: 760px; +} diff --git a/app/plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonRow.spec.ts b/app/plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonRow.spec.ts new file mode 100644 index 000000000..230f472cf --- /dev/null +++ b/app/plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonRow.spec.ts @@ -0,0 +1,172 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +import { mount } from '@vue/test-utils'; + +// The row mounts the real MetricValue (Tooltips directive, NumberFormatter) and the reused +// Sparkline. CoreHome has no jest module mapping, so mock it virtually. +jest.mock('CoreHome', () => ({ + Tooltips: {}, + // ucfirst is mocked as an identity passthrough; its capitalization is covered by ucfirst.spec. + ucfirst: (s?: string) => s ?? '', + Sparkline: { + name: 'Sparkline', + props: ['params', 'seriesIndices', 'width', 'height'], + template: '', + }, + NumberFormatter: { + formatNumber: (value: number) => String(value), + }, +}), { virtual: true }); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const SegmentComparisonRow = require('./SegmentComparisonRow.vue').default; + +function segment(overrides = {}) { + return { + url: '?module=API&action=get&columns=nb_visits&segment=continentCode==eur', + metrics: { + 'Jan 12 - 17, 2012': [ + { + value: 10558, description: 'visits', title: 'Visits', column: 'nb_visits', + }, + { + value: 9527, description: 'unique visitors', title: 'Unique visitors', column: 'nb_uniq_visitors', + }, + ], + }, + metricsOrder: ['Jan 12 - 17, 2012'], + order: 501, + title: 'Eu visitors', + group: '0', + seriesIndices: [1], + graphParams: null, + ...overrides, + }; +} + +function createWrapper(props = {}) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return mount(SegmentComparisonRow as any, { props: { segment: segment(), ...props } }); +} + +describe('CoreVisualizations/SegmentComparisonRow', () => { + it('renders the segment name as a chip, with the full name as a hover-recovery title', () => { + const chip = createWrapper().find('.sparklineSegmentComparisonRow__chip'); + + expect(chip.text()).toBe('Eu visitors'); + expect(chip.attributes('title')).toBe('Eu visitors'); + }); + + it('renders the primary and secondary metric values, formatting raw numbers', () => { + const wrapper = createWrapper(); + + expect(wrapper.find('.metricValue__number').text()).toBe('10558'); + expect(wrapper.find('.metricValue__secondaryLine').text()).toBe('9527 unique visitors'); + }); + + it('omits the MetricValue title (the card shows the metric name once above the rows)', () => { + expect(createWrapper().find('.metricValue__title').exists()).toBe(false); + }); + + it('renders its own single-series sparkline with the entry url and series index', () => { + const sparkline = createWrapper().findComponent({ name: 'Sparkline' }); + + expect(sparkline.props('params')).toBe( + '?module=API&action=get&columns=nb_visits&segment=continentCode==eur', + ); + expect(sparkline.props('seriesIndices')).toEqual([1]); + expect(sparkline.props('width')).toBe(380); + expect(sparkline.props('height')).toBe(40); + }); + + it('is a plain presentational block, not itself a .sparkline link (the card is the link)', () => { + const wrapper = createWrapper(); + + expect(wrapper.classes()).toContain('sparklineSegmentComparisonRow'); + expect(wrapper.classes()).not.toContain('sparkline'); + expect(wrapper.attributes('data-series-indices')).toBeUndefined(); + expect(wrapper.attributes('data-graph-params')).toBeUndefined(); + }); + + it('renders no EvolutionBadge (segment comparison carries no evolution)', () => { + expect(createWrapper().findComponent({ name: 'EvolutionBadge' }).exists()).toBe(false); + }); +}); + +// Segment + date: the same row, but the segment entry carries one value column per compared date +// (metricsOrder length > 1), a per-date evolution on the current period, and a multi-series sparkline. +function segmentDate(overrides = {}) { + return { + url: '?module=API&action=get&columns=nb_visits&segment=continentCode==eur&comparePeriods[]=range', + metrics: { + 'Apr 23 - May 2, 2026': [ + { + value: 23558, + description: 'visits', + title: 'Visits', + column: '', + group: 'Apr 23 - May 2, 2026', + evolution: { + percent: '+28.5%', trend: 5000, isLowerValueBetter: false, tooltip: 'more than before', + }, + }, + ], + 'Mar 24 - Apr 2, 2026': [ + { + value: 30119, description: 'visits', title: 'Visits', column: '', group: 'Mar 24 - Apr 2, 2026', + }, + ], + }, + metricsOrder: ['Apr 23 - May 2, 2026', 'Mar 24 - Apr 2, 2026'], + order: 501, + title: 'Eu visitors', + group: '0', + seriesIndices: [1, 3], + graphParams: null, + ...overrides, + }; +} + +function createSegmentDateWrapper(props = {}) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return mount(SegmentComparisonRow as any, { props: { segment: segmentDate(), ...props } }); +} + +describe('CoreVisualizations/SegmentComparisonRow segment + date', () => { + it('renders one value column per compared date, split by a separator', () => { + const wrapper = createSegmentDateWrapper(); + + expect(wrapper.findAll('.periodColumns__column')).toHaveLength(2); + expect(wrapper.findAll('.periodColumns__separator')).toHaveLength(1); + + const numbers = wrapper.findAll('.metricValue__number').map((n) => n.text()); + expect(numbers).toEqual(['23558', '30119']); + }); + + it('labels each column with its compared date (only shown when comparing more than one date)', () => { + const labels = createSegmentDateWrapper().findAll('.dateAtom').map((d) => d.text()); + + expect(labels).toEqual(['Apr 23 - May 2, 2026', 'Mar 24 - Apr 2, 2026']); + }); + + it('shows the evolution badge only on the period that carries evolution (the current date)', () => { + const wrapper = createSegmentDateWrapper(); + + expect(wrapper.findAllComponents({ name: 'EvolutionBadge' })).toHaveLength(1); + expect(wrapper.findComponent({ name: 'EvolutionBadge' }).props('percent')).toBe('+28.5%'); + }); + + it('renders a wider, multi-series sparkline (one series per compared date)', () => { + const wrapper = createSegmentDateWrapper(); + const sparkline = wrapper.findComponent({ name: 'Sparkline' }); + + expect(sparkline.props('seriesIndices')).toEqual([1, 3]); + expect(sparkline.props('width')).toBe(760); + expect(wrapper.find('.sparklineSegmentComparisonRow__sparkline--wide').exists()).toBe(true); + }); +}); diff --git a/app/plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonRow.vue b/app/plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonRow.vue new file mode 100644 index 000000000..989532998 --- /dev/null +++ b/app/plugins/CoreVisualizations/vue/src/Sparklines/SegmentComparisonRow.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/app/plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.less b/app/plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.less index 16070c003..7fc754043 100644 --- a/app/plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.less +++ b/app/plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.less @@ -4,14 +4,11 @@ * @link https://matomo.org * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later */ -// Card frame. .sparklineCard is a new class, so it's safe to style directly; the element keeps -// the legacy .sparkline class only for initializeSparklines() click-to-evolution wiring. -.widget .widgetContent .sparkline.sparklineCard, -.sparkline.sparklineCard { - // Override legacy `div.sparkline { display: flex }` (Morpheus main.less) so the card stacks in - // block flow. As a flex item its min-width:auto would size to the nowrap title, so long titles - // would overflow instead of truncating. - display: block; +// Shared card frame. Defined as a mixin (the parens keep it out of the compiled CSS) so every +// sparkline card block can apply the frame to its own root instead of borrowing another component's +// block class. Reused by SegmentComparisonCard.less: all sparkline `.less` compile in a single pass +// (StylesheetUIAssetMerger), and this file is registered before it, so the mixin is in scope there. +.sparklineCardFrame() { box-sizing: border-box; padding: 16px; background: @theme-color-widget-background; @@ -21,8 +18,25 @@ margin-top: 0; margin-bottom: 0; + // Query container for the metric readout: MetricValue's number + heights scale with this width via + // cqi units. Standalone readouts have no nearer container, so they size to the card; comparison + // columns set their own container and override it. inline-size contains only the inline axis, so + // the card's height still grows with its content. + container-type: inline-size; +} + +// Clickable-card treatment, shared by SparklineCard and SegmentComparisonCard. A mixin (parens keep +// it out of compiled CSS) applied to `.sparkline.`, so `&.linked` / `&:hover` resolve against +// the caller. Overrides two legacy Morpheus `div.sparkline` rules (main.less): `display: flex` → +// block (a flex item's min-width:auto would let a long nowrap title overflow instead of truncate), +// and the dashed grey `.linked:hover` bottom-border → a solid focus-ring border on all sides. Kept +// component-side (not excluded at the Morpheus source) because the hover must react to the runtime +// `.linked` class; theme tokens keep the colours right in light/dark. +.sparklineCardClickable() { + display: block; + // Clickable cards (initializeSparklines adds .linked): pointer cursor + hover border so they - // read as interactive. Theme tokens keep it correct in light and dark mode. + // read as interactive. &.linked { cursor: pointer; border-bottom-color: @theme-color-border-alternative; @@ -35,45 +49,49 @@ } } -// No-comparison body: metric value stacked over a full-width sparkline. -.noComparison { - // Sparkline dimensions. The height is the fixed slot height; the width is the base size the - // image scales from, and must match the passed in NoComparison.vue. - @_sparkline-height: 40px; - @_sparkline-width: 380px; +// The card carries the legacy `.sparkline` class because initializeSparklines (CoreHome sparkline.js) +// wires the click-to-evolution link by that selector — it's a JS contract, not decoration. Downside: +// global `.sparkline` rules (Morpheus/CoreHome, and Dashboard's `.widget .widgetContent .sparkline`) +// would override the frame's padding/margins. So we qualify each frame selector with `.sparkline` to +// out-specify them; without it the cards lose their padding when rendered as a widget. +.widget .widgetContent .sparkline.sparklineCard, +.sparkline.sparklineCard { + .sparklineCardFrame(); +} - // Fixed height (title 24 + primary 36 + secondary 24) so cards line up whether or not the - // optional secondary line is present. - .metricValue { - height: 84px; - margin-bottom: 12px; - } +// Clickable-card treatment (same `.sparkline.sparklineCard` compound as the frame above). +.sparkline.sparklineCard { + .sparklineCardClickable(); +} - // Clamp the title to one line so it can't grow the card; the full title shows on hover. - .metricValue__title { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - height: 24px; - } +// Shared sparkline slot, rendered by the shell after either body (both put the sparkline last). +// Fixed height keeps the card height constant regardless of the image; centering sits the image +// mid-slot once it scales shorter than the slot, and stops flex's default `stretch` from forcing +// it back to full height. `safe` keeps it reachable if it ever overflows. +.sparklineCard__sparkline { + // Displayed sparkline size: the height pins the slot; the width caps the fluid image so it never + // renders taller than the slot. The width must match the in SparklineCard.vue + // (the PNG is rendered at 2x that). Height stays 40 in both modes. + @_height: 40px; + @_width: 380px; - // Fixed-height slot keeps the card height constant regardless of the image. Centering the image - // sits it mid-slot once it becomes shorter than the slot; align-items also stops flex's default - // `stretch` from forcing the image back to full height. - .sparklineSlot { - height: @_sparkline-height; - display: flex; - align-items: center; - } + height: @_height; + display: flex; + align-items: safe center; - // Fluid width with height derived from the 380x40 aspect ratio (via the img's width/height - // attributes) so the sparkline scales without distortion, becoming shorter than the slot on - // narrow cards. Capping the width at the base stops it rendering taller than the fixed slot on - // very wide monitors. The 2x (760x80) source stays crisp as it scales. + // override: the reused CoreHome Sparkline renders an external we can't + // rename; size it fluidly to the slot (height from the width:40 aspect ratio, capped at the base + // width so it never exceeds the fixed slot). The 2x source stays crisp as it scales. .sparklineImg { display: block; width: 100%; height: auto; - max-width: @_sparkline-width; + max-width: @_width; } } + +// Comparison cards are wider, so their sparkline gets a wider cap to fill the extra room. 760 must +// match the wide in SparklineCard.vue (the PNG is rendered at 2x that). +.sparklineCard__sparkline--wide .sparklineImg { + max-width: 760px; +} diff --git a/app/plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.spec.ts b/app/plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.spec.ts index 72ff8ec03..fc89952df 100644 --- a/app/plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.spec.ts +++ b/app/plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.spec.ts @@ -11,9 +11,11 @@ import { mount } from '@vue/test-utils'; // directive) and the Sparkline. CoreHome has no jest module mapping, so mock it virtually. jest.mock('CoreHome', () => ({ Tooltips: {}, + // ucfirst is mocked as an identity passthrough; its capitalization is covered by ucfirst.spec. + ucfirst: (s?: string) => s ?? '', Sparkline: { name: 'Sparkline', - props: ['params', 'seriesIndices'], + props: ['params', 'seriesIndices', 'width', 'height'], template: '', }, // SparklineCard derives graph-params from the sparkline url; parse a query string like the real @@ -27,6 +29,10 @@ jest.mock('CoreHome', () => ({ return params; }, }, + // The DateComparison body (rendered for comparison entries) formats raw numeric metric values. + NumberFormatter: { + formatNumber: (value: number) => String(value), + }, }), { virtual: true }); // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -54,12 +60,38 @@ describe('CoreVisualizations/SparklineCard', () => { }); } + const comparisonSparkline = { + url: '?module=API&action=get&columns=nb_visits&compareDates[]=2026-05-03', + metrics: { + 'Monday, May 4, 2026': [{ value: '10,558', description: 'Visits', title: 'Visits' }], + 'Sunday, May 3, 2026': [{ value: '12,558', description: 'Visits', title: 'Visits' }], + }, + metricsOrder: ['Monday, May 4, 2026', 'Sunday, May 3, 2026'], + order: 1, + title: null, + group: '0', + seriesIndices: [0, 1], + graphParams: null, + }; + it('renders the no-comparison body and forwards the sparkline to it', () => { const wrapper = createWrapper(); const body = wrapper.findComponent({ name: 'NoComparison' }); expect(body.exists()).toBe(true); expect(body.props('sparkline')).toEqual(baseSparkline); + expect(wrapper.findComponent({ name: 'DateComparison' }).exists()).toBe(false); + }); + + it('renders the date-comparison body for entries carrying series indices', () => { + const wrapper = createWrapper(comparisonSparkline); + + const body = wrapper.findComponent({ name: 'DateComparison' }); + expect(body.exists()).toBe(true); + expect(body.props('sparkline')).toEqual(comparisonSparkline); + expect(wrapper.findComponent({ name: 'NoComparison' }).exists()).toBe(false); + expect(wrapper.find('.sparklineDateComparison__title').text()).toBe('Visits'); + expect(wrapper.findAll('.dateAtom').length).toBe(2); }); it('forwards allMetricsDocumentation to the body so the title shows the metric tooltip', () => { @@ -81,6 +113,33 @@ describe('CoreVisualizations/SparklineCard', () => { expect(wrapper.find('.sparkline-stub').exists()).toBe(true); }); + it('renders the shared sparkline slot, forwarding the entry url and series indices', () => { + // The shell owns the single sparkline for both bodies; here the comparison entry carries a + // series index per compared date. + const wrapper = createWrapper(comparisonSparkline); + + expect(wrapper.find('.sparklineCard__sparkline').exists()).toBe(true); + const sparkline = wrapper.findComponent({ name: 'Sparkline' }); + expect(sparkline.props('params')).toBe( + '?module=API&action=get&columns=nb_visits&compareDates[]=2026-05-03', + ); + expect(sparkline.props('seriesIndices')).toEqual([0, 1]); + }); + + it('sizes the shared sparkline per mode — wider (760) when comparing, 380 otherwise', () => { + const plain = createWrapper(); + const plainSparkline = plain.findComponent({ name: 'Sparkline' }); + expect(plainSparkline.props('width')).toBe(380); + expect(plainSparkline.props('height')).toBe(40); + expect(plain.find('.sparklineCard__sparkline--wide').exists()).toBe(false); + + const comparing = createWrapper(comparisonSparkline); + const comparingSparkline = comparing.findComponent({ name: 'Sparkline' }); + expect(comparingSparkline.props('width')).toBe(760); + expect(comparingSparkline.props('height')).toBe(40); + expect(comparing.find('.sparklineCard__sparkline--wide').exists()).toBe(true); + }); + it('does not render the segment title region in no-comparison mode', () => { const wrapper = createWrapper(); diff --git a/app/plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.vue b/app/plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.vue index 21b160198..785a614ae 100644 --- a/app/plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.vue +++ b/app/plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.vue @@ -12,36 +12,62 @@ :data-graph-params="graphParamsAttr" :data-series-indices="seriesIndicesAttr" > - +
    {{ sparkline.title }}
    + + +
    + +