Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/matomo-org/matomo.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThomas Steur <tsteur@users.noreply.github.com>2018-11-13 11:23:07 +0300
committerMatthieu Aubry <mattab@users.noreply.github.com>2018-11-13 11:23:07 +0300
commitac432359d9436d4ac3564b0c6c07558871356737 (patch)
treeb27c10d0959a4fd3ca4f4cc21388458130e06865
parente6a7ace51248f43d0ffa10e2e833bb79b1010182 (diff)
Update theme to reflect the new logo and brand color changes (#13629)
* update theme * fix alert box has no spacing * fix couple issues with new theme * improve contrast and make sure to use correct theme color * fix notification colour * fix wrong logo used * fix theme color * fix update title not readable
-rw-r--r--core/Plugin/ThemeStyles.php14
-rw-r--r--plugins/Annotations/javascripts/annotations.js13
-rw-r--r--plugins/Annotations/stylesheets/annotations.less9
-rw-r--r--plugins/Annotations/templates/getEvolutionIcons.twig2
-rw-r--r--plugins/CoreHome/angularjs/alert/alert.directive.less36
-rw-r--r--plugins/CoreHome/angularjs/notification/notification.directive.less24
-rw-r--r--plugins/CoreHome/images/favicon.pngbin526 -> 2233 bytes
-rw-r--r--plugins/CoreHome/javascripts/color_manager.js2
-rw-r--r--plugins/CoreHome/javascripts/manifest.json2
-rw-r--r--plugins/CoreHome/stylesheets/dataTable/_dataTable.less14
-rw-r--r--plugins/CoreHome/stylesheets/dataTable/_entityTable.less23
-rw-r--r--plugins/CoreHome/stylesheets/dataTable/_limitSelection.less34
-rw-r--r--plugins/CoreHome/stylesheets/dataTable/_rowActions.less5
-rw-r--r--plugins/CoreHome/stylesheets/layout.less28
-rw-r--r--plugins/CoreHome/stylesheets/zen-mode.less4
-rw-r--r--plugins/CoreHome/templates/_applePinnedTabIcon.twig2
-rw-r--r--plugins/CoreVisualizations/stylesheets/jqplot.css4
-rw-r--r--plugins/Dashboard/stylesheets/dashboard.less10
-rw-r--r--plugins/Dashboard/stylesheets/widget.less9
-rw-r--r--plugins/Installation/stylesheets/installation.css10
-rw-r--r--plugins/Installation/stylesheets/systemCheckPage.less4
-rw-r--r--plugins/Live/stylesheets/visitor_profile.less2
-rw-r--r--plugins/Login/templates/login.twig2
-rw-r--r--plugins/Marketplace/stylesheets/marketplace.less6
m---------plugins/Morpheus/icons0
-rw-r--r--plugins/Morpheus/images/logo-header.pngbin3233 -> 8030 bytes
-rw-r--r--plugins/Morpheus/images/logo.pngbin6734 -> 22654 bytes
-rw-r--r--plugins/Morpheus/images/logo.svg10
-rw-r--r--plugins/Morpheus/stylesheets/base/colors.less9
-rw-r--r--plugins/Morpheus/stylesheets/base/icons.css4
-rw-r--r--plugins/Morpheus/stylesheets/general/_forms.less33
-rw-r--r--plugins/Morpheus/stylesheets/main.less5
-rw-r--r--plugins/Morpheus/stylesheets/simple_structure.css10
-rw-r--r--plugins/Morpheus/stylesheets/ui/_buttons.less1
-rw-r--r--plugins/Morpheus/stylesheets/uibase/_header.less2
-rw-r--r--plugins/Morpheus/stylesheets/uibase/_headerMessage.less2
-rw-r--r--plugins/Morpheus/templates/layout.twig2
-rw-r--r--plugins/MultiSites/angularjs/dashboard/dashboard.directive.html3
-rw-r--r--plugins/MultiSites/angularjs/dashboard/dashboard.directive.less11
-rw-r--r--plugins/PrivacyManager/angularjs/manage-gdpr/managegdpr.directive.less4
-rw-r--r--plugins/ScheduledReports/templates/unsubscribe.twig2
-rw-r--r--plugins/SegmentEditor/stylesheets/segmentation.less8
-rw-r--r--plugins/UserCountry/stylesheets/userCountry.less4
-rw-r--r--tests/resources/customlogo/logo-header.pngbin806 -> 7709 bytes
-rw-r--r--tests/resources/customlogo/logo.pngbin1933 -> 11802 bytes
-rw-r--r--tests/resources/customlogo/logo.svg10
-rw-r--r--tests/resources/matomo.test.js15882
47 files changed, 16134 insertions, 127 deletions
diff --git a/core/Plugin/ThemeStyles.php b/core/Plugin/ThemeStyles.php
index f9b1ab5672..355ce1acfe 100644
--- a/core/Plugin/ThemeStyles.php
+++ b/core/Plugin/ThemeStyles.php
@@ -55,7 +55,7 @@ class ThemeStyles
/**
* @var string
*/
- public $colorBrand = '#d4291f';
+ public $colorBrand = '#43a047';
/**
* @var string
@@ -65,7 +65,7 @@ class ThemeStyles
/**
* @var string
*/
- public $colorText = '#0d0d0d';
+ public $colorText = '#212121';
/**
* @var string
@@ -80,12 +80,12 @@ class ThemeStyles
/**
* @var string
*/
- public $colorTextContrast = '#777';
+ public $colorTextContrast = '#37474f';
/**
* @var string
*/
- public $colorLink = '#4183C4';
+ public $colorLink = '#1976D2';
/**
* @var string
@@ -100,7 +100,7 @@ class ThemeStyles
/**
* @var string
*/
- public $colorHeaderBackground = '#37474f';
+ public $colorHeaderBackground = '#3450A3';
/**
* @var string
@@ -145,7 +145,7 @@ class ThemeStyles
/**
* @var string
*/
- public $colorBackgroundBase = '#edecec';
+ public $colorBackgroundBase = '#eff0f1';
/**
* @var string
@@ -196,7 +196,7 @@ class ThemeStyles
{
$this->colorMenuContrastText = $this->colorText;
$this->colorMenuContrastTextSelected = $this->colorMenuContrastText;
- $this->colorMenuContrastTextActive = $this->colorBrand;
+ $this->colorMenuContrastTextActive = $this->colorMenuContrastText;
$this->colorMenuContrastBackground = $this->colorBackgroundContrast;
$this->colorWidgetExportedBackgroundBase = $this->colorBackgroundContrast;
$this->colorWidgetTitleText = $this->colorText;
diff --git a/plugins/Annotations/javascripts/annotations.js b/plugins/Annotations/javascripts/annotations.js
index 0556b6027f..988690e9ea 100644
--- a/plugins/Annotations/javascripts/annotations.js
+++ b/plugins/Annotations/javascripts/annotations.js
@@ -459,7 +459,7 @@
date = date.split(',')[0];
}
- $('.evolution-annotations>span', domElem).each(function () {
+ $('.evolution-annotations>span[data-date]', domElem).each(function () {
if ($(this).attr('data-date') == date) {
// get counts from attributes (and convert them to ints)
var starredCount = +$(this).attr('data-starred'),
@@ -467,12 +467,11 @@
// modify the starred count & make sure the correct image is used
var newStarCount = starredCount + starAmt;
+ var newAnno = 'icon-annotation';
if (newStarCount > 0) {
- var newImg = 'plugins/Morpheus/images/annotations_starred.png';
- } else {
- var newImg = 'plugins/Morpheus/images/annotations.png';
+ newAnno += ' starred';
}
- $(this).attr('data-starred', newStarCount).find('img').attr('src', newImg);
+ $(this).attr('data-starred', newStarCount).find('span').attr('class', newAnno);
// modify the annotation count & hide/show based on new count
var newCount = annotationCount + amt;
@@ -575,12 +574,12 @@
// if no graph available, hide all icons
if (!canvases || canvases.length == 0) {
- $('span', annotations).hide();
+ $('span[data-date]', annotations).hide();
return true;
}
// set position of each individual icon
- $('span', annotations).each(function (i) {
+ $('span[data-date]', annotations).each(function (i) {
var canvas = $(canvases[i]),
canvasCenterX = canvas.position().left + (canvas.width() / 2);
$(this).css({
diff --git a/plugins/Annotations/stylesheets/annotations.less b/plugins/Annotations/stylesheets/annotations.less
index 6270ebbe05..74ede38ba3 100644
--- a/plugins/Annotations/stylesheets/annotations.less
+++ b/plugins/Annotations/stylesheets/annotations.less
@@ -3,6 +3,15 @@
height: 18px;
width: 100%;
cursor: pointer;
+
+ .icon-annotation {
+ font-size: 16px;
+ color: #666666;
+ }
+
+ .icon-annotation.starred {
+ color: @theme-color-brand;
+ }
}
.evolution-annotations > span {
diff --git a/plugins/Annotations/templates/getEvolutionIcons.twig b/plugins/Annotations/templates/getEvolutionIcons.twig
index e024f3bef5..f4a26700c4 100644
--- a/plugins/Annotations/templates/getEvolutionIcons.twig
+++ b/plugins/Annotations/templates/getEvolutionIcons.twig
@@ -8,7 +8,7 @@
(counts.note|e('html_attr')))|raw }}
{{ 'Annotations_ClickToEditOrAdd'|translate }}"
{% else %}}title="{{ 'Annotations_ViewAndAddAnnotations'|translate(date) }}"{% endif %}>
- <img src="plugins/Morpheus/images/{% if counts.starred > 0 %}annotations_starred.png{% else %}annotations.png{% endif %}" width="16" height="16"/>
+ <span class="icon-annotation {% if counts.starred > 0 %}starred{% endif %}"/>
</span>
{% endfor %}
</div>
diff --git a/plugins/CoreHome/angularjs/alert/alert.directive.less b/plugins/CoreHome/angularjs/alert/alert.directive.less
index 45d9f84fa4..6d1dc174cb 100644
--- a/plugins/CoreHome/angularjs/alert/alert.directive.less
+++ b/plugins/CoreHome/angularjs/alert/alert.directive.less
@@ -29,21 +29,21 @@
}
body #content .alert-success p {
- color: #009874;
+ color: @color-green-piwik;
}
.alert-success {
- color: #009874;
- border-color: #1AA282;
+ color: @color-green-piwik;
+ border-color: @color-green-piwik;
&:before {
content: "\e63d";
- color: #1AA282;
+ color: @color-green-piwik;
}
p {
- color: #009874;
+ color: @color-green-piwik;
}
a {
- color: #009874;
+ color: @color-green-piwik;
text-decoration: underline;
&:hover {
text-decoration: underline;
@@ -78,21 +78,21 @@ body #content .alert-info p {
}
body #content .alert-warning p {
- color: #CA8100;
+ color: #fbf7f1;
}
.alert-warning {
- color: #CA8100;
- border-color: #DF9D27;
+ color: #f57c00;
+ border-color: #f57c00;
&:before {
content: "\e621";
- color: #DF9D27;
+ color: #f57c00;
}
p {
- color: #CA8100;
+ color: #f57c00;
}
a {
- color: #CA8100;
+ color: #f57c00;
text-decoration: underline;
&:hover {
text-decoration: underline;
@@ -101,21 +101,21 @@ body #content .alert-warning p {
}
body #content .alert-danger p {
- color: #D4291F;
+ color: @color-red-piwik;
}
.alert-danger {
- color: #D4291F;
- border-color: #D73F36;
+ color: @color-red-piwik;
+ border-color: @color-red-piwik;
&:before {
content: "\e616";
- color: #D73F36;
+ color: @color-red-piwik;
}
p {
- color: #D4291F;
+ color: @color-red-piwik;
}
a {
- color: #D4291F;
+ color: @color-red-piwik;
text-decoration: underline;
&:hover {
text-decoration: underline;
diff --git a/plugins/CoreHome/angularjs/notification/notification.directive.less b/plugins/CoreHome/angularjs/notification/notification.directive.less
index cb47f28c10..4b27802a5a 100644
--- a/plugins/CoreHome/angularjs/notification/notification.directive.less
+++ b/plugins/CoreHome/angularjs/notification/notification.directive.less
@@ -23,19 +23,35 @@
&.notification-success {
.alert-success;
- background-color: #e8f5e9;
+ color: #eef6ef !important;
+ background-color: @color-green-piwik !important;
+ &:before, p, a {
+ color: #eef6ef;
+ }
}
&.notification-warning {
.alert-warning;
- background-color: #fff3e0;
+ background-color: #f57c00;
+ color: #fbf7f1 !important;
+ &:before, p, a {
+ color: #fbf7f1;
+ }
}
&.notification-danger,
&.notification-error {
.alert-danger;
- background-color: #ffebee;
+ color: #fdf0f2 !important;
+ background-color: #e53935;
+ &:before, p, a {
+ color: #fdf0f2;
+ }
}
&.notification-info {
.alert-info;
- background-color: #e3f2fd;
+ color: #f3feff !important;
+ background-color: #00bcd4;
+ &:before, p, a {
+ color: #f3feff;
+ }
}
} \ No newline at end of file
diff --git a/plugins/CoreHome/images/favicon.png b/plugins/CoreHome/images/favicon.png
index e47d348e82..7e21902777 100644
--- a/plugins/CoreHome/images/favicon.png
+++ b/plugins/CoreHome/images/favicon.png
Binary files differ
diff --git a/plugins/CoreHome/javascripts/color_manager.js b/plugins/CoreHome/javascripts/color_manager.js
index cffff2c7be..deaa506aa7 100644
--- a/plugins/CoreHome/javascripts/color_manager.js
+++ b/plugins/CoreHome/javascripts/color_manager.js
@@ -13,7 +13,7 @@
"darkblue":"#00008b","darkcyan":"#008b8b","darkgoldenrod":"#b8860b","darkgray":"#a9a9a9","darkgreen":"#006400","darkkhaki":"#bdb76b","darkmagenta":"#8b008b","darkolivegreen":"#556b2f",
"darkorange":"#ff8c00","darkorchid":"#9932cc","darkred":"#8b0000","darksalmon":"#e9967a","darkseagreen":"#8fbc8f","darkslateblue":"#483d8b","darkslategray":"#2f4f4f","darkturquoise":"#00ced1",
"darkviolet":"#9400d3","deeppink":"#ff1493","deepskyblue":"#00bfff","dimgray":"#696969","dodgerblue":"#1e90ff",
- "firebrick":"#b22222","floralwhite":"#fffaf0","forestgreen":"#228b22","fuchsia":"#ff00ff","gainsboro":"#dcdcdc","ghostwhite":"#f8f8ff","gold":"#ffd700","goldenrod":"#daa520","gray":"#808080","green":"#008000","greenyellow":"#adff2f",
+ "firebrick":"#b22222","floralwhite":"#fffaf0","forestgreen":"#228b22","fuchsia":"#ff00ff","gainsboro":"#dcdcdc","ghostwhite":"#f8f8ff","gold":"#ffd700","goldenrod":"#daa520","gray":"#808080","green":"#43a047","greenyellow":"#adff2f",
"honeydew":"#f0fff0","hotpink":"#ff69b4","indianred ":"#cd5c5c","indigo ":"#4b0082","ivory":"#fffff0","khaki":"#f0e68c",
"lavender":"#e6e6fa","lavenderblush":"#fff0f5","lawngreen":"#7cfc00","lemonchiffon":"#fffacd","lightblue":"#add8e6","lightcoral":"#f08080","lightcyan":"#e0ffff","lightgoldenrodyellow":"#fafad2",
"lightgrey":"#d3d3d3","lightgreen":"#90ee90","lightpink":"#ffb6c1","lightsalmon":"#ffa07a","lightseagreen":"#20b2aa","lightskyblue":"#87cefa","lightslategray":"#778899","lightsteelblue":"#b0c4de",
diff --git a/plugins/CoreHome/javascripts/manifest.json b/plugins/CoreHome/javascripts/manifest.json
index 1723675206..7314adde61 100644
--- a/plugins/CoreHome/javascripts/manifest.json
+++ b/plugins/CoreHome/javascripts/manifest.json
@@ -37,7 +37,7 @@
"display": "standalone",
"orientation": "portrait",
"background_color": "#edecec",
- "theme_color": "#37474f",
+ "theme_color": "#3450A3",
"prefer_related_applications": true,
"related_applications": [
{
diff --git a/plugins/CoreHome/stylesheets/dataTable/_dataTable.less b/plugins/CoreHome/stylesheets/dataTable/_dataTable.less
index 0659b508c6..a74d62b875 100644
--- a/plugins/CoreHome/stylesheets/dataTable/_dataTable.less
+++ b/plugins/CoreHome/stylesheets/dataTable/_dataTable.less
@@ -66,7 +66,7 @@ table.dataTable th {
// for dataTables in exported widgets ( we do not want to apply this for widgets in dashboard )
body>.widget table.dataTable {
th {
- background: @theme-color-background-tinyContrast;
+ background: @theme-color-background-base;
}
}
@@ -76,7 +76,9 @@ body>.widget table.dataTable {
width: ~"calc(100% + 40px)";
th {
- background: @theme-color-background-tinyContrast;
+ background: @theme-color-background-base !important;
+ border-radius: 0;
+ color: @theme-color-text-contrast;
&:first-child {
padding-left: 20px;
@@ -492,7 +494,7 @@ table.dataTable td.label img {
tr td.label img.plusMinus {
margin-left: -3px;
margin-right: 3px;
- margin-top: -3px;
+ margin-top: 0;
}
.pk-emptyDataTable {
@@ -576,10 +578,12 @@ td.cellSubDataTable .loadingPiwik {
vertical-align: middle;
text-decoration: none !important;
text-align: center;
- color: #adadad;
font-size: 16px;
+ color: @theme-color-brand !important;
+ opacity: 0.7;
&:hover {
- color: #333;
+ color: @theme-color-brand !important;
+ opacity: 1;
background-color: @theme-color-background-base;
text-decoration: none !important;
}
diff --git a/plugins/CoreHome/stylesheets/dataTable/_entityTable.less b/plugins/CoreHome/stylesheets/dataTable/_entityTable.less
index 4326119505..79cb150078 100644
--- a/plugins/CoreHome/stylesheets/dataTable/_entityTable.less
+++ b/plugins/CoreHome/stylesheets/dataTable/_entityTable.less
@@ -9,7 +9,7 @@ table.entityTable {
}
text-transform: none;
- background-color: @theme-color-background-tinyContrast;
+ background: @theme-color-background-base;
vertical-align: middle !important;
text-align: left !important;
font-size: 15px;
@@ -19,7 +19,7 @@ table.entityTable {
margin: 0;
font-weight: normal;
border-radius: 0;
- color: @theme-color-text;
+ color: @theme-color-text-contrast;
}
}
}
@@ -100,11 +100,13 @@ table.entityTable {
.tableActionBar {
a,
button {
- height: 54px;
- line-height: 54px;
- box-shadow: none;
- background-color: rgba(0, 0, 0, 0);
- color: @theme-color-text !important;
+ height: 36px;
+ line-height: 36px;
+ box-shadow: 0 2px 5px 0 rgba(0,0,0,0.16), 0 2px 10px 0 rgba(0,0,0,0.12) !important;
+ background-color: @color-silver-l60;
+ color: @theme-color-brand-contrast;
+ background-image: none !important;
+ font-size: 12px;
cursor: pointer;
text-decoration: none;
background-color: inherit;
@@ -112,9 +114,10 @@ table.entityTable {
letter-spacing: .5px;
transition: .2s ease-out;
border: none;
- border-radius: 2px;
+ border-radius: 3px;
display: inline-block;
padding: 0 2rem;
+ margin-left: 16px;
text-transform: uppercase;
vertical-align: middle;
-webkit-tap-highlight-color: transparent;
@@ -122,4 +125,8 @@ table.entityTable {
text-decoration: underline;
}
}
+
+ button:first-child, a:first-child {
+ background-color: @theme-color-brand !important;
+ }
}
diff --git a/plugins/CoreHome/stylesheets/dataTable/_limitSelection.less b/plugins/CoreHome/stylesheets/dataTable/_limitSelection.less
index ab678b427a..9486e875cf 100644
--- a/plugins/CoreHome/stylesheets/dataTable/_limitSelection.less
+++ b/plugins/CoreHome/stylesheets/dataTable/_limitSelection.less
@@ -17,14 +17,23 @@
.select-wrapper:hover {
background-color: #edecec;
border-radius: 2px;
- color: #333;
+ color: @theme-color-brand !important;
cursor: pointer;
- input.select-dropdown {
- color: #333;
- }
+ input.select-dropdown,
span.caret {
- color: #333 !important;
+ color: @theme-color-brand !important;
+ opacity: 1;
+ &:hover {
+ color: @theme-color-brand !important;
+ opacity: 1;
+ }
+ }
+
+ input.select-dropdown {
+ &:hover {
+ border-color: @theme-color-brand !important;
+ }
}
}
@@ -39,16 +48,23 @@
overflow: hidden;
border: 0;
transition: all 0s;
- color: #adadad;
+ color: @theme-color-brand !important;
+ opacity: 0.7;
&:hover {
- color: #333;
- border-color: #333;
+ color: @theme-color-brand !important;
+ opacity: 1;
+ border-color: @theme-color-brand !important;
}
}
span.caret {
top: 11px;
right: 4px;
- color: #adadad;
+ color: @theme-color-brand !important;
+ opacity: 0.7;
+ &:hover {
+ color: @theme-color-brand !important;
+ opacity: 1;
+ }
}
}
diff --git a/plugins/CoreHome/stylesheets/dataTable/_rowActions.less b/plugins/CoreHome/stylesheets/dataTable/_rowActions.less
index d6c445883e..8353101ef8 100644
--- a/plugins/CoreHome/stylesheets/dataTable/_rowActions.less
+++ b/plugins/CoreHome/stylesheets/dataTable/_rowActions.less
@@ -16,9 +16,10 @@ table.dataTable .dataTableRowActions a {
.rowActionIcon {
font-size: 19px;
- color: @color-gray;
+ color: @theme-color-brand !important;
+ opacity: 0.6;
&:hover {
- color: @theme-color-text;
+ opacity: 1;
}
}
@media print{
diff --git a/plugins/CoreHome/stylesheets/layout.less b/plugins/CoreHome/stylesheets/layout.less
index 20bbc07f4b..cb125bc50f 100644
--- a/plugins/CoreHome/stylesheets/layout.less
+++ b/plugins/CoreHome/stylesheets/layout.less
@@ -40,6 +40,10 @@ nav {
}
}
+ ul.right.hide-on-med-and-down {
+ margin-right: 4px;
+ }
+
.languageSelection {
&:hover {
background-color: rgba(0,0,0,0.1);
@@ -93,8 +97,12 @@ nav {
#root {
.pageWrap {
- padding-left: 16px;
- padding-right: 16px;
+ padding-left: 18px;
+ padding-right: 18px;
+ }
+
+ #secondNavBar {
+ box-shadow: none !important;
}
#secondNavBar + .pageWrap {
@@ -213,6 +221,7 @@ nav {
&:hover, &:focus {
text-decoration: none;
color: @theme-color-menu-contrast-textActive;
+ background-color: @theme-color-background-base;
}
}
@@ -282,7 +291,7 @@ nav {
> .item {
cursor: pointer;
&:hover, &:focus {
- color: @theme-color-menu-contrast-textActive;
+ color: @theme-color-header-background;
}
}
@@ -353,8 +362,8 @@ nav {
}
.piwikTopControl {
- margin-top: 8px;
- margin-bottom: 8px;
+ margin-top: 10px;
+ margin-bottom: 10px;
}
}
}
@@ -372,7 +381,7 @@ nav {
display: inline-block;
float: none;
position: relative;
- margin-right: 12px;
+ margin-right: 14px;
vertical-align: top;
font-size: 11px;
}
@@ -446,3 +455,10 @@ nav {
margin-bottom: -20px !important;
}
}
+
+.z-depth-1, nav, .card-panel, .card, .toast, .btn, .btn-large, .btn-floating, .dropdown-content, .collapsible, .side-nav {
+ box-shadow: @default-box-shade;
+}
+#root .borderedControl, #standalone .borderedControl, .ui-dialog .borderedControl, .ngdialog .borderedControl {
+ box-shadow: @default-box-shade;
+} \ No newline at end of file
diff --git a/plugins/CoreHome/stylesheets/zen-mode.less b/plugins/CoreHome/stylesheets/zen-mode.less
index e80b9fc12e..944e0260a7 100644
--- a/plugins/CoreHome/stylesheets/zen-mode.less
+++ b/plugins/CoreHome/stylesheets/zen-mode.less
@@ -31,8 +31,8 @@
font-weight: bold !important;
}
tr td {
- padding-top: 10px;
- padding-bottom: 10px;
+ padding-top: 11px;
+ padding-bottom: 11px;
}
}
diff --git a/plugins/CoreHome/templates/_applePinnedTabIcon.twig b/plugins/CoreHome/templates/_applePinnedTabIcon.twig
index 55a72c7ef1..5ca38d9322 100644
--- a/plugins/CoreHome/templates/_applePinnedTabIcon.twig
+++ b/plugins/CoreHome/templates/_applePinnedTabIcon.twig
@@ -1,4 +1,4 @@
{% if isCustomLogo and customFavicon is defined and customFavicon %}
{% else %}
- <link rel="mask-icon" href="plugins/CoreHome/images/applePinnedTab.svg" color="#d4291f">
+ <link rel="mask-icon" href="plugins/CoreHome/images/applePinnedTab.svg" color="#3450A3">
{% endif %}
diff --git a/plugins/CoreVisualizations/stylesheets/jqplot.css b/plugins/CoreVisualizations/stylesheets/jqplot.css
index 41ffa2f960..2be3bda099 100644
--- a/plugins/CoreVisualizations/stylesheets/jqplot.css
+++ b/plugins/CoreVisualizations/stylesheets/jqplot.css
@@ -178,11 +178,11 @@ a.rowevolution-startmulti {
}
.rowevolution table.metrics td.text span.good {
- color: #008000;
+ color: #43a047;
}
.rowevolution table.metrics td.text span.bad {
- color: #f00;
+ color: #D4291F;
}
.rowevolution .metric-selectbox,
diff --git a/plugins/Dashboard/stylesheets/dashboard.less b/plugins/Dashboard/stylesheets/dashboard.less
index 7269aa23ed..80d1583f1a 100644
--- a/plugins/Dashboard/stylesheets/dashboard.less
+++ b/plugins/Dashboard/stylesheets/dashboard.less
@@ -1,5 +1,5 @@
#dashboard {
- margin: 0 -6px;
+ margin: 0 -8px;
}
body#standalone {
@@ -17,11 +17,11 @@ body#standalone {
#dashboardWidgetsArea > .col {
min-height: 100px;
// Customize Bootstrap gutter between columns
- padding-right: 6px;
- padding-left: 6px;
+ padding-right: 8px;
+ padding-left: 8px;
>.sortable {
- padding: 5px 0 8px 0;
+ padding: 5px 0 13px 0;
}
}
}
@@ -425,7 +425,7 @@ div.widgetpreview-preview {
.ui-button, .ui-dialog-titlebar-close {
.btn;
color: @theme-color-brand-contrast !important;
- background-color: @theme-color-brand !important;
+ background-color: @color-red-piwik !important;
&:hover {
background: none;
background-color: transparent;
diff --git a/plugins/Dashboard/stylesheets/widget.less b/plugins/Dashboard/stylesheets/widget.less
index 7bf51ba107..f4a53bdb00 100644
--- a/plugins/Dashboard/stylesheets/widget.less
+++ b/plugins/Dashboard/stylesheets/widget.less
@@ -1,13 +1,13 @@
.widget {
.font-default(13px, 18px);
background: @theme-color-widget-background;
- border: 1px solid @theme-color-widget-border;
border-radius: 2px;
position: relative;
- box-shadow: 0 1px 1px 0 rgba(0,0,0,.14),0 1px 1px -1px rgba(0,0,0,.2),0 1px 1px 0 rgba(0,0,0,.12);
+ box-shadow: @default-box-shade;
+ border: 1px solid @theme-color-widget-background !important;
&:hover, &:focus {
- box-shadow: 0 1px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12);
+ box-shadow: @default-box-shade;
}
h2 {
@@ -22,7 +22,7 @@
margin: 0;
color: @theme-color-text;
text-shadow: none;
- padding: 15px 15px 10px 12px;
+ padding: 22px 15px 16px 12px !important;
}
p {
margin-left: 10px;
@@ -58,6 +58,7 @@
position: absolute;
padding-left: 50px;
display: none;
+ top: 7px;
color: @theme-color-widget-title-text;
background: -moz-linear-gradient(left, rgba(255,255,255,0) 0%, @theme-color-widget-title-background 45px);
background: -webkit-linear-gradient(left,rgba(255,255,255,0) 0%, @theme-color-widget-title-background 45px);
diff --git a/plugins/Installation/stylesheets/installation.css b/plugins/Installation/stylesheets/installation.css
index 74c219cbd0..1c8aa7dc48 100644
--- a/plugins/Installation/stylesheets/installation.css
+++ b/plugins/Installation/stylesheets/installation.css
@@ -22,8 +22,12 @@ body {
margin-bottom: 20px;
}
+#installation .form-group .dropdown-content li>a, .dropdown-content li>span {
+ color: #43a047;
+}
+
.header .languageSelection {
- color: #4183C4;
+ color: #1976D2;
font-size: 13px;
}
@@ -54,7 +58,7 @@ body {
}
.installation-progress h4 small {
float: right;
- color: #d4291f;
+ color: #43a047;
}
.installation-progress .progress {
margin-bottom: 0;
@@ -106,7 +110,7 @@ p.next-step:first-child {
}
.system-check .icon-ok, .system-check-legend .icon-ok {
- color: #1AA282;
+ color: #43a047;
margin-right: 10px;
}
.system-check .icon-warning, .system-check-legend .icon-warning {
diff --git a/plugins/Installation/stylesheets/systemCheckPage.less b/plugins/Installation/stylesheets/systemCheckPage.less
index 05c97b2cdb..267b67ab49 100644
--- a/plugins/Installation/stylesheets/systemCheckPage.less
+++ b/plugins/Installation/stylesheets/systemCheckPage.less
@@ -19,7 +19,7 @@
}
.system-check .icon-ok {
- color: #1AA282;
+ color: @color-green-piwik;
margin-right: 10px;
}
.system-check .icon-warning {
@@ -47,7 +47,7 @@
}
.system-success {
- color: #1AA282 !important;
+ color: @color-green-piwik !important;
font-weight: bold;
}
diff --git a/plugins/Live/stylesheets/visitor_profile.less b/plugins/Live/stylesheets/visitor_profile.less
index 72cc5a7453..770a013070 100644
--- a/plugins/Live/stylesheets/visitor_profile.less
+++ b/plugins/Live/stylesheets/visitor_profile.less
@@ -97,7 +97,7 @@
&:before {
padding: 4.5px;
content: "\e60a";
- background-color: #d4291f;
+ background-color: @color-red-piwik;
color: #fff;
border-radius: 3px;
font-size: 9px;
diff --git a/plugins/Login/templates/login.twig b/plugins/Login/templates/login.twig
index 3ef83e7afa..abe2e2ae46 100644
--- a/plugins/Login/templates/login.twig
+++ b/plugins/Login/templates/login.twig
@@ -27,7 +27,7 @@
</div>
<nav>
<div class="nav-wrapper">
- {% include "@CoreHome/_logo.twig" with { 'logoLink': 'https://matomo.org', 'centeredLogo': true, 'useLargeLogo': true } %}
+ {% include "@CoreHome/_logo.twig" with { 'logoLink': 'https://matomo.org', 'centeredLogo': true, 'useLargeLogo': false } %}
</div>
</nav>
diff --git a/plugins/Marketplace/stylesheets/marketplace.less b/plugins/Marketplace/stylesheets/marketplace.less
index e5302fa270..eedf9e7ec9 100644
--- a/plugins/Marketplace/stylesheets/marketplace.less
+++ b/plugins/Marketplace/stylesheets/marketplace.less
@@ -1,14 +1,14 @@
.subscriptionOverview {
.icon-error, .errorMessage {
- color: #D4291F;
+ color: @color-red-piwik;
}
.icon-ok {
- color: #009874;
+ color: @color-green-piwik;
}
.icon-warning {
- color: #CA8100;
+ color: #fbf7f1;
}
.subscriptionName,
.subscriptionType,
diff --git a/plugins/Morpheus/icons b/plugins/Morpheus/icons
-Subproject 2f972982f571730178cec0e4f87e9f7b4eed3a8
+Subproject c37c3dfd643d4202897d3bf47d469251e4062eb
diff --git a/plugins/Morpheus/images/logo-header.png b/plugins/Morpheus/images/logo-header.png
index bd6aac3b5c..234d96a75a 100644
--- a/plugins/Morpheus/images/logo-header.png
+++ b/plugins/Morpheus/images/logo-header.png
Binary files differ
diff --git a/plugins/Morpheus/images/logo.png b/plugins/Morpheus/images/logo.png
index 86d1a7a412..24e59ee096 100644
--- a/plugins/Morpheus/images/logo.png
+++ b/plugins/Morpheus/images/logo.png
Binary files differ
diff --git a/plugins/Morpheus/images/logo.svg b/plugins/Morpheus/images/logo.svg
index 9bcd0531cd..809c79a91a 100644
--- a/plugins/Morpheus/images/logo.svg
+++ b/plugins/Morpheus/images/logo.svg
@@ -1,10 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
- width="5836" height="1024" viewBox="0 0 5836 1024"
- enable-background="new 0 0 5836 1024" xml:space="preserve" version="1.1" id="Layer_1">
-<path d="M1147.636 960h-398.545l-170.182-749.091-170.182 749.091h-408.727v-1003.636h261.818v421.818c0 87.273 0 216.727-2.909 318.545 16-101.818 45.091-231.273 68.364-318.545l112-421.818h269.091l110.545 421.818c23.273 88.727 52.364 216.727 69.818 318.545-2.909-101.818-2.909-231.273-2.909-318.545v-421.818h261.818zM1761.205-43.636h256c-11.636 85.818-14.545 138.182-14.545 196.364v218.182c0 48-1.455 93.091-4.364 132.364-11.636 141.091-96 250.182-341.818 250.182-196.364 0-357.818-103.273-359.273-285.091l247.273 1.455c4.364 66.909 39.273 106.182 100.364 106.182 56.727 0 88.727-34.909 93.091-74.182 2.909-24.727 2.909-40.727 2.909-56.727v-11.636h-49.455c-263.273 0-420.364-101.818-420.364-267.636 0-149.818 120.727-229.818 248.727-229.818 71.273 0 136.727 17.455 192 52.364l32 81.455zM1627.386 113.455c-52.364 0-88.727 26.182-88.727 72.727 0 77.091 84.364 107.636 202.182 98.909v-50.909c0-81.455-61.091-120.727-113.455-120.727zM2475.227 564.364h171.636v162.909h-171.636v200.727h-261.818v-200.727h-123.636v-162.909h123.636v-324.364c0-43.636 0-80 2.909-125.091 8.727-119.273 66.909-178.909 260.364-178.909 55.273 0 103.273 4.364 154.182 13.091v162.909c-123.636-5.818-148.364 11.636-154.182 90.182-1.455 20.364-1.455 49.455-1.455 75.636zM3108.727 753.455c-254.545 0-417.455-158.545-417.455-408.727 0-248.727 162.909-408.727 417.455-408.727s417.455 160 417.455 408.727c0 250.182-162.909 408.727-417.455 408.727zM3108.727 113.455c-93.091 0-149.818 77.091-149.818 231.273 0 155.636 56.727 231.273 149.818 231.273s149.818-75.636 149.818-231.273c0-154.182-56.727-231.273-149.818-231.273zM4887.773 557.091c-17.454 106.182-104.727 196.364-260.364 196.364-71.273 0-119.273-16-167.273-45.091l-81.455-106.182c-33.455 85.818-116.364 151.273-248.727 151.273-71.273 0-119.273-16-167.273-45.091l-62.545-100.364v125.091h-261.818v-776.727h261.818v410.182c0 132.364 33.455 209.455 117.818 209.455 68.364 0 103.273-43.636 112-117.818 4.363-39.273 5.818-81.455 5.818-106.182v-395.636h261.818v410.182c0 132.364 33.455 209.455 117.818 209.455 68.364 0 103.273-43.636 112-117.818 4.363-39.273 5.818-81.455 5.818-106.182v-395.636h261.818v453.818c0 62.545-1.455 107.636-7.273 146.909zM5418.387 753.455c-254.546 0-417.455-158.545-417.455-408.727 0-248.727 162.909-408.727 417.455-408.727s417.454 160 417.454 408.727c0 250.182-162.909 408.727-417.454 408.727zM5418.387 113.455c-93.091 0-149.819 77.091-149.819 231.273 0 155.636 56.728 231.273 149.819 231.273s149.818-75.636 149.818-231.273c0-154.182-56.728-231.273-149.818-231.273z"
- horiz-adv-x="5836" transform="scale(1, -1) translate(0, -960)" fill="#fff"></path>
-</svg>
-
-
+ width="590" height="98.995" viewBox="0 0 590 98.995"
+ enable-background="new 0 0 590 98.995" xml:space="preserve" version="1.1" id="Layer_1">
+<path transform="translate(-250 -73) scale(2.4 2.4)" fill="#fff" d="M349.338 54.142c0 9.062-7.347 16.408-16.408 16.408-9.063 0-16.409-7.347-16.409-16.408 0-9.063 7.347-16.409 16.409-16.409 9.061-.001 16.408 7.346 16.408 16.409m-16.408-8.223a8.223 8.223 0 1 0 0 16.445 8.223 8.223 0 0 0 0-16.445M262.479 54.151c0 9.062-7.346 16.406-16.406 16.406-9.062 0-16.406-7.345-16.406-16.406s7.345-16.407 16.406-16.407c9.061.001 16.406 7.347 16.406 16.407m-16.406-8.22a8.22 8.22 0 1 0 0 16.442 8.221 8.221 0 0 0 0-16.442M184.184 37.733c-1.686 0-3.132.85-3.767 2.413a16.309 16.309 0 0 0-8.535-2.413c-9.066 0-16.416 7.35-16.416 16.416 0 9.067 7.35 16.417 16.416 16.417 3.129 0 6.043-.892 8.532-2.41.633 1.563 2.081 2.41 3.77 2.41 2.272 0 4.114-1.526 4.114-4.281v-24.26c0-2.755-1.842-4.292-4.114-4.292m-12.302 24.642a8.227 8.227 0 0 1 0-16.452c4.29 0 7.81 3.286 8.188 7.478v1.495c-.378 4.193-3.898 7.479-8.188 7.479M226.841 56.421a4.081 4.081 0 0 0-4.084-4.08 4.081 4.081 0 0 0-4.082 4.08 5.981 5.981 0 0 1-5.973 5.973 5.98 5.98 0 0 1-5.972-5.973l-.002-.01V45.933h9.644c2.255 0 4.081-1.851 4.081-4.104 0-2.254-1.826-4.095-4.081-4.095h-9.665v-2.79a4.084 4.084 0 0 0-8.166 0v2.814h-3.307a4.084 4.084 0 0 0-4.083 4.082 4.083 4.083 0 0 0 4.083 4.082h3.307l-.033 10.499c0 7.762 6.562 14.137 14.194 14.137 7.796 0 14.139-6.342 14.139-14.137M313.694 66.45V51.697h-.009c-.095-7.715-6.392-13.963-14.128-13.963a14.084 14.084 0 0 0-10.069 4.23c-2.563-2.602-6.11-4.23-10.047-4.23-7.735 0-14.032 6.248-14.128 13.963h-.009V66.45a4.081 4.081 0 0 0 8.162 0V51.869h.002a5.98 5.98 0 0 1 5.973-5.97c3.295 0 5.976 2.68 5.976 5.97h.003l-.001 14.437c0 2.252 1.871 4.244 4.125 4.244s4.031-1.992 4.031-4.244l.008-14.437h.002a5.98 5.98 0 0 1 5.973-5.97 5.98 5.98 0 0 1 5.972 5.97h.003V66.45a4.08 4.08 0 1 0 8.161 0M152.64 66.438V51.685h-.009c-.095-7.715-6.392-13.963-14.128-13.963-3.94 0-7.505 1.622-10.07 4.23-2.562-2.602-6.11-4.23-10.046-4.23-7.736 0-14.033 6.248-14.128 13.963h-.009v14.753a4.081 4.081 0 0 0 8.162 0V51.856h.002a5.98 5.98 0 0 1 5.973-5.97c3.295 0 5.975 2.68 5.975 5.97h.003l-.001 14.436c0 2.252 1.871 4.245 4.125 4.245s4.031-1.993 4.031-4.245l.008-14.436h.002a5.98 5.98 0 0 1 5.973-5.97 5.98 5.98 0 0 1 5.972 5.97h.002v14.581a4.082 4.082 0 0 0 8.163.001"/></svg> \ No newline at end of file
diff --git a/plugins/Morpheus/stylesheets/base/colors.less b/plugins/Morpheus/stylesheets/base/colors.less
index 89ec7ff92d..e026fd71d1 100644
--- a/plugins/Morpheus/stylesheets/base/colors.less
+++ b/plugins/Morpheus/stylesheets/base/colors.less
@@ -24,20 +24,23 @@
@color-black-piwik: #0d0d0d;
@color-blue-piwik: #4183C4;
@color-red-piwik: #d4291f;
+@color-green-piwik: #43a047;
@color-green-brandSocial: #009874;
@color-blue-brandSocial: #3b5998;
@color-blue-brandSocialLight: #1c87bd;
@color-blue-brandSocialVeryLight: #00aced;
@color-orange-brand: #ff9600;
-@graph-colors-data-series1: #d4291f;
-@graph-colors-data-series2: #1f78b4;
+@graph-colors-data-series1: #3450A3;
+@graph-colors-data-series2: #43a047;
@graph-colors-data-series3: #ff7f00;
-@graph-colors-data-series4: #33a02c;
+@graph-colors-data-series4: #d4291f;
@graph-colors-data-series5: #6a3d9a;
@graph-colors-data-series6: #b15928;
@graph-colors-data-series7: #fdbf6f;
@graph-colors-data-series8: #cab2d6;
+
+@default-box-shade: 0 2px 3px 0 rgba(0,0,0,0.16), 0 0px 3px 0 rgba(0,0,0,0.12);
/*
Qualitative data color series inspired from colorbrewer2.org/
next ones could be: #cab2d6 #ffff99 # #b2df8a
diff --git a/plugins/Morpheus/stylesheets/base/icons.css b/plugins/Morpheus/stylesheets/base/icons.css
index 93fcff408e..f45b9d185f 100644
--- a/plugins/Morpheus/stylesheets/base/icons.css
+++ b/plugins/Morpheus/stylesheets/base/icons.css
@@ -497,3 +497,7 @@
.icon-embed:before {
content: "\ea80";
}
+
+.icon-delete:hover {
+ color: #d4291f !important;
+}
diff --git a/plugins/Morpheus/stylesheets/general/_forms.less b/plugins/Morpheus/stylesheets/general/_forms.less
index cc56ae93b0..4cf5727b67 100644
--- a/plugins/Morpheus/stylesheets/general/_forms.less
+++ b/plugins/Morpheus/stylesheets/general/_forms.less
@@ -22,8 +22,8 @@
}
.form-help {
- color: #838383;
- background-color: #F5F5F5;
+ background-color: #eff0f1;
+ color: #76838f;
font-size: 13px;
padding: 20px 20px 20px 20px;
border: 1px solid rgba(0, 0, 0, 0);
@@ -33,6 +33,8 @@
}
label {
+ color: #76838f;
+
.form-description {
font-size: 90%;
font-style: italic;
@@ -64,4 +66,29 @@ input.browser-default {
box-sizing: border-box;
margin: 0;
height: auto;
-} \ No newline at end of file
+}
+
+.progress .determinate {
+ background-color: @theme-color-brand !important;
+}
+
+[type="checkbox"]:checked+label:before {
+ border-right-color: @theme-color-brand;
+ border-bottom-color: @theme-color-brand;
+}
+nav.blue-grey.darken-3 {
+ background-color: @theme-color-header-background !important;
+}
+[type="radio"]:checked+label:after, [type="radio"].with-gap:checked+label:after {
+ background-color: @theme-color-brand;
+ border-color: @theme-color-brand;
+}
+
+input:not([type]):focus:not([readonly]), input[type=text]:focus:not([readonly]), input[type=password]:focus:not([readonly]), input[type=email]:focus:not([readonly]), input[type=url]:focus:not([readonly]), input[type=time]:focus:not([readonly]), input[type=date]:focus:not([readonly]), input[type=datetime]:focus:not([readonly]), input[type=datetime-local]:focus:not([readonly]), input[type=tel]:focus:not([readonly]), input[type=number]:focus:not([readonly]), input[type=search]:focus:not([readonly]), textarea.materialize-textarea:focus:not([readonly]) {
+ border-bottom-color: @theme-color-brand;
+ box-shadow: 0 1px 0 0 @theme-color-brand;
+}
+
+input:not([type]):focus:not([readonly])+label, input[type=text]:focus:not([readonly])+label, input[type=password]:focus:not([readonly])+label, input[type=email]:focus:not([readonly])+label, input[type=url]:focus:not([readonly])+label, input[type=time]:focus:not([readonly])+label, input[type=date]:focus:not([readonly])+label, input[type=datetime]:focus:not([readonly])+label, input[type=datetime-local]:focus:not([readonly])+label, input[type=tel]:focus:not([readonly])+label, input[type=number]:focus:not([readonly])+label, input[type=search]:focus:not([readonly])+label, textarea.materialize-textarea:focus:not([readonly])+label {
+ color: @theme-color-brand;
+}
diff --git a/plugins/Morpheus/stylesheets/main.less b/plugins/Morpheus/stylesheets/main.less
index 1ac27adbda..db54e58b94 100644
--- a/plugins/Morpheus/stylesheets/main.less
+++ b/plugins/Morpheus/stylesheets/main.less
@@ -273,9 +273,10 @@ table.dataTable {
thead {
tr {
th {
- background: @theme-color-background-contrast;
+ background: @theme-color-background-base;
+ border-radius: 0;
+ color: @theme-color-text-contrast;
text-transform: uppercase;
- color: @theme-color-text;
.font-default(11px, 12px);
padding-top: 16px;
padding-bottom: 16px;
diff --git a/plugins/Morpheus/stylesheets/simple_structure.css b/plugins/Morpheus/stylesheets/simple_structure.css
index 4e204a4218..0581550123 100644
--- a/plugins/Morpheus/stylesheets/simple_structure.css
+++ b/plugins/Morpheus/stylesheets/simple_structure.css
@@ -1,6 +1,7 @@
/* Most of the code here can be removed once we switch to Bootstrap */
body {
font-family: Verdana, sans-serif;
+ color: #212121;
}
body#simple {
background: #fff;
@@ -10,7 +11,7 @@ body#simple {
color: #888;
text-align: center;
font-size: 12px;
- background-color: #37474f !important;
+ background-color: #3450A3 !important;
padding: 15px 0;
}
#simple .logo a {
@@ -27,7 +28,7 @@ body#simple {
}
#simple .box .header {
- background-color: #f0f0f0;
+ background-color: #eff0f1;
padding: 40px 80px;
text-align: center;
}
@@ -83,7 +84,7 @@ body#simple {
}
#simple .box .footer {
- background-color: #f0f0f0;
+ background-color: #eff0f1;
padding: 15px;
text-align: center;
}
@@ -108,6 +109,9 @@ body#simple {
font-size: 45px;
font-weight: lighter;
}
+a {
+ color: #1976D2;
+}
#subh1 {
color: #444444;
font-size: 17pt;
diff --git a/plugins/Morpheus/stylesheets/ui/_buttons.less b/plugins/Morpheus/stylesheets/ui/_buttons.less
index 6aaccdd9f4..ac608d2ed4 100644
--- a/plugins/Morpheus/stylesheets/ui/_buttons.less
+++ b/plugins/Morpheus/stylesheets/ui/_buttons.less
@@ -7,7 +7,6 @@ button.btn,input[type="submit"].btn, .btn {
background: none;
background-color: @theme-color-brand;
color: @theme-color-brand-contrast !important;
- #gradient > .vertical(rgba(255,255,255,.15), rgba(255,255,255,0));
font-size: 12px;
font-weight: normal;
text-align: center;
diff --git a/plugins/Morpheus/stylesheets/uibase/_header.less b/plugins/Morpheus/stylesheets/uibase/_header.less
index b65a3284fe..852ee4041e 100644
--- a/plugins/Morpheus/stylesheets/uibase/_header.less
+++ b/plugins/Morpheus/stylesheets/uibase/_header.less
@@ -5,7 +5,7 @@
padding-top: 4px;
img {
- max-height: 30px;
+ max-height: 28px;
&.default-piwik-logo {
width: 144px;
diff --git a/plugins/Morpheus/stylesheets/uibase/_headerMessage.less b/plugins/Morpheus/stylesheets/uibase/_headerMessage.less
index 1a00389f44..9e564d0980 100644
--- a/plugins/Morpheus/stylesheets/uibase/_headerMessage.less
+++ b/plugins/Morpheus/stylesheets/uibase/_headerMessage.less
@@ -9,7 +9,7 @@
border-radius: 4px;
&.update_available:not(.isPiwikDemo) .title {
- color: #CA8100;
+ color: #f57c00;
font-weight: bold;
cursor: default;
}
diff --git a/plugins/Morpheus/templates/layout.twig b/plugins/Morpheus/templates/layout.twig
index fde91939f5..8bfe0503b0 100644
--- a/plugins/Morpheus/templates/layout.twig
+++ b/plugins/Morpheus/templates/layout.twig
@@ -21,7 +21,7 @@
{% include "@CoreHome/_favicon.twig" %}
{% include "@CoreHome/_applePinnedTabIcon.twig" %}
- <meta name="theme-color" content="#37474f">
+ <meta name="theme-color" content="#3450A3">
{% include "_jsGlobalVariables.twig" %}
{% include "_jsCssIncludes.twig" %}
diff --git a/plugins/MultiSites/angularjs/dashboard/dashboard.directive.html b/plugins/MultiSites/angularjs/dashboard/dashboard.directive.html
index 3fd9780ae2..c479798550 100644
--- a/plugins/MultiSites/angularjs/dashboard/dashboard.directive.html
+++ b/plugins/MultiSites/angularjs/dashboard/dashboard.directive.html
@@ -10,7 +10,7 @@
</span>
</h2>
- <table id="mt" class="dataTable" cellspacing="0">
+ <table id="mt" class="dataTable card-table" cellspacing="0">
<thead>
<tr>
<th id="names" class="label" ng-click="model.sortBy('label')" ng-class="{columnSorted: 'label' == model.sortColumn}">
@@ -125,4 +125,5 @@
</tfoot>
</table>
+
</div>
diff --git a/plugins/MultiSites/angularjs/dashboard/dashboard.directive.less b/plugins/MultiSites/angularjs/dashboard/dashboard.directive.less
index dccbb713ac..8173e4cdc8 100644
--- a/plugins/MultiSites/angularjs/dashboard/dashboard.directive.less
+++ b/plugins/MultiSites/angularjs/dashboard/dashboard.directive.less
@@ -9,7 +9,14 @@
}
}
+#mt {
+ table.dataTable td.label img {
+ margin-top: -8px;
+ }
+}
+
#multisites {
+
> .col {
padding-left: 0;
padding-right: 0;
@@ -26,6 +33,7 @@
border: 0 !important;
font-size: 13px;
text-align: left;
+ padding-left: 27px;
a {
color: @theme-color-text;
@@ -103,7 +111,7 @@
th:first-child {
text-align:left;
- padding-left: 12px;
+ padding-left: 32px;
}
th {
@@ -153,6 +161,7 @@
text-align: left;
width: 250px;
max-width: 250px;
+ padding-left: 32px;
a {
width: auto;
diff --git a/plugins/PrivacyManager/angularjs/manage-gdpr/managegdpr.directive.less b/plugins/PrivacyManager/angularjs/manage-gdpr/managegdpr.directive.less
index c618566735..4495f32c0f 100644
--- a/plugins/PrivacyManager/angularjs/manage-gdpr/managegdpr.directive.less
+++ b/plugins/PrivacyManager/angularjs/manage-gdpr/managegdpr.directive.less
@@ -10,3 +10,7 @@
width: 180px;
}
}
+
+.deleteDataSubjects .btn {
+ background-color: @color-red-piwik;
+} \ No newline at end of file
diff --git a/plugins/ScheduledReports/templates/unsubscribe.twig b/plugins/ScheduledReports/templates/unsubscribe.twig
index 14684cd010..cde0fa7f83 100644
--- a/plugins/ScheduledReports/templates/unsubscribe.twig
+++ b/plugins/ScheduledReports/templates/unsubscribe.twig
@@ -19,7 +19,7 @@
</div>
<nav>
<div class="nav-wrapper">
- {% include "@CoreHome/_logo.twig" with { 'logoLink': 'https://matomo.org', 'centeredLogo': true, 'useLargeLogo': true } %}
+ {% include "@CoreHome/_logo.twig" with { 'logoLink': 'https://matomo.org', 'centeredLogo': true, 'useLargeLogo': false } %}
</div>
</nav>
diff --git a/plugins/SegmentEditor/stylesheets/segmentation.less b/plugins/SegmentEditor/stylesheets/segmentation.less
index a38df86939..8f3d973f93 100644
--- a/plugins/SegmentEditor/stylesheets/segmentation.less
+++ b/plugins/SegmentEditor/stylesheets/segmentation.less
@@ -503,3 +503,11 @@ a.metric_category {
font-style: normal;
}
}
+
+.segment-element .segment-footer a.delete {
+ color: #d4291f !important;
+}
+
+.segment-element .segment-footer a {
+ color: @theme-color-link !important;
+}
diff --git a/plugins/UserCountry/stylesheets/userCountry.less b/plugins/UserCountry/stylesheets/userCountry.less
index 147666fc24..c1b5fc73ee 100644
--- a/plugins/UserCountry/stylesheets/userCountry.less
+++ b/plugins/UserCountry/stylesheets/userCountry.less
@@ -11,11 +11,11 @@ input.location-provider {
}
span.is-installed {
- color: #009874;
+ color: @color-green-piwik;
}
span.is-broken {
- color: #D4291F;
+ color: @color-red-piwik;
}
.loc-provider-status {
diff --git a/tests/resources/customlogo/logo-header.png b/tests/resources/customlogo/logo-header.png
index 136d763d1a..88922c391f 100644
--- a/tests/resources/customlogo/logo-header.png
+++ b/tests/resources/customlogo/logo-header.png
Binary files differ
diff --git a/tests/resources/customlogo/logo.png b/tests/resources/customlogo/logo.png
index 014121e673..94cf3df964 100644
--- a/tests/resources/customlogo/logo.png
+++ b/tests/resources/customlogo/logo.png
Binary files differ
diff --git a/tests/resources/customlogo/logo.svg b/tests/resources/customlogo/logo.svg
index 1cec6e0987..529af4a8dc 100644
--- a/tests/resources/customlogo/logo.svg
+++ b/tests/resources/customlogo/logo.svg
@@ -1,10 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
- width="5836" height="1024" viewBox="0 0 5836 1024"
- enable-background="new 0 0 5836 1024" xml:space="preserve" version="1.1" id="Layer_1">
-<path d="M1147.636 960h-398.545l-170.182-749.091-170.182 749.091h-408.727v-1003.636h261.818v421.818c0 87.273 0 216.727-2.909 318.545 16-101.818 45.091-231.273 68.364-318.545l112-421.818h269.091l110.545 421.818c23.273 88.727 52.364 216.727 69.818 318.545-2.909-101.818-2.909-231.273-2.909-318.545v-421.818h261.818zM1761.205-43.636h256c-11.636 85.818-14.545 138.182-14.545 196.364v218.182c0 48-1.455 93.091-4.364 132.364-11.636 141.091-96 250.182-341.818 250.182-196.364 0-357.818-103.273-359.273-285.091l247.273 1.455c4.364 66.909 39.273 106.182 100.364 106.182 56.727 0 88.727-34.909 93.091-74.182 2.909-24.727 2.909-40.727 2.909-56.727v-11.636h-49.455c-263.273 0-420.364-101.818-420.364-267.636 0-149.818 120.727-229.818 248.727-229.818 71.273 0 136.727 17.455 192 52.364l32 81.455zM1627.386 113.455c-52.364 0-88.727 26.182-88.727 72.727 0 77.091 84.364 107.636 202.182 98.909v-50.909c0-81.455-61.091-120.727-113.455-120.727zM2475.227 564.364h171.636v162.909h-171.636v200.727h-261.818v-200.727h-123.636v-162.909h123.636v-324.364c0-43.636 0-80 2.909-125.091 8.727-119.273 66.909-178.909 260.364-178.909 55.273 0 103.273 4.364 154.182 13.091v162.909c-123.636-5.818-148.364 11.636-154.182 90.182-1.455 20.364-1.455 49.455-1.455 75.636zM3108.727 753.455c-254.545 0-417.455-158.545-417.455-408.727 0-248.727 162.909-408.727 417.455-408.727s417.455 160 417.455 408.727c0 250.182-162.909 408.727-417.455 408.727zM3108.727 113.455c-93.091 0-149.818 77.091-149.818 231.273 0 155.636 56.727 231.273 149.818 231.273s149.818-75.636 149.818-231.273c0-154.182-56.727-231.273-149.818-231.273zM4887.773 557.091c-17.454 106.182-104.727 196.364-260.364 196.364-71.273 0-119.273-16-167.273-45.091l-81.455-106.182c-33.455 85.818-116.364 151.273-248.727 151.273-71.273 0-119.273-16-167.273-45.091l-62.545-100.364v125.091h-261.818v-776.727h261.818v410.182c0 132.364 33.455 209.455 117.818 209.455 68.364 0 103.273-43.636 112-117.818 4.363-39.273 5.818-81.455 5.818-106.182v-395.636h261.818v410.182c0 132.364 33.455 209.455 117.818 209.455 68.364 0 103.273-43.636 112-117.818 4.363-39.273 5.818-81.455 5.818-106.182v-395.636h261.818v453.818c0 62.545-1.455 107.636-7.273 146.909zM5418.387 753.455c-254.546 0-417.455-158.545-417.455-408.727 0-248.727 162.909-408.727 417.455-408.727s417.454 160 417.454 408.727c0 250.182-162.909 408.727-417.454 408.727zM5418.387 113.455c-93.091 0-149.819 77.091-149.819 231.273 0 155.636 56.728 231.273 149.819 231.273s149.818-75.636 149.818-231.273c0-154.182-56.728-231.273-149.818-231.273z"
- horiz-adv-x="5836" transform="scale(1, -1) translate(0, -960)" fill="#9ff"></path>
-</svg>
-
-
+ width="590" height="98.995" viewBox="0 0 590 98.995"
+ enable-background="new 0 0 590 98.995" xml:space="preserve" version="1.1" id="Layer_1">
+<path transform="translate(-250 -73) scale(2.4 2.4)" fill="#9ff" d="M349.338 54.142c0 9.062-7.347 16.408-16.408 16.408-9.063 0-16.409-7.347-16.409-16.408 0-9.063 7.347-16.409 16.409-16.409 9.061-.001 16.408 7.346 16.408 16.409m-16.408-8.223a8.223 8.223 0 1 0 0 16.445 8.223 8.223 0 0 0 0-16.445M262.479 54.151c0 9.062-7.346 16.406-16.406 16.406-9.062 0-16.406-7.345-16.406-16.406s7.345-16.407 16.406-16.407c9.061.001 16.406 7.347 16.406 16.407m-16.406-8.22a8.22 8.22 0 1 0 0 16.442 8.221 8.221 0 0 0 0-16.442M184.184 37.733c-1.686 0-3.132.85-3.767 2.413a16.309 16.309 0 0 0-8.535-2.413c-9.066 0-16.416 7.35-16.416 16.416 0 9.067 7.35 16.417 16.416 16.417 3.129 0 6.043-.892 8.532-2.41.633 1.563 2.081 2.41 3.77 2.41 2.272 0 4.114-1.526 4.114-4.281v-24.26c0-2.755-1.842-4.292-4.114-4.292m-12.302 24.642a8.227 8.227 0 0 1 0-16.452c4.29 0 7.81 3.286 8.188 7.478v1.495c-.378 4.193-3.898 7.479-8.188 7.479M226.841 56.421a4.081 4.081 0 0 0-4.084-4.08 4.081 4.081 0 0 0-4.082 4.08 5.981 5.981 0 0 1-5.973 5.973 5.98 5.98 0 0 1-5.972-5.973l-.002-.01V45.933h9.644c2.255 0 4.081-1.851 4.081-4.104 0-2.254-1.826-4.095-4.081-4.095h-9.665v-2.79a4.084 4.084 0 0 0-8.166 0v2.814h-3.307a4.084 4.084 0 0 0-4.083 4.082 4.083 4.083 0 0 0 4.083 4.082h3.307l-.033 10.499c0 7.762 6.562 14.137 14.194 14.137 7.796 0 14.139-6.342 14.139-14.137M313.694 66.45V51.697h-.009c-.095-7.715-6.392-13.963-14.128-13.963a14.084 14.084 0 0 0-10.069 4.23c-2.563-2.602-6.11-4.23-10.047-4.23-7.735 0-14.032 6.248-14.128 13.963h-.009V66.45a4.081 4.081 0 0 0 8.162 0V51.869h.002a5.98 5.98 0 0 1 5.973-5.97c3.295 0 5.976 2.68 5.976 5.97h.003l-.001 14.437c0 2.252 1.871 4.244 4.125 4.244s4.031-1.992 4.031-4.244l.008-14.437h.002a5.98 5.98 0 0 1 5.973-5.97 5.98 5.98 0 0 1 5.972 5.97h.003V66.45a4.08 4.08 0 1 0 8.161 0M152.64 66.438V51.685h-.009c-.095-7.715-6.392-13.963-14.128-13.963-3.94 0-7.505 1.622-10.07 4.23-2.562-2.602-6.11-4.23-10.046-4.23-7.736 0-14.033 6.248-14.128 13.963h-.009v14.753a4.081 4.081 0 0 0 8.162 0V51.856h.002a5.98 5.98 0 0 1 5.973-5.97c3.295 0 5.975 2.68 5.975 5.97h.003l-.001 14.436c0 2.252 1.871 4.245 4.125 4.245s4.031-1.993 4.031-4.245l.008-14.436h.002a5.98 5.98 0 0 1 5.973-5.97 5.98 5.98 0 0 1 5.972 5.97h.002v14.581a4.082 4.082 0 0 0 8.163.001"/></svg> \ No newline at end of file
diff --git a/tests/resources/matomo.test.js b/tests/resources/matomo.test.js
new file mode 100644
index 0000000000..4fa7bebdb6
--- /dev/null
+++ b/tests/resources/matomo.test.js
@@ -0,0 +1,15882 @@
+/*!
+ * Piwik - free/libre analytics platform
+ *
+ * JavaScript tracking client
+ *
+ * @link https://piwik.org
+ * @source https://github.com/matomo-org/matomo/blob/master/js/piwik.js
+ * @license https://piwik.org/free-software/bsd/ BSD-3 Clause (also in js/LICENSE.txt)
+ * @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
+ */
+// NOTE: if you change this above Piwik comment block, you must also change `$byteStart` in js/tracker.php
+
+// Refer to README.md for build instructions when minifying this file for distribution.
+
+/*
+ * Browser [In]Compatibility
+ * - minimum required ECMAScript: ECMA-262, edition 3
+ *
+ * Incompatible with these (and earlier) versions of:
+ * - IE4 - try..catch and for..in introduced in IE5
+ * - IE5 - named anonymous functions, array.push, encodeURIComponent, decodeURIComponent, and getElementsByTagName introduced in IE5.5
+ * - Firefox 1.0 and Netscape 8.x - FF1.5 adds array.indexOf, among other things
+ * - Mozilla 1.7 and Netscape 6.x-7.x
+ * - Netscape 4.8
+ * - Opera 6 - Error object (and Presto) introduced in Opera 7
+ * - Opera 7
+ */
+
+/*global JSON_PIWIK:true */
+
+if (typeof JSON_PIWIK !== 'object' && typeof window.JSON === 'object' && window.JSON.stringify && window.JSON.parse) {
+ JSON_PIWIK = window.JSON;
+} else {
+ (function () {
+ // we make sure to not break any site that uses JSON3 as well as we do not know if they run it in conflict mode
+ // or not.
+ var exports = {};
+
+ // Create a JSON object only if one does not already exist. We create the
+ // methods in a closure to avoid creating global variables.
+
+ /*! JSON v3.3.2 | http://bestiejs.github.io/json3 | Copyright 2012-2014, Kit Cambridge | http://kit.mit-license.org */
+ (function () {
+ // Detect the `define` function exposed by asynchronous module loaders. The
+ // strict `define` check is necessary for compatibility with `r.js`.
+ var isLoader = typeof define === "function" && define.amd;
+
+ // A set of types used to distinguish objects from primitives.
+ var objectTypes = {
+ "function": true,
+ "object": true
+ };
+
+ // Detect the `exports` object exposed by CommonJS implementations.
+ var freeExports = objectTypes[typeof exports] && exports && !exports.nodeType && exports;
+
+ // Use the `global` object exposed by Node (including Browserify via
+ // `insert-module-globals`), Narwhal, and Ringo as the default context,
+ // and the `window` object in browsers. Rhino exports a `global` function
+ // instead.
+ var root = objectTypes[typeof window] && window || this,
+ freeGlobal = freeExports && objectTypes[typeof module] && module && !module.nodeType && typeof global == "object" && global;
+
+ if (freeGlobal && (freeGlobal["global"] === freeGlobal || freeGlobal["window"] === freeGlobal || freeGlobal["self"] === freeGlobal)) {
+ root = freeGlobal;
+ }
+
+ // Public: Initializes JSON 3 using the given `context` object, attaching the
+ // `stringify` and `parse` functions to the specified `exports` object.
+ function runInContext(context, exports) {
+ context || (context = root["Object"]());
+ exports || (exports = root["Object"]());
+
+ // Native constructor aliases.
+ var Number = context["Number"] || root["Number"],
+ String = context["String"] || root["String"],
+ Object = context["Object"] || root["Object"],
+ Date = context["Date"] || root["Date"],
+ SyntaxError = context["SyntaxError"] || root["SyntaxError"],
+ TypeError = context["TypeError"] || root["TypeError"],
+ Math = context["Math"] || root["Math"],
+ nativeJSON = context["JSON"] || root["JSON"];
+
+ // Delegate to the native `stringify` and `parse` implementations.
+ if (typeof nativeJSON == "object" && nativeJSON) {
+ exports.stringify = nativeJSON.stringify;
+ exports.parse = nativeJSON.parse;
+ }
+
+ // Convenience aliases.
+ var objectProto = Object.prototype,
+ getClass = objectProto.toString,
+ isProperty, forEach, undef;
+
+ // Test the `Date#getUTC*` methods. Based on work by @Yaffle.
+ var isExtended = new Date(-3509827334573292);
+ try {
+ // The `getUTCFullYear`, `Month`, and `Date` methods return nonsensical
+ // results for certain dates in Opera >= 10.53.
+ isExtended = isExtended.getUTCFullYear() == -109252 && isExtended.getUTCMonth() === 0 && isExtended.getUTCDate() === 1 &&
+ // Safari < 2.0.2 stores the internal millisecond time value correctly,
+ // but clips the values returned by the date methods to the range of
+ // signed 32-bit integers ([-2 ** 31, 2 ** 31 - 1]).
+ isExtended.getUTCHours() == 10 && isExtended.getUTCMinutes() == 37 && isExtended.getUTCSeconds() == 6 && isExtended.getUTCMilliseconds() == 708;
+ } catch (exception) {}
+
+ // Internal: Determines whether the native `JSON.stringify` and `parse`
+ // implementations are spec-compliant. Based on work by Ken Snyder.
+ function has(name) {
+ if (has[name] !== undef) {
+ // Return cached feature test result.
+ return has[name];
+ }
+ var isSupported;
+ if (name == "bug-string-char-index") {
+ // IE <= 7 doesn't support accessing string characters using square
+ // bracket notation. IE 8 only supports this for primitives.
+ isSupported = "a"[0] != "a";
+ } else if (name == "json") {
+ // Indicates whether both `JSON.stringify` and `JSON.parse` are
+ // supported.
+ isSupported = has("json-stringify") && has("json-parse");
+ } else {
+ var value, serialized = '{"a":[1,true,false,null,"\\u0000\\b\\n\\f\\r\\t"]}';
+ // Test `JSON.stringify`.
+ if (name == "json-stringify") {
+ var stringify = exports.stringify, stringifySupported = typeof stringify == "function" && isExtended;
+ if (stringifySupported) {
+ // A test function object with a custom `toJSON` method.
+ (value = function () {
+ return 1;
+ }).toJSON = value;
+ try {
+ stringifySupported =
+ // Firefox 3.1b1 and b2 serialize string, number, and boolean
+ // primitives as object literals.
+ stringify(0) === "0" &&
+ // FF 3.1b1, b2, and JSON 2 serialize wrapped primitives as object
+ // literals.
+ stringify(new Number()) === "0" &&
+ stringify(new String()) == '""' &&
+ // FF 3.1b1, 2 throw an error if the value is `null`, `undefined`, or
+ // does not define a canonical JSON representation (this applies to
+ // objects with `toJSON` properties as well, *unless* they are nested
+ // within an object or array).
+ stringify(getClass) === undef &&
+ // IE 8 serializes `undefined` as `"undefined"`. Safari <= 5.1.7 and
+ // FF 3.1b3 pass this test.
+ stringify(undef) === undef &&
+ // Safari <= 5.1.7 and FF 3.1b3 throw `Error`s and `TypeError`s,
+ // respectively, if the value is omitted entirely.
+ stringify() === undef &&
+ // FF 3.1b1, 2 throw an error if the given value is not a number,
+ // string, array, object, Boolean, or `null` literal. This applies to
+ // objects with custom `toJSON` methods as well, unless they are nested
+ // inside object or array literals. YUI 3.0.0b1 ignores custom `toJSON`
+ // methods entirely.
+ stringify(value) === "1" &&
+ stringify([value]) == "[1]" &&
+ // Prototype <= 1.6.1 serializes `[undefined]` as `"[]"` instead of
+ // `"[null]"`.
+ stringify([undef]) == "[null]" &&
+ // YUI 3.0.0b1 fails to serialize `null` literals.
+ stringify(null) == "null" &&
+ // FF 3.1b1, 2 halts serialization if an array contains a function:
+ // `[1, true, getClass, 1]` serializes as "[1,true,],". FF 3.1b3
+ // elides non-JSON values from objects and arrays, unless they
+ // define custom `toJSON` methods.
+ stringify([undef, getClass, null]) == "[null,null,null]" &&
+ // Simple serialization test. FF 3.1b1 uses Unicode escape sequences
+ // where character escape codes are expected (e.g., `\b` => `\u0008`).
+ stringify({ "a": [value, true, false, null, "\x00\b\n\f\r\t"] }) == serialized &&
+ // FF 3.1b1 and b2 ignore the `filter` and `width` arguments.
+ stringify(null, value) === "1" &&
+ stringify([1, 2], null, 1) == "[\n 1,\n 2\n]" &&
+ // JSON 2, Prototype <= 1.7, and older WebKit builds incorrectly
+ // serialize extended years.
+ stringify(new Date(-8.64e15)) == '"-271821-04-20T00:00:00.000Z"' &&
+ // The milliseconds are optional in ES 5, but required in 5.1.
+ stringify(new Date(8.64e15)) == '"+275760-09-13T00:00:00.000Z"' &&
+ // Firefox <= 11.0 incorrectly serializes years prior to 0 as negative
+ // four-digit years instead of six-digit years. Credits: @Yaffle.
+ stringify(new Date(-621987552e5)) == '"-000001-01-01T00:00:00.000Z"' &&
+ // Safari <= 5.1.5 and Opera >= 10.53 incorrectly serialize millisecond
+ // values less than 1000. Credits: @Yaffle.
+ stringify(new Date(-1)) == '"1969-12-31T23:59:59.999Z"';
+ } catch (exception) {
+ stringifySupported = false;
+ }
+ }
+ isSupported = stringifySupported;
+ }
+ // Test `JSON.parse`.
+ if (name == "json-parse") {
+ var parse = exports.parse;
+ if (typeof parse == "function") {
+ try {
+ // FF 3.1b1, b2 will throw an exception if a bare literal is provided.
+ // Conforming implementations should also coerce the initial argument to
+ // a string prior to parsing.
+ if (parse("0") === 0 && !parse(false)) {
+ // Simple parsing test.
+ value = parse(serialized);
+ var parseSupported = value["a"].length == 5 && value["a"][0] === 1;
+ if (parseSupported) {
+ try {
+ // Safari <= 5.1.2 and FF 3.1b1 allow unescaped tabs in strings.
+ parseSupported = !parse('"\t"');
+ } catch (exception) {}
+ if (parseSupported) {
+ try {
+ // FF 4.0 and 4.0.1 allow leading `+` signs and leading
+ // decimal points. FF 4.0, 4.0.1, and IE 9-10 also allow
+ // certain octal literals.
+ parseSupported = parse("01") !== 1;
+ } catch (exception) {}
+ }
+ if (parseSupported) {
+ try {
+ // FF 4.0, 4.0.1, and Rhino 1.7R3-R4 allow trailing decimal
+ // points. These environments, along with FF 3.1b1 and 2,
+ // also allow trailing commas in JSON objects and arrays.
+ parseSupported = parse("1.") !== 1;
+ } catch (exception) {}
+ }
+ }
+ }
+ } catch (exception) {
+ parseSupported = false;
+ }
+ }
+ isSupported = parseSupported;
+ }
+ }
+ return has[name] = !!isSupported;
+ }
+
+ if (!has("json")) {
+ // Common `[[Class]]` name aliases.
+ var functionClass = "[object Function]",
+ dateClass = "[object Date]",
+ numberClass = "[object Number]",
+ stringClass = "[object String]",
+ arrayClass = "[object Array]",
+ booleanClass = "[object Boolean]";
+
+ // Detect incomplete support for accessing string characters by index.
+ var charIndexBuggy = has("bug-string-char-index");
+
+ // Define additional utility methods if the `Date` methods are buggy.
+ if (!isExtended) {
+ var floor = Math.floor;
+ // A mapping between the months of the year and the number of days between
+ // January 1st and the first of the respective month.
+ var Months = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
+ // Internal: Calculates the number of days between the Unix epoch and the
+ // first day of the given month.
+ var getDay = function (year, month) {
+ return Months[month] + 365 * (year - 1970) + floor((year - 1969 + (month = +(month > 1))) / 4) - floor((year - 1901 + month) / 100) + floor((year - 1601 + month) / 400);
+ };
+ }
+
+ // Internal: Determines if a property is a direct property of the given
+ // object. Delegates to the native `Object#hasOwnProperty` method.
+ if (!(isProperty = objectProto.hasOwnProperty)) {
+ isProperty = function (property) {
+ var members = {}, constructor;
+ if ((members.__proto__ = null, members.__proto__ = {
+ // The *proto* property cannot be set multiple times in recent
+ // versions of Firefox and SeaMonkey.
+ "toString": 1
+ }, members).toString != getClass) {
+ // Safari <= 2.0.3 doesn't implement `Object#hasOwnProperty`, but
+ // supports the mutable *proto* property.
+ isProperty = function (property) {
+ // Capture and break the object's prototype chain (see section 8.6.2
+ // of the ES 5.1 spec). The parenthesized expression prevents an
+ // unsafe transformation by the Closure Compiler.
+ var original = this.__proto__, result = property in (this.__proto__ = null, this);
+ // Restore the original prototype chain.
+ this.__proto__ = original;
+ return result;
+ };
+ } else {
+ // Capture a reference to the top-level `Object` constructor.
+ constructor = members.constructor;
+ // Use the `constructor` property to simulate `Object#hasOwnProperty` in
+ // other environments.
+ isProperty = function (property) {
+ var parent = (this.constructor || constructor).prototype;
+ return property in this && !(property in parent && this[property] === parent[property]);
+ };
+ }
+ members = null;
+ return isProperty.call(this, property);
+ };
+ }
+
+ // Internal: Normalizes the `for...in` iteration algorithm across
+ // environments. Each enumerated key is yielded to a `callback` function.
+ forEach = function (object, callback) {
+ var size = 0, Properties, members, property;
+
+ // Tests for bugs in the current environment's `for...in` algorithm. The
+ // `valueOf` property inherits the non-enumerable flag from
+ // `Object.prototype` in older versions of IE, Netscape, and Mozilla.
+ (Properties = function () {
+ this.valueOf = 0;
+ }).prototype.valueOf = 0;
+
+ // Iterate over a new instance of the `Properties` class.
+ members = new Properties();
+ for (property in members) {
+ // Ignore all properties inherited from `Object.prototype`.
+ if (isProperty.call(members, property)) {
+ size++;
+ }
+ }
+ Properties = members = null;
+
+ // Normalize the iteration algorithm.
+ if (!size) {
+ // A list of non-enumerable properties inherited from `Object.prototype`.
+ members = ["valueOf", "toString", "toLocaleString", "propertyIsEnumerable", "isPrototypeOf", "hasOwnProperty", "constructor"];
+ // IE <= 8, Mozilla 1.0, and Netscape 6.2 ignore shadowed non-enumerable
+ // properties.
+ forEach = function (object, callback) {
+ var isFunction = getClass.call(object) == functionClass, property, length;
+ var hasProperty = !isFunction && typeof object.constructor != "function" && objectTypes[typeof object.hasOwnProperty] && object.hasOwnProperty || isProperty;
+ for (property in object) {
+ // Gecko <= 1.0 enumerates the `prototype` property of functions under
+ // certain conditions; IE does not.
+ if (!(isFunction && property == "prototype") && hasProperty.call(object, property)) {
+ callback(property);
+ }
+ }
+ // Manually invoke the callback for each non-enumerable property.
+ for (length = members.length; property = members[--length]; hasProperty.call(object, property) && callback(property));
+ };
+ } else if (size == 2) {
+ // Safari <= 2.0.4 enumerates shadowed properties twice.
+ forEach = function (object, callback) {
+ // Create a set of iterated properties.
+ var members = {}, isFunction = getClass.call(object) == functionClass, property;
+ for (property in object) {
+ // Store each property name to prevent double enumeration. The
+ // `prototype` property of functions is not enumerated due to cross-
+ // environment inconsistencies.
+ if (!(isFunction && property == "prototype") && !isProperty.call(members, property) && (members[property] = 1) && isProperty.call(object, property)) {
+ callback(property);
+ }
+ }
+ };
+ } else {
+ // No bugs detected; use the standard `for...in` algorithm.
+ forEach = function (object, callback) {
+ var isFunction = getClass.call(object) == functionClass, property, isConstructor;
+ for (property in object) {
+ if (!(isFunction && property == "prototype") && isProperty.call(object, property) && !(isConstructor = property === "constructor")) {
+ callback(property);
+ }
+ }
+ // Manually invoke the callback for the `constructor` property due to
+ // cross-environment inconsistencies.
+ if (isConstructor || isProperty.call(object, (property = "constructor"))) {
+ callback(property);
+ }
+ };
+ }
+ return forEach(object, callback);
+ };
+
+ // Public: Serializes a JavaScript `value` as a JSON string. The optional
+ // `filter` argument may specify either a function that alters how object and
+ // array members are serialized, or an array of strings and numbers that
+ // indicates which properties should be serialized. The optional `width`
+ // argument may be either a string or number that specifies the indentation
+ // level of the output.
+ if (!has("json-stringify")) {
+ // Internal: A map of control characters and their escaped equivalents.
+ var Escapes = {
+ 92: "\\\\",
+ 34: '\\"',
+ 8: "\\b",
+ 12: "\\f",
+ 10: "\\n",
+ 13: "\\r",
+ 9: "\\t"
+ };
+
+ // Internal: Converts `value` into a zero-padded string such that its
+ // length is at least equal to `width`. The `width` must be <= 6.
+ var leadingZeroes = "000000";
+ var toPaddedString = function (width, value) {
+ // The `|| 0` expression is necessary to work around a bug in
+ // Opera <= 7.54u2 where `0 == -0`, but `String(-0) !== "0"`.
+ return (leadingZeroes + (value || 0)).slice(-width);
+ };
+
+ // Internal: Double-quotes a string `value`, replacing all ASCII control
+ // characters (characters with code unit values between 0 and 31) with
+ // their escaped equivalents. This is an implementation of the
+ // `Quote(value)` operation defined in ES 5.1 section 15.12.3.
+ var unicodePrefix = "\\u00";
+ var quote = function (value) {
+ var result = '"', index = 0, length = value.length, useCharIndex = !charIndexBuggy || length > 10;
+ var symbols = useCharIndex && (charIndexBuggy ? value.split("") : value);
+ for (; index < length; index++) {
+ var charCode = value.charCodeAt(index);
+ // If the character is a control character, append its Unicode or
+ // shorthand escape sequence; otherwise, append the character as-is.
+ switch (charCode) {
+ case 8: case 9: case 10: case 12: case 13: case 34: case 92:
+ result += Escapes[charCode];
+ break;
+ default:
+ if (charCode < 32) {
+ result += unicodePrefix + toPaddedString(2, charCode.toString(16));
+ break;
+ }
+ result += useCharIndex ? symbols[index] : value.charAt(index);
+ }
+ }
+ return result + '"';
+ };
+
+ // Internal: Recursively serializes an object. Implements the
+ // `Str(key, holder)`, `JO(value)`, and `JA(value)` operations.
+ var serialize = function (property, object, callback, properties, whitespace, indentation, stack) {
+ var value, className, year, month, date, time, hours, minutes, seconds, milliseconds, results, element, index, length, prefix, result;
+ try {
+ // Necessary for host object support.
+ value = object[property];
+ } catch (exception) {}
+ if (typeof value == "object" && value) {
+ className = getClass.call(value);
+ if (className == dateClass && !isProperty.call(value, "toJSON")) {
+ if (value > -1 / 0 && value < 1 / 0) {
+ // Dates are serialized according to the `Date#toJSON` method
+ // specified in ES 5.1 section 15.9.5.44. See section 15.9.1.15
+ // for the ISO 8601 date time string format.
+ if (getDay) {
+ // Manually compute the year, month, date, hours, minutes,
+ // seconds, and milliseconds if the `getUTC*` methods are
+ // buggy. Adapted from @Yaffle's `date-shim` project.
+ date = floor(value / 864e5);
+ for (year = floor(date / 365.2425) + 1970 - 1; getDay(year + 1, 0) <= date; year++);
+ for (month = floor((date - getDay(year, 0)) / 30.42); getDay(year, month + 1) <= date; month++);
+ date = 1 + date - getDay(year, month);
+ // The `time` value specifies the time within the day (see ES
+ // 5.1 section 15.9.1.2). The formula `(A % B + B) % B` is used
+ // to compute `A modulo B`, as the `%` operator does not
+ // correspond to the `modulo` operation for negative numbers.
+ time = (value % 864e5 + 864e5) % 864e5;
+ // The hours, minutes, seconds, and milliseconds are obtained by
+ // decomposing the time within the day. See section 15.9.1.10.
+ hours = floor(time / 36e5) % 24;
+ minutes = floor(time / 6e4) % 60;
+ seconds = floor(time / 1e3) % 60;
+ milliseconds = time % 1e3;
+ } else {
+ year = value.getUTCFullYear();
+ month = value.getUTCMonth();
+ date = value.getUTCDate();
+ hours = value.getUTCHours();
+ minutes = value.getUTCMinutes();
+ seconds = value.getUTCSeconds();
+ milliseconds = value.getUTCMilliseconds();
+ }
+ // Serialize extended years correctly.
+ value = (year <= 0 || year >= 1e4 ? (year < 0 ? "-" : "+") + toPaddedString(6, year < 0 ? -year : year) : toPaddedString(4, year)) +
+ "-" + toPaddedString(2, month + 1) + "-" + toPaddedString(2, date) +
+ // Months, dates, hours, minutes, and seconds should have two
+ // digits; milliseconds should have three.
+ "T" + toPaddedString(2, hours) + ":" + toPaddedString(2, minutes) + ":" + toPaddedString(2, seconds) +
+ // Milliseconds are optional in ES 5.0, but required in 5.1.
+ "." + toPaddedString(3, milliseconds) + "Z";
+ } else {
+ value = null;
+ }
+ } else if (typeof value.toJSON == "function" && ((className != numberClass && className != stringClass && className != arrayClass) || isProperty.call(value, "toJSON"))) {
+ // Prototype <= 1.6.1 adds non-standard `toJSON` methods to the
+ // `Number`, `String`, `Date`, and `Array` prototypes. JSON 3
+ // ignores all `toJSON` methods on these objects unless they are
+ // defined directly on an instance.
+ value = value.toJSON(property);
+ }
+ }
+ if (callback) {
+ // If a replacement function was provided, call it to obtain the value
+ // for serialization.
+ value = callback.call(object, property, value);
+ }
+ if (value === null) {
+ return "null";
+ }
+ className = getClass.call(value);
+ if (className == booleanClass) {
+ // Booleans are represented literally.
+ return "" + value;
+ } else if (className == numberClass) {
+ // JSON numbers must be finite. `Infinity` and `NaN` are serialized as
+ // `"null"`.
+ return value > -1 / 0 && value < 1 / 0 ? "" + value : "null";
+ } else if (className == stringClass) {
+ // Strings are double-quoted and escaped.
+ return quote("" + value);
+ }
+ // Recursively serialize objects and arrays.
+ if (typeof value == "object") {
+ // Check for cyclic structures. This is a linear search; performance
+ // is inversely proportional to the number of unique nested objects.
+ for (length = stack.length; length--;) {
+ if (stack[length] === value) {
+ // Cyclic structures cannot be serialized by `JSON.stringify`.
+ throw TypeError();
+ }
+ }
+ // Add the object to the stack of traversed objects.
+ stack.push(value);
+ results = [];
+ // Save the current indentation level and indent one additional level.
+ prefix = indentation;
+ indentation += whitespace;
+ if (className == arrayClass) {
+ // Recursively serialize array elements.
+ for (index = 0, length = value.length; index < length; index++) {
+ element = serialize(index, value, callback, properties, whitespace, indentation, stack);
+ results.push(element === undef ? "null" : element);
+ }
+ result = results.length ? (whitespace ? "[\n" + indentation + results.join(",\n" + indentation) + "\n" + prefix + "]" : ("[" + results.join(",") + "]")) : "[]";
+ } else {
+ // Recursively serialize object members. Members are selected from
+ // either a user-specified list of property names, or the object
+ // itself.
+ forEach(properties || value, function (property) {
+ var element = serialize(property, value, callback, properties, whitespace, indentation, stack);
+ if (element !== undef) {
+ // According to ES 5.1 section 15.12.3: "If `gap` {whitespace}
+ // is not the empty string, let `member` {quote(property) + ":"}
+ // be the concatenation of `member` and the `space` character."
+ // The "`space` character" refers to the literal space
+ // character, not the `space` {width} argument provided to
+ // `JSON.stringify`.
+ results.push(quote(property) + ":" + (whitespace ? " " : "") + element);
+ }
+ });
+ result = results.length ? (whitespace ? "{\n" + indentation + results.join(",\n" + indentation) + "\n" + prefix + "}" : ("{" + results.join(",") + "}")) : "{}";
+ }
+ // Remove the object from the traversed object stack.
+ stack.pop();
+ return result;
+ }
+ };
+
+ // Public: `JSON.stringify`. See ES 5.1 section 15.12.3.
+ exports.stringify = function (source, filter, width) {
+ var whitespace, callback, properties, className;
+ if (objectTypes[typeof filter] && filter) {
+ if ((className = getClass.call(filter)) == functionClass) {
+ callback = filter;
+ } else if (className == arrayClass) {
+ // Convert the property names array into a makeshift set.
+ properties = {};
+ for (var index = 0, length = filter.length, value; index < length; value = filter[index++], ((className = getClass.call(value)), className == stringClass || className == numberClass) && (properties[value] = 1));
+ }
+ }
+ if (width) {
+ if ((className = getClass.call(width)) == numberClass) {
+ // Convert the `width` to an integer and create a string containing
+ // `width` number of space characters.
+ if ((width -= width % 1) > 0) {
+ for (whitespace = "", width > 10 && (width = 10); whitespace.length < width; whitespace += " ");
+ }
+ } else if (className == stringClass) {
+ whitespace = width.length <= 10 ? width : width.slice(0, 10);
+ }
+ }
+ // Opera <= 7.54u2 discards the values associated with empty string keys
+ // (`""`) only if they are used directly within an object member list
+ // (e.g., `!("" in { "": 1})`).
+ return serialize("", (value = {}, value[""] = source, value), callback, properties, whitespace, "", []);
+ };
+ }
+
+ // Public: Parses a JSON source string.
+ if (!has("json-parse")) {
+ var fromCharCode = String.fromCharCode;
+
+ // Internal: A map of escaped control characters and their unescaped
+ // equivalents.
+ var Unescapes = {
+ 92: "\\",
+ 34: '"',
+ 47: "/",
+ 98: "\b",
+ 116: "\t",
+ 110: "\n",
+ 102: "\f",
+ 114: "\r"
+ };
+
+ // Internal: Stores the parser state.
+ var Index, Source;
+
+ // Internal: Resets the parser state and throws a `SyntaxError`.
+ var abort = function () {
+ Index = Source = null;
+ throw SyntaxError();
+ };
+
+ // Internal: Returns the next token, or `"$"` if the parser has reached
+ // the end of the source string. A token may be a string, number, `null`
+ // literal, or Boolean literal.
+ var lex = function () {
+ var source = Source, length = source.length, value, begin, position, isSigned, charCode;
+ while (Index < length) {
+ charCode = source.charCodeAt(Index);
+ switch (charCode) {
+ case 9: case 10: case 13: case 32:
+ // Skip whitespace tokens, including tabs, carriage returns, line
+ // feeds, and space characters.
+ Index++;
+ break;
+ case 123: case 125: case 91: case 93: case 58: case 44:
+ // Parse a punctuator token (`{`, `}`, `[`, `]`, `:`, or `,`) at
+ // the current position.
+ value = charIndexBuggy ? source.charAt(Index) : source[Index];
+ Index++;
+ return value;
+ case 34:
+ // `"` delimits a JSON string; advance to the next character and
+ // begin parsing the string. String tokens are prefixed with the
+ // sentinel `@` character to distinguish them from punctuators and
+ // end-of-string tokens.
+ for (value = "@", Index++; Index < length;) {
+ charCode = source.charCodeAt(Index);
+ if (charCode < 32) {
+ // Unescaped ASCII control characters (those with a code unit
+ // less than the space character) are not permitted.
+ abort();
+ } else if (charCode == 92) {
+ // A reverse solidus (`\`) marks the beginning of an escaped
+ // control character (including `"`, `\`, and `/`) or Unicode
+ // escape sequence.
+ charCode = source.charCodeAt(++Index);
+ switch (charCode) {
+ case 92: case 34: case 47: case 98: case 116: case 110: case 102: case 114:
+ // Revive escaped control characters.
+ value += Unescapes[charCode];
+ Index++;
+ break;
+ case 117:
+ // `\u` marks the beginning of a Unicode escape sequence.
+ // Advance to the first character and validate the
+ // four-digit code point.
+ begin = ++Index;
+ for (position = Index + 4; Index < position; Index++) {
+ charCode = source.charCodeAt(Index);
+ // A valid sequence comprises four hexdigits (case-
+ // insensitive) that form a single hexadecimal value.
+ if (!(charCode >= 48 && charCode <= 57 || charCode >= 97 && charCode <= 102 || charCode >= 65 && charCode <= 70)) {
+ // Invalid Unicode escape sequence.
+ abort();
+ }
+ }
+ // Revive the escaped character.
+ value += fromCharCode("0x" + source.slice(begin, Index));
+ break;
+ default:
+ // Invalid escape sequence.
+ abort();
+ }
+ } else {
+ if (charCode == 34) {
+ // An unescaped double-quote character marks the end of the
+ // string.
+ break;
+ }
+ charCode = source.charCodeAt(Index);
+ begin = Index;
+ // Optimize for the common case where a string is valid.
+ while (charCode >= 32 && charCode != 92 && charCode != 34) {
+ charCode = source.charCodeAt(++Index);
+ }
+ // Append the string as-is.
+ value += source.slice(begin, Index);
+ }
+ }
+ if (source.charCodeAt(Index) == 34) {
+ // Advance to the next character and return the revived string.
+ Index++;
+ return value;
+ }
+ // Unterminated string.
+ abort();
+ default:
+ // Parse numbers and literals.
+ begin = Index;
+ // Advance past the negative sign, if one is specified.
+ if (charCode == 45) {
+ isSigned = true;
+ charCode = source.charCodeAt(++Index);
+ }
+ // Parse an integer or floating-point value.
+ if (charCode >= 48 && charCode <= 57) {
+ // Leading zeroes are interpreted as octal literals.
+ if (charCode == 48 && ((charCode = source.charCodeAt(Index + 1)), charCode >= 48 && charCode <= 57)) {
+ // Illegal octal literal.
+ abort();
+ }
+ isSigned = false;
+ // Parse the integer component.
+ for (; Index < length && ((charCode = source.charCodeAt(Index)), charCode >= 48 && charCode <= 57); Index++);
+ // Floats cannot contain a leading decimal point; however, this
+ // case is already accounted for by the parser.
+ if (source.charCodeAt(Index) == 46) {
+ position = ++Index;
+ // Parse the decimal component.
+ for (; position < length && ((charCode = source.charCodeAt(position)), charCode >= 48 && charCode <= 57); position++);
+ if (position == Index) {
+ // Illegal trailing decimal.
+ abort();
+ }
+ Index = position;
+ }
+ // Parse exponents. The `e` denoting the exponent is
+ // case-insensitive.
+ charCode = source.charCodeAt(Index);
+ if (charCode == 101 || charCode == 69) {
+ charCode = source.charCodeAt(++Index);
+ // Skip past the sign following the exponent, if one is
+ // specified.
+ if (charCode == 43 || charCode == 45) {
+ Index++;
+ }
+ // Parse the exponential component.
+ for (position = Index; position < length && ((charCode = source.charCodeAt(position)), charCode >= 48 && charCode <= 57); position++);
+ if (position == Index) {
+ // Illegal empty exponent.
+ abort();
+ }
+ Index = position;
+ }
+ // Coerce the parsed value to a JavaScript number.
+ return +source.slice(begin, Index);
+ }
+ // A negative sign may only precede numbers.
+ if (isSigned) {
+ abort();
+ }
+ // `true`, `false`, and `null` literals.
+ if (source.slice(Index, Index + 4) == "true") {
+ Index += 4;
+ return true;
+ } else if (source.slice(Index, Index + 5) == "false") {
+ Index += 5;
+ return false;
+ } else if (source.slice(Index, Index + 4) == "null") {
+ Index += 4;
+ return null;
+ }
+ // Unrecognized token.
+ abort();
+ }
+ }
+ // Return the sentinel `$` character if the parser has reached the end
+ // of the source string.
+ return "$";
+ };
+
+ // Internal: Parses a JSON `value` token.
+ var get = function (value) {
+ var results, hasMembers;
+ if (value == "$") {
+ // Unexpected end of input.
+ abort();
+ }
+ if (typeof value == "string") {
+ if ((charIndexBuggy ? value.charAt(0) : value[0]) == "@") {
+ // Remove the sentinel `@` character.
+ return value.slice(1);
+ }
+ // Parse object and array literals.
+ if (value == "[") {
+ // Parses a JSON array, returning a new JavaScript array.
+ results = [];
+ for (;; hasMembers || (hasMembers = true)) {
+ value = lex();
+ // A closing square bracket marks the end of the array literal.
+ if (value == "]") {
+ break;
+ }
+ // If the array literal contains elements, the current token
+ // should be a comma separating the previous element from the
+ // next.
+ if (hasMembers) {
+ if (value == ",") {
+ value = lex();
+ if (value == "]") {
+ // Unexpected trailing `,` in array literal.
+ abort();
+ }
+ } else {
+ // A `,` must separate each array element.
+ abort();
+ }
+ }
+ // Elisions and leading commas are not permitted.
+ if (value == ",") {
+ abort();
+ }
+ results.push(get(value));
+ }
+ return results;
+ } else if (value == "{") {
+ // Parses a JSON object, returning a new JavaScript object.
+ results = {};
+ for (;; hasMembers || (hasMembers = true)) {
+ value = lex();
+ // A closing curly brace marks the end of the object literal.
+ if (value == "}") {
+ break;
+ }
+ // If the object literal contains members, the current token
+ // should be a comma separator.
+ if (hasMembers) {
+ if (value == ",") {
+ value = lex();
+ if (value == "}") {
+ // Unexpected trailing `,` in object literal.
+ abort();
+ }
+ } else {
+ // A `,` must separate each object member.
+ abort();
+ }
+ }
+ // Leading commas are not permitted, object property names must be
+ // double-quoted strings, and a `:` must separate each property
+ // name and value.
+ if (value == "," || typeof value != "string" || (charIndexBuggy ? value.charAt(0) : value[0]) != "@" || lex() != ":") {
+ abort();
+ }
+ results[value.slice(1)] = get(lex());
+ }
+ return results;
+ }
+ // Unexpected token encountered.
+ abort();
+ }
+ return value;
+ };
+
+ // Internal: Updates a traversed object member.
+ var update = function (source, property, callback) {
+ var element = walk(source, property, callback);
+ if (element === undef) {
+ delete source[property];
+ } else {
+ source[property] = element;
+ }
+ };
+
+ // Internal: Recursively traverses a parsed JSON object, invoking the
+ // `callback` function for each value. This is an implementation of the
+ // `Walk(holder, name)` operation defined in ES 5.1 section 15.12.2.
+ var walk = function (source, property, callback) {
+ var value = source[property], length;
+ if (typeof value == "object" && value) {
+ // `forEach` can't be used to traverse an array in Opera <= 8.54
+ // because its `Object#hasOwnProperty` implementation returns `false`
+ // for array indices (e.g., `![1, 2, 3].hasOwnProperty("0")`).
+ if (getClass.call(value) == arrayClass) {
+ for (length = value.length; length--;) {
+ update(value, length, callback);
+ }
+ } else {
+ forEach(value, function (property) {
+ update(value, property, callback);
+ });
+ }
+ }
+ return callback.call(source, property, value);
+ };
+
+ // Public: `JSON.parse`. See ES 5.1 section 15.12.2.
+ exports.parse = function (source, callback) {
+ var result, value;
+ Index = 0;
+ Source = "" + source;
+ result = get(lex());
+ // If a JSON string contains multiple tokens, it is invalid.
+ if (lex() != "$") {
+ abort();
+ }
+ // Reset the parser state.
+ Index = Source = null;
+ return callback && getClass.call(callback) == functionClass ? walk((value = {}, value[""] = result, value), "", callback) : result;
+ };
+ }
+ }
+
+ exports["runInContext"] = runInContext;
+ return exports;
+ }
+
+ if (freeExports && !isLoader) {
+ // Export for CommonJS environments.
+ runInContext(root, freeExports);
+ } else {
+ // Export for web browsers and JavaScript engines.
+ var nativeJSON = root.JSON,
+ previousJSON = root["JSON3"],
+ isRestored = false;
+
+ var JSON3 = runInContext(root, (root["JSON3"] = {
+ // Public: Restores the original value of the global `JSON` object and
+ // returns a reference to the `JSON3` object.
+ "noConflict": function () {
+ if (!isRestored) {
+ isRestored = true;
+ root.JSON = nativeJSON;
+ root["JSON3"] = previousJSON;
+ nativeJSON = previousJSON = null;
+ }
+ return JSON3;
+ }
+ }));
+
+ root.JSON = {
+ "parse": JSON3.parse,
+ "stringify": JSON3.stringify
+ };
+ }
+
+ // Export for asynchronous module loaders.
+ if (isLoader) {
+ define(function () {
+ return JSON3;
+ });
+ }
+ }).call(this);
+ /************************************************************
+ * end JSON
+ ************************************************************/
+
+ JSON_PIWIK = exports;
+
+ })();
+}
+
+/* startjslint */
+/*jslint browser:true, plusplus:true, vars:true, nomen:true, evil:true, regexp: false, bitwise: true, white: true */
+/*global JSON_PIWIK */
+/*global window */
+/*global unescape */
+/*global ActiveXObject */
+/*global Blob */
+/*members Piwik, Matomo, encodeURIComponent, decodeURIComponent, getElementsByTagName,
+ shift, unshift, piwikAsyncInit, piwikPluginAsyncInit, frameElement, self, hasFocus,
+ createElement, appendChild, characterSet, charset, all,
+ addEventListener, attachEvent, removeEventListener, detachEvent, disableCookies,
+ cookie, domain, readyState, documentElement, doScroll, title, text,
+ location, top, onerror, document, referrer, parent, links, href, protocol, name, GearsFactory,
+ performance, mozPerformance, msPerformance, webkitPerformance, timing, requestStart,
+ responseEnd, event, which, button, srcElement, type, target,
+ parentNode, tagName, hostname, className,
+ userAgent, cookieEnabled, sendBeacon, platform, mimeTypes, enabledPlugin, javaEnabled,
+ XMLHttpRequest, ActiveXObject, open, setRequestHeader, onreadystatechange, send, readyState, status,
+ getTime, getTimeAlias, setTime, toGMTString, getHours, getMinutes, getSeconds,
+ toLowerCase, toUpperCase, charAt, indexOf, lastIndexOf, split, slice,
+ onload, src,
+ min, round, random, floor,
+ exec,
+ res, width, height,
+ pdf, qt, realp, wma, dir, fla, java, gears, ag,
+ initialized, hook, getHook, resetUserId, getVisitorId, getVisitorInfo, setUserId, getUserId, setSiteId, getSiteId, setTrackerUrl, getTrackerUrl, appendToTrackingUrl, getRequest, addPlugin,
+ getAttributionInfo, getAttributionCampaignName, getAttributionCampaignKeyword,
+ getAttributionReferrerTimestamp, getAttributionReferrerUrl,
+ setCustomData, getCustomData,
+ setCustomRequestProcessing,
+ setCustomVariable, getCustomVariable, deleteCustomVariable, storeCustomVariablesInCookie, setCustomDimension, getCustomDimension,
+ deleteCustomVariables, deleteCustomDimension, setDownloadExtensions, addDownloadExtensions, removeDownloadExtensions,
+ setDomains, setIgnoreClasses, setRequestMethod, setRequestContentType,
+ setReferrerUrl, setCustomUrl, setAPIUrl, setDocumentTitle, getPiwikUrl, getCurrentUrl,
+ setDownloadClasses, setLinkClasses,
+ setCampaignNameKey, setCampaignKeywordKey,
+ getConsentRequestsQueue, requireConsent, getRememberedConsent, hasRememberedConsent, setConsentGiven,
+ rememberConsentGiven, forgetConsentGiven, unload, hasConsent,
+ discardHashTag,
+ setCookieNamePrefix, setCookieDomain, setCookiePath, setSecureCookie, setVisitorIdCookie, getCookieDomain, hasCookies, setSessionCookie,
+ setVisitorCookieTimeout, setSessionCookieTimeout, setReferralCookieTimeout, getCookie, getCookiePath, getSessionCookieTimeout,
+ setConversionAttributionFirstReferrer, tracker, request,
+ disablePerformanceTracking, setGenerationTimeMs,
+ doNotTrack, setDoNotTrack, msDoNotTrack, getValuesFromVisitorIdCookie,
+ enableCrossDomainLinking, disableCrossDomainLinking, isCrossDomainLinkingEnabled, setCrossDomainLinkingTimeout, getCrossDomainLinkingUrlParameter,
+ addListener, enableLinkTracking, enableJSErrorTracking, setLinkTrackingTimer, getLinkTrackingTimer,
+ enableHeartBeatTimer, disableHeartBeatTimer, killFrame, redirectFile, setCountPreRendered,
+ trackGoal, trackLink, trackPageView, getNumTrackedPageViews, trackRequest, trackSiteSearch, trackEvent,
+ setEcommerceView, addEcommerceItem, removeEcommerceItem, clearEcommerceCart, trackEcommerceOrder, trackEcommerceCartUpdate,
+ deleteCookie, deleteCookies, offsetTop, offsetLeft, offsetHeight, offsetWidth, nodeType, defaultView,
+ innerHTML, scrollLeft, scrollTop, currentStyle, getComputedStyle, querySelectorAll, splice,
+ getAttribute, hasAttribute, attributes, nodeName, findContentNodes, findContentNodes, findContentNodesWithinNode,
+ findPieceNode, findTargetNodeNoDefault, findTargetNode, findContentPiece, children, hasNodeCssClass,
+ getAttributeValueFromNode, hasNodeAttributeWithValue, hasNodeAttribute, findNodesByTagName, findMultiple,
+ makeNodesUnique, concat, find, htmlCollectionToArray, offsetParent, value, nodeValue, findNodesHavingAttribute,
+ findFirstNodeHavingAttribute, findFirstNodeHavingAttributeWithValue, getElementsByClassName,
+ findNodesHavingCssClass, findFirstNodeHavingClass, isLinkElement, findParentContentNode, removeDomainIfIsInLink,
+ findContentName, findMediaUrlInNode, toAbsoluteUrl, findContentTarget, getLocation, origin, host, isSameDomain,
+ search, trim, getBoundingClientRect, bottom, right, left, innerWidth, innerHeight, clientWidth, clientHeight,
+ isOrWasNodeInViewport, isNodeVisible, buildInteractionRequestParams, buildImpressionRequestParams,
+ shouldIgnoreInteraction, setHrefAttribute, setAttribute, buildContentBlock, collectContent, setLocation,
+ CONTENT_ATTR, CONTENT_CLASS, CONTENT_NAME_ATTR, CONTENT_PIECE_ATTR, CONTENT_PIECE_CLASS,
+ CONTENT_TARGET_ATTR, CONTENT_TARGET_CLASS, CONTENT_IGNOREINTERACTION_ATTR, CONTENT_IGNOREINTERACTION_CLASS,
+ trackCallbackOnLoad, trackCallbackOnReady, buildContentImpressionsRequests, wasContentImpressionAlreadyTracked,
+ getQuery, getContent, setVisitorId, getContentImpressionsRequestsFromNodes, buildContentInteractionTrackingRedirectUrl,
+ buildContentInteractionRequestNode, buildContentInteractionRequest, buildContentImpressionRequest,
+ appendContentInteractionToRequestIfPossible, setupInteractionsTracking, trackContentImpressionClickInteraction,
+ internalIsNodeVisible, clearTrackedContentImpressions, getTrackerUrl, trackAllContentImpressions,
+ getTrackedContentImpressions, getCurrentlyVisibleContentImpressionsRequestsIfNotTrackedYet,
+ contentInteractionTrackingSetupDone, contains, match, pathname, piece, trackContentInteractionNode,
+ trackContentInteractionNode, trackContentImpressionsWithinNode, trackContentImpression,
+ enableTrackOnlyVisibleContent, trackContentInteraction, clearEnableTrackOnlyVisibleContent, logAllContentBlocksOnPage,
+ trackVisibleContentImpressions, isTrackOnlyVisibleContentEnabled, port, isUrlToCurrentDomain, piwikTrackers,
+ isNodeAuthorizedToTriggerInteraction, replaceHrefIfInternalLink, getConfigDownloadExtensions, disableLinkTracking,
+ substr, setAnyAttribute, wasContentTargetAttrReplaced, max, abs, childNodes, compareDocumentPosition, body,
+ getConfigVisitorCookieTimeout, getRemainingVisitorCookieTimeout, getDomains, getConfigCookiePath,
+ getConfigIdPageView, newVisitor, uuid, createTs, visitCount, currentVisitTs, lastVisitTs, lastEcommerceOrderTs,
+ "", "\b", "\t", "\n", "\f", "\r", "\"", "\\", apply, call, charCodeAt, getUTCDate, getUTCFullYear, getUTCHours,
+ getUTCMinutes, getUTCMonth, getUTCSeconds, hasOwnProperty, join, lastIndex, length, parse, prototype, push, replace,
+ sort, slice, stringify, test, toJSON, toString, valueOf, objectToJSON, addTracker, removeAllAsyncTrackersButFirst,
+ optUserOut, forgetUserOptOut, isUserOptedOut
+ */
+/*global _paq:true */
+/*members push */
+/*global Piwik:true */
+/*members addPlugin, getTracker, getAsyncTracker, getAsyncTrackers, addTracker, trigger, on, off, retryMissedPluginCalls,
+ DOM, onLoad, onReady, isNodeVisible, isOrWasNodeVisible, JSON */
+/*global Piwik_Overlay_Client */
+/*global AnalyticsTracker:true */
+/*members initialize */
+/*global define */
+/*global console */
+/*members amd */
+/*members error */
+/*members log */
+
+// asynchronous tracker (or proxy)
+if (typeof _paq !== 'object') {
+ _paq = [];
+}
+
+// Piwik singleton and namespace
+if (typeof window.Piwik !== 'object') {
+ window.Matomo = window.Piwik = (function () {
+ 'use strict';
+
+ /************************************************************
+ * Private data
+ ************************************************************/
+
+ var expireDateTime,
+
+ /* plugins */
+ plugins = {},
+
+ eventHandlers = {},
+
+ /* alias frequently used globals for added minification */
+ documentAlias = document,
+ navigatorAlias = navigator,
+ screenAlias = screen,
+ windowAlias = window,
+
+ /* performance timing */
+ performanceAlias = windowAlias.performance || windowAlias.mozPerformance || windowAlias.msPerformance || windowAlias.webkitPerformance,
+
+ /* encode */
+ encodeWrapper = windowAlias.encodeURIComponent,
+
+ /* decode */
+ decodeWrapper = windowAlias.decodeURIComponent,
+
+ /* urldecode */
+ urldecode = unescape,
+
+ /* asynchronous tracker */
+ asyncTrackers = [],
+
+ /* iterator */
+ iterator,
+
+ /* local Piwik */
+ Piwik,
+
+ missedPluginTrackerCalls = [],
+
+ coreConsentCounter = 0,
+
+ isPageUnloading = false;
+
+ /************************************************************
+ * Private methods
+ ************************************************************/
+
+ /**
+ * See https://github.com/piwik/piwik/issues/8413
+ * To prevent Javascript Error: Uncaught URIError: URI malformed when encoding is not UTF-8. Use this method
+ * instead of decodeWrapper if a text could contain any non UTF-8 encoded characters eg
+ * a URL like http://apache.piwik/test.html?%F6%E4%FC or a link like
+ * <a href="test-with-%F6%E4%FC/story/0">(encoded iso-8859-1 URL)</a>
+ */
+ function safeDecodeWrapper(url)
+ {
+ try {
+ return decodeWrapper(url);
+ } catch (e) {
+ return unescape(url);
+ }
+ }
+
+ /*
+ * Is property defined?
+ */
+ function isDefined(property) {
+ // workaround https://github.com/douglascrockford/JSLint/commit/24f63ada2f9d7ad65afc90e6d949f631935c2480
+ var propertyType = typeof property;
+
+ return propertyType !== 'undefined';
+ }
+
+ /*
+ * Is property a function?
+ */
+ function isFunction(property) {
+ return typeof property === 'function';
+ }
+
+ /*
+ * Is property an object?
+ *
+ * @return bool Returns true if property is null, an Object, or subclass of Object (i.e., an instanceof String, Date, etc.)
+ */
+ function isObject(property) {
+ return typeof property === 'object';
+ }
+
+ /*
+ * Is property a string?
+ */
+ function isString(property) {
+ return typeof property === 'string' || property instanceof String;
+ }
+
+ function isObjectEmpty(property)
+ {
+ if (!property) {
+ return true;
+ }
+
+ var i;
+ var isEmpty = true;
+ for (i in property) {
+ if (Object.prototype.hasOwnProperty.call(property, i)) {
+ isEmpty = false;
+ }
+ }
+
+ return isEmpty;
+ }
+
+ /**
+ * Logs an error in the console.
+ * Note: it does not generate a JavaScript error, so make sure to also generate an error if needed.
+ * @param message
+ */
+ function logConsoleError(message) {
+ // needed to write it this way for jslint
+ var consoleType = typeof console;
+ if (consoleType !== 'undefined' && console && console.error) {
+ console.error(message);
+ }
+ }
+
+ /*
+ * apply wrapper
+ *
+ * @param array parameterArray An array comprising either:
+ * [ 'methodName', optional_parameters ]
+ * or:
+ * [ functionObject, optional_parameters ]
+ */
+ function apply() {
+ var i, j, f, parameterArray, trackerCall;
+
+ for (i = 0; i < arguments.length; i += 1) {
+ trackerCall = null;
+ if (arguments[i] && arguments[i].slice) {
+ trackerCall = arguments[i].slice();
+ }
+ parameterArray = arguments[i];
+ f = parameterArray.shift();
+
+ var fParts, context;
+
+ var isStaticPluginCall = isString(f) && f.indexOf('::') > 0;
+ if (isStaticPluginCall) {
+ // a static method will not be called on a tracker and is not dependent on the existence of a
+ // tracker etc
+ fParts = f.split('::');
+ context = fParts[0];
+ f = fParts[1];
+
+ if ('object' === typeof Piwik[context] && 'function' === typeof Piwik[context][f]) {
+ Piwik[context][f].apply(Piwik[context], parameterArray);
+ } else if (trackerCall) {
+ // we try to call that method again later as the plugin might not be loaded yet
+ // a plugin can call "Piwik.retryMissedPluginCalls();" once it has been loaded and then the
+ // method call to "Piwik[context][f]" may be executed
+ missedPluginTrackerCalls.push(trackerCall);
+ }
+
+ } else {
+ for (j = 0; j < asyncTrackers.length; j++) {
+ if (isString(f)) {
+ context = asyncTrackers[j];
+
+ var isPluginTrackerCall = f.indexOf('.') > 0;
+
+ if (isPluginTrackerCall) {
+ fParts = f.split('.');
+ if (context && 'object' === typeof context[fParts[0]]) {
+ context = context[fParts[0]];
+ f = fParts[1];
+ } else if (trackerCall) {
+ // we try to call that method again later as the plugin might not be loaded yet
+ missedPluginTrackerCalls.push(trackerCall);
+ break;
+ }
+ }
+
+ if (context[f]) {
+ context[f].apply(context, parameterArray);
+ } else {
+ var message = 'The method \'' + f + '\' was not found in "_paq" variable. Please have a look at the Piwik tracker documentation: https://developer.piwik.org/api-reference/tracking-javascript';
+ logConsoleError(message);
+
+ if (!isPluginTrackerCall) {
+ // do not trigger an error if it is a call to a plugin as the plugin may just not be
+ // loaded yet etc
+ throw new TypeError(message);
+ }
+ }
+
+ if (f === 'addTracker') {
+ // addTracker adds an entry to asyncTrackers and would otherwise result in an endless loop
+ break;
+ }
+
+ if (f === 'setTrackerUrl' || f === 'setSiteId') {
+ // these two methods should be only executed on the first tracker
+ break;
+ }
+ } else {
+ f.apply(asyncTrackers[j], parameterArray);
+ }
+ }
+ }
+ }
+ }
+
+ /*
+ * Cross-browser helper function to add event handler
+ */
+ function addEventListener(element, eventType, eventHandler, useCapture) {
+ if (element.addEventListener) {
+ element.addEventListener(eventType, eventHandler, useCapture);
+
+ return true;
+ }
+
+ if (element.attachEvent) {
+ return element.attachEvent('on' + eventType, eventHandler);
+ }
+
+ element['on' + eventType] = eventHandler;
+ }
+
+ function trackCallbackOnLoad(callback)
+ {
+ if (documentAlias.readyState === 'complete') {
+ callback();
+ } else if (windowAlias.addEventListener) {
+ windowAlias.addEventListener('load', callback, false);
+ } else if (windowAlias.attachEvent) {
+ windowAlias.attachEvent('onload', callback);
+ }
+ }
+
+ function trackCallbackOnReady(callback)
+ {
+ var loaded = false;
+
+ if (documentAlias.attachEvent) {
+ loaded = documentAlias.readyState === 'complete';
+ } else {
+ loaded = documentAlias.readyState !== 'loading';
+ }
+
+ if (loaded) {
+ callback();
+ return;
+ }
+
+ var _timer;
+
+ if (documentAlias.addEventListener) {
+ addEventListener(documentAlias, 'DOMContentLoaded', function ready() {
+ documentAlias.removeEventListener('DOMContentLoaded', ready, false);
+ if (!loaded) {
+ loaded = true;
+ callback();
+ }
+ });
+ } else if (documentAlias.attachEvent) {
+ documentAlias.attachEvent('onreadystatechange', function ready() {
+ if (documentAlias.readyState === 'complete') {
+ documentAlias.detachEvent('onreadystatechange', ready);
+ if (!loaded) {
+ loaded = true;
+ callback();
+ }
+ }
+ });
+
+ if (documentAlias.documentElement.doScroll && windowAlias === windowAlias.top) {
+ (function ready() {
+ if (!loaded) {
+ try {
+ documentAlias.documentElement.doScroll('left');
+ } catch (error) {
+ setTimeout(ready, 0);
+
+ return;
+ }
+ loaded = true;
+ callback();
+ }
+ }());
+ }
+ }
+
+ // fallback
+ addEventListener(windowAlias, 'load', function () {
+ if (!loaded) {
+ loaded = true;
+ callback();
+ }
+ }, false);
+ }
+
+ /*
+ * Call plugin hook methods
+ */
+ function executePluginMethod(methodName, params, callback) {
+ if (!methodName) {
+ return '';
+ }
+
+ var result = '',
+ i,
+ pluginMethod, value, isFunction;
+
+ for (i in plugins) {
+ if (Object.prototype.hasOwnProperty.call(plugins, i)) {
+ isFunction = plugins[i] && 'function' === typeof plugins[i][methodName];
+
+ if (isFunction) {
+ pluginMethod = plugins[i][methodName];
+ value = pluginMethod(params || {}, callback);
+
+ if (value) {
+ result += value;
+ }
+ }
+ }
+ }
+
+ return result;
+ }
+
+ /*
+ * Handle beforeunload event
+ *
+ * Subject to Safari's "Runaway JavaScript Timer" and
+ * Chrome V8 extension that terminates JS that exhibits
+ * "slow unload", i.e., calling getTime() > 1000 times
+ */
+ function beforeUnloadHandler() {
+ var now;
+ isPageUnloading = true;
+
+ executePluginMethod('unload');
+ /*
+ * Delay/pause (blocks UI)
+ */
+ if (expireDateTime) {
+ // the things we do for backwards compatibility...
+ // in ECMA-262 5th ed., we could simply use:
+ // while (Date.now() < expireDateTime) { }
+ do {
+ now = new Date();
+ } while (now.getTimeAlias() < expireDateTime);
+ }
+ }
+
+ /*
+ * Load JavaScript file (asynchronously)
+ */
+ function loadScript(src, onLoad) {
+ var script = documentAlias.createElement('script');
+
+ script.type = 'text/javascript';
+ script.src = src;
+
+ if (script.readyState) {
+ script.onreadystatechange = function () {
+ var state = this.readyState;
+
+ if (state === 'loaded' || state === 'complete') {
+ script.onreadystatechange = null;
+ onLoad();
+ }
+ };
+ } else {
+ script.onload = onLoad;
+ }
+
+ documentAlias.getElementsByTagName('head')[0].appendChild(script);
+ }
+
+ /*
+ * Get page referrer
+ */
+ function getReferrer() {
+ var referrer = '';
+
+ try {
+ referrer = windowAlias.top.document.referrer;
+ } catch (e) {
+ if (windowAlias.parent) {
+ try {
+ referrer = windowAlias.parent.document.referrer;
+ } catch (e2) {
+ referrer = '';
+ }
+ }
+ }
+
+ if (referrer === '') {
+ referrer = documentAlias.referrer;
+ }
+
+ return referrer;
+ }
+
+ /*
+ * Extract scheme/protocol from URL
+ */
+ function getProtocolScheme(url) {
+ var e = new RegExp('^([a-z]+):'),
+ matches = e.exec(url);
+
+ return matches ? matches[1] : null;
+ }
+
+ /*
+ * Extract hostname from URL
+ */
+ function getHostName(url) {
+ // scheme : // [username [: password] @] hostame [: port] [/ [path] [? query] [# fragment]]
+ var e = new RegExp('^(?:(?:https?|ftp):)/*(?:[^@]+@)?([^:/#]+)'),
+ matches = e.exec(url);
+
+ return matches ? matches[1] : url;
+ }
+
+ function stringStartsWith(str, prefix) {
+ str = String(str);
+ return str.lastIndexOf(prefix, 0) === 0;
+ }
+
+ function stringEndsWith(str, suffix) {
+ str = String(str);
+ return str.indexOf(suffix, str.length - suffix.length) !== -1;
+ }
+
+ function stringContains(str, needle) {
+ str = String(str);
+ return str.indexOf(needle) !== -1;
+ }
+
+ function removeCharactersFromEndOfString(str, numCharactersToRemove) {
+ str = String(str);
+ return str.substr(0, str.length - numCharactersToRemove);
+ }
+
+ /**
+ * We do not check whether URL contains already url parameter, please use removeUrlParameter() if needed
+ * before calling this method.
+ * This method makes sure to append URL parameters before a possible hash. Will escape (encode URI component)
+ * the set name and value
+ */
+ function addUrlParameter(url, name, value) {
+ url = String(url);
+
+ if (!value) {
+ value = '';
+ }
+
+ var hashPos = url.indexOf('#');
+ var urlLength = url.length;
+
+ if (hashPos === -1) {
+ hashPos = urlLength;
+ }
+
+ var baseUrl = url.substr(0, hashPos);
+ var urlHash = url.substr(hashPos, urlLength - hashPos);
+
+ if (baseUrl.indexOf('?') === -1) {
+ baseUrl += '?';
+ } else if (!stringEndsWith(baseUrl, '?')) {
+ baseUrl += '&';
+ }
+ // nothing to if ends with ?
+
+ return baseUrl + encodeWrapper(name) + '=' + encodeWrapper(value) + urlHash;
+ }
+
+ function removeUrlParameter(url, name) {
+ url = String(url);
+
+ if (url.indexOf('?' + name + '=') === -1 && url.indexOf('&' + name + '=') === -1) {
+ // nothing to remove, url does not contain this parameter
+ return url;
+ }
+
+ var searchPos = url.indexOf('?');
+ if (searchPos === -1) {
+ // nothing to remove, no query parameters
+ return url;
+ }
+
+ var queryString = url.substr(searchPos + 1);
+ var baseUrl = url.substr(0, searchPos);
+
+ if (queryString) {
+ var urlHash = '';
+ var hashPos = queryString.indexOf('#');
+ if (hashPos !== -1) {
+ urlHash = queryString.substr(hashPos + 1);
+ queryString = queryString.substr(0, hashPos);
+ }
+
+ var param;
+ var paramsArr = queryString.split('&');
+ var i = paramsArr.length - 1;
+
+ for (i; i >= 0; i--) {
+ param = paramsArr[i].split('=')[0];
+ if (param === name) {
+ paramsArr.splice(i, 1);
+ }
+ }
+
+ var newQueryString = paramsArr.join('&');
+
+ if (newQueryString) {
+ baseUrl = baseUrl + '?' + newQueryString;
+ }
+
+ if (urlHash) {
+ baseUrl += '#' + urlHash;
+ }
+ }
+
+ return baseUrl;
+ }
+
+ /*
+ * Extract parameter from URL
+ */
+ function getUrlParameter(url, name) {
+ var regexSearch = "[\\?&#]" + name + "=([^&#]*)";
+ var regex = new RegExp(regexSearch);
+ var results = regex.exec(url);
+ return results ? decodeWrapper(results[1]) : '';
+ }
+
+ function trim(text)
+ {
+ if (text && String(text) === text) {
+ return text.replace(/^\s+|\s+$/g, '');
+ }
+
+ return text;
+ }
+
+ /*
+ * UTF-8 encoding
+ */
+ function utf8_encode(argString) {
+ return unescape(encodeWrapper(argString));
+ }
+
+ /************************************************************
+ * sha1
+ * - based on sha1 from http://phpjs.org/functions/sha1:512 (MIT / GPL v2)
+ ************************************************************/
+
+ function sha1(str) {
+ // + original by: Webtoolkit.info (http://www.webtoolkit.info/)
+ // + namespaced by: Michael White (http://getsprink.com)
+ // + input by: Brett Zamir (http://brett-zamir.me)
+ // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
+ // + jslinted by: Anthon Pang (http://piwik.org)
+
+ var
+ rotate_left = function (n, s) {
+ return (n << s) | (n >>> (32 - s));
+ },
+
+ cvt_hex = function (val) {
+ var strout = '',
+ i,
+ v;
+
+ for (i = 7; i >= 0; i--) {
+ v = (val >>> (i * 4)) & 0x0f;
+ strout += v.toString(16);
+ }
+
+ return strout;
+ },
+
+ blockstart,
+ i,
+ j,
+ W = [],
+ H0 = 0x67452301,
+ H1 = 0xEFCDAB89,
+ H2 = 0x98BADCFE,
+ H3 = 0x10325476,
+ H4 = 0xC3D2E1F0,
+ A,
+ B,
+ C,
+ D,
+ E,
+ temp,
+ str_len,
+ word_array = [];
+
+ str = utf8_encode(str);
+ str_len = str.length;
+
+ for (i = 0; i < str_len - 3; i += 4) {
+ j = str.charCodeAt(i) << 24 | str.charCodeAt(i + 1) << 16 |
+ str.charCodeAt(i + 2) << 8 | str.charCodeAt(i + 3);
+ word_array.push(j);
+ }
+
+ switch (str_len & 3) {
+ case 0:
+ i = 0x080000000;
+ break;
+ case 1:
+ i = str.charCodeAt(str_len - 1) << 24 | 0x0800000;
+ break;
+ case 2:
+ i = str.charCodeAt(str_len - 2) << 24 | str.charCodeAt(str_len - 1) << 16 | 0x08000;
+ break;
+ case 3:
+ i = str.charCodeAt(str_len - 3) << 24 | str.charCodeAt(str_len - 2) << 16 | str.charCodeAt(str_len - 1) << 8 | 0x80;
+ break;
+ }
+
+ word_array.push(i);
+
+ while ((word_array.length & 15) !== 14) {
+ word_array.push(0);
+ }
+
+ word_array.push(str_len >>> 29);
+ word_array.push((str_len << 3) & 0x0ffffffff);
+
+ for (blockstart = 0; blockstart < word_array.length; blockstart += 16) {
+ for (i = 0; i < 16; i++) {
+ W[i] = word_array[blockstart + i];
+ }
+
+ for (i = 16; i <= 79; i++) {
+ W[i] = rotate_left(W[i - 3] ^ W[i - 8] ^ W[i - 14] ^ W[i - 16], 1);
+ }
+
+ A = H0;
+ B = H1;
+ C = H2;
+ D = H3;
+ E = H4;
+
+ for (i = 0; i <= 19; i++) {
+ temp = (rotate_left(A, 5) + ((B & C) | (~B & D)) + E + W[i] + 0x5A827999) & 0x0ffffffff;
+ E = D;
+ D = C;
+ C = rotate_left(B, 30);
+ B = A;
+ A = temp;
+ }
+
+ for (i = 20; i <= 39; i++) {
+ temp = (rotate_left(A, 5) + (B ^ C ^ D) + E + W[i] + 0x6ED9EBA1) & 0x0ffffffff;
+ E = D;
+ D = C;
+ C = rotate_left(B, 30);
+ B = A;
+ A = temp;
+ }
+
+ for (i = 40; i <= 59; i++) {
+ temp = (rotate_left(A, 5) + ((B & C) | (B & D) | (C & D)) + E + W[i] + 0x8F1BBCDC) & 0x0ffffffff;
+ E = D;
+ D = C;
+ C = rotate_left(B, 30);
+ B = A;
+ A = temp;
+ }
+
+ for (i = 60; i <= 79; i++) {
+ temp = (rotate_left(A, 5) + (B ^ C ^ D) + E + W[i] + 0xCA62C1D6) & 0x0ffffffff;
+ E = D;
+ D = C;
+ C = rotate_left(B, 30);
+ B = A;
+ A = temp;
+ }
+
+ H0 = (H0 + A) & 0x0ffffffff;
+ H1 = (H1 + B) & 0x0ffffffff;
+ H2 = (H2 + C) & 0x0ffffffff;
+ H3 = (H3 + D) & 0x0ffffffff;
+ H4 = (H4 + E) & 0x0ffffffff;
+ }
+
+ temp = cvt_hex(H0) + cvt_hex(H1) + cvt_hex(H2) + cvt_hex(H3) + cvt_hex(H4);
+
+ return temp.toLowerCase();
+ }
+
+ /************************************************************
+ * end sha1
+ ************************************************************/
+
+ /*
+ * Fix-up URL when page rendered from search engine cache or translated page
+ */
+ function urlFixup(hostName, href, referrer) {
+ if (!hostName) {
+ hostName = '';
+ }
+
+ if (!href) {
+ href = '';
+ }
+
+ if (hostName === 'translate.googleusercontent.com') { // Google
+ if (referrer === '') {
+ referrer = href;
+ }
+
+ href = getUrlParameter(href, 'u');
+ hostName = getHostName(href);
+ } else if (hostName === 'cc.bingj.com' || // Bing
+ hostName === 'webcache.googleusercontent.com' || // Google
+ hostName.slice(0, 5) === '74.6.') { // Yahoo (via Inktomi 74.6.0.0/16)
+ href = documentAlias.links[0].href;
+ hostName = getHostName(href);
+ }
+
+ return [hostName, href, referrer];
+ }
+
+ /*
+ * Fix-up domain
+ */
+ function domainFixup(domain) {
+ var dl = domain.length;
+
+ // remove trailing '.'
+ if (domain.charAt(--dl) === '.') {
+ domain = domain.slice(0, dl);
+ }
+
+ // remove leading '*'
+ if (domain.slice(0, 2) === '*.') {
+ domain = domain.slice(1);
+ }
+
+ if (domain.indexOf('/') !== -1) {
+ domain = domain.substr(0, domain.indexOf('/'));
+ }
+
+ return domain;
+ }
+
+ /*
+ * Title fixup
+ */
+ function titleFixup(title) {
+ title = title && title.text ? title.text : title;
+
+ if (!isString(title)) {
+ var tmp = documentAlias.getElementsByTagName('title');
+
+ if (tmp && isDefined(tmp[0])) {
+ title = tmp[0].text;
+ }
+ }
+
+ return title;
+ }
+
+ function getChildrenFromNode(node)
+ {
+ if (!node) {
+ return [];
+ }
+
+ if (!isDefined(node.children) && isDefined(node.childNodes)) {
+ return node.children;
+ }
+
+ if (isDefined(node.children)) {
+ return node.children;
+ }
+
+ return [];
+ }
+
+ function containsNodeElement(node, containedNode)
+ {
+ if (!node || !containedNode) {
+ return false;
+ }
+
+ if (node.contains) {
+ return node.contains(containedNode);
+ }
+
+ if (node === containedNode) {
+ return true;
+ }
+
+ if (node.compareDocumentPosition) {
+ return !!(node.compareDocumentPosition(containedNode) & 16);
+ }
+
+ return false;
+ }
+
+ // Polyfill for IndexOf for IE6-IE8
+ function indexOfArray(theArray, searchElement)
+ {
+ if (theArray && theArray.indexOf) {
+ return theArray.indexOf(searchElement);
+ }
+
+ // 1. Let O be the result of calling ToObject passing
+ // the this value as the argument.
+ if (!isDefined(theArray) || theArray === null) {
+ return -1;
+ }
+
+ if (!theArray.length) {
+ return -1;
+ }
+
+ var len = theArray.length;
+
+ if (len === 0) {
+ return -1;
+ }
+
+ var k = 0;
+
+ // 9. Repeat, while k < len
+ while (k < len) {
+ // a. Let Pk be ToString(k).
+ // This is implicit for LHS operands of the in operator
+ // b. Let kPresent be the result of calling the
+ // HasProperty internal method of O with argument Pk.
+ // This step can be combined with c
+ // c. If kPresent is true, then
+ // i. Let elementK be the result of calling the Get
+ // internal method of O with the argument ToString(k).
+ // ii. Let same be the result of applying the
+ // Strict Equality Comparison Algorithm to
+ // searchElement and elementK.
+ // iii. If same is true, return k.
+ if (theArray[k] === searchElement) {
+ return k;
+ }
+ k++;
+ }
+ return -1;
+ }
+
+ /************************************************************
+ * Element Visiblility
+ ************************************************************/
+
+ /**
+ * Author: Jason Farrell
+ * Author URI: http://useallfive.com/
+ *
+ * Description: Checks if a DOM element is truly visible.
+ * Package URL: https://github.com/UseAllFive/true-visibility
+ * License: MIT (https://github.com/UseAllFive/true-visibility/blob/master/LICENSE.txt)
+ */
+ function isVisible(node) {
+
+ if (!node) {
+ return false;
+ }
+
+ //-- Cross browser method to get style properties:
+ function _getStyle(el, property) {
+ if (windowAlias.getComputedStyle) {
+ return documentAlias.defaultView.getComputedStyle(el,null)[property];
+ }
+ if (el.currentStyle) {
+ return el.currentStyle[property];
+ }
+ }
+
+ function _elementInDocument(element) {
+ element = element.parentNode;
+
+ while (element) {
+ if (element === documentAlias) {
+ return true;
+ }
+ element = element.parentNode;
+ }
+ return false;
+ }
+
+ /**
+ * Checks if a DOM element is visible. Takes into
+ * consideration its parents and overflow.
+ *
+ * @param (el) the DOM element to check if is visible
+ *
+ * These params are optional that are sent in recursively,
+ * you typically won't use these:
+ *
+ * @param (t) Top corner position number
+ * @param (r) Right corner position number
+ * @param (b) Bottom corner position number
+ * @param (l) Left corner position number
+ * @param (w) Element width number
+ * @param (h) Element height number
+ */
+ function _isVisible(el, t, r, b, l, w, h) {
+ var p = el.parentNode,
+ VISIBLE_PADDING = 1; // has to be visible at least one px of the element
+
+ if (!_elementInDocument(el)) {
+ return false;
+ }
+
+ //-- Return true for document node
+ if (9 === p.nodeType) {
+ return true;
+ }
+
+ //-- Return false if our element is invisible
+ if (
+ '0' === _getStyle(el, 'opacity') ||
+ 'none' === _getStyle(el, 'display') ||
+ 'hidden' === _getStyle(el, 'visibility')
+ ) {
+ return false;
+ }
+
+ if (!isDefined(t) ||
+ !isDefined(r) ||
+ !isDefined(b) ||
+ !isDefined(l) ||
+ !isDefined(w) ||
+ !isDefined(h)) {
+ t = el.offsetTop;
+ l = el.offsetLeft;
+ b = t + el.offsetHeight;
+ r = l + el.offsetWidth;
+ w = el.offsetWidth;
+ h = el.offsetHeight;
+ }
+
+ if (node === el && (0 === h || 0 === w) && 'hidden' === _getStyle(el, 'overflow')) {
+ return false;
+ }
+
+ //-- If we have a parent, let's continue:
+ if (p) {
+ //-- Check if the parent can hide its children.
+ if (('hidden' === _getStyle(p, 'overflow') || 'scroll' === _getStyle(p, 'overflow'))) {
+ //-- Only check if the offset is different for the parent
+ if (
+ //-- If the target element is to the right of the parent elm
+ l + VISIBLE_PADDING > p.offsetWidth + p.scrollLeft ||
+ //-- If the target element is to the left of the parent elm
+ l + w - VISIBLE_PADDING < p.scrollLeft ||
+ //-- If the target element is under the parent elm
+ t + VISIBLE_PADDING > p.offsetHeight + p.scrollTop ||
+ //-- If the target element is above the parent elm
+ t + h - VISIBLE_PADDING < p.scrollTop
+ ) {
+ //-- Our target element is out of bounds:
+ return false;
+ }
+ }
+ //-- Add the offset parent's left/top coords to our element's offset:
+ if (el.offsetParent === p) {
+ l += p.offsetLeft;
+ t += p.offsetTop;
+ }
+ //-- Let's recursively check upwards:
+ return _isVisible(p, t, r, b, l, w, h);
+ }
+ return true;
+ }
+
+ return _isVisible(node);
+ }
+
+ /************************************************************
+ * Query
+ ************************************************************/
+
+ var query = {
+ htmlCollectionToArray: function (foundNodes)
+ {
+ var nodes = [], index;
+
+ if (!foundNodes || !foundNodes.length) {
+ return nodes;
+ }
+
+ for (index = 0; index < foundNodes.length; index++) {
+ nodes.push(foundNodes[index]);
+ }
+
+ return nodes;
+ },
+ find: function (selector)
+ {
+ // we use querySelectorAll only on document, not on nodes because of its unexpected behavior. See for
+ // instance http://stackoverflow.com/questions/11503534/jquery-vs-document-queryselectorall and
+ // http://jsfiddle.net/QdMc5/ and http://ejohn.org/blog/thoughts-on-queryselectorall
+ if (!document.querySelectorAll || !selector) {
+ return []; // we do not support all browsers
+ }
+
+ var foundNodes = document.querySelectorAll(selector);
+
+ return this.htmlCollectionToArray(foundNodes);
+ },
+ findMultiple: function (selectors)
+ {
+ if (!selectors || !selectors.length) {
+ return [];
+ }
+
+ var index, foundNodes;
+ var nodes = [];
+ for (index = 0; index < selectors.length; index++) {
+ foundNodes = this.find(selectors[index]);
+ nodes = nodes.concat(foundNodes);
+ }
+
+ nodes = this.makeNodesUnique(nodes);
+
+ return nodes;
+ },
+ findNodesByTagName: function (node, tagName)
+ {
+ if (!node || !tagName || !node.getElementsByTagName) {
+ return [];
+ }
+
+ var foundNodes = node.getElementsByTagName(tagName);
+
+ return this.htmlCollectionToArray(foundNodes);
+ },
+ makeNodesUnique: function (nodes)
+ {
+ var copy = [].concat(nodes);
+ nodes.sort(function(n1, n2){
+ if (n1 === n2) {
+ return 0;
+ }
+
+ var index1 = indexOfArray(copy, n1);
+ var index2 = indexOfArray(copy, n2);
+
+ if (index1 === index2) {
+ return 0;
+ }
+
+ return index1 > index2 ? -1 : 1;
+ });
+
+ if (nodes.length <= 1) {
+ return nodes;
+ }
+
+ var index = 0;
+ var numDuplicates = 0;
+ var duplicates = [];
+ var node;
+
+ node = nodes[index++];
+
+ while (node) {
+ if (node === nodes[index]) {
+ numDuplicates = duplicates.push(index);
+ }
+
+ node = nodes[index++] || null;
+ }
+
+ while (numDuplicates--) {
+ nodes.splice(duplicates[numDuplicates], 1);
+ }
+
+ return nodes;
+ },
+ getAttributeValueFromNode: function (node, attributeName)
+ {
+ if (!this.hasNodeAttribute(node, attributeName)) {
+ return;
+ }
+
+ if (node && node.getAttribute) {
+ return node.getAttribute(attributeName);
+ }
+
+ if (!node || !node.attributes) {
+ return;
+ }
+
+ var typeOfAttr = (typeof node.attributes[attributeName]);
+ if ('undefined' === typeOfAttr) {
+ return;
+ }
+
+ if (node.attributes[attributeName].value) {
+ return node.attributes[attributeName].value; // nodeValue is deprecated ie Chrome
+ }
+
+ if (node.attributes[attributeName].nodeValue) {
+ return node.attributes[attributeName].nodeValue;
+ }
+
+ var index;
+ var attrs = node.attributes;
+
+ if (!attrs) {
+ return;
+ }
+
+ for (index = 0; index < attrs.length; index++) {
+ if (attrs[index].nodeName === attributeName) {
+ return attrs[index].nodeValue;
+ }
+ }
+
+ return null;
+ },
+ hasNodeAttributeWithValue: function (node, attributeName)
+ {
+ var value = this.getAttributeValueFromNode(node, attributeName);
+
+ return !!value;
+ },
+ hasNodeAttribute: function (node, attributeName)
+ {
+ if (node && node.hasAttribute) {
+ return node.hasAttribute(attributeName);
+ }
+
+ if (node && node.attributes) {
+ var typeOfAttr = (typeof node.attributes[attributeName]);
+ return 'undefined' !== typeOfAttr;
+ }
+
+ return false;
+ },
+ hasNodeCssClass: function (node, klassName)
+ {
+ if (node && klassName && node.className) {
+ var classes = typeof node.className === "string" ? node.className.split(' ') : [];
+ if (-1 !== indexOfArray(classes, klassName)) {
+ return true;
+ }
+ }
+
+ return false;
+ },
+ findNodesHavingAttribute: function (nodeToSearch, attributeName, nodes)
+ {
+ if (!nodes) {
+ nodes = [];
+ }
+
+ if (!nodeToSearch || !attributeName) {
+ return nodes;
+ }
+
+ var children = getChildrenFromNode(nodeToSearch);
+
+ if (!children || !children.length) {
+ return nodes;
+ }
+
+ var index, child;
+ for (index = 0; index < children.length; index++) {
+ child = children[index];
+ if (this.hasNodeAttribute(child, attributeName)) {
+ nodes.push(child);
+ }
+
+ nodes = this.findNodesHavingAttribute(child, attributeName, nodes);
+ }
+
+ return nodes;
+ },
+ findFirstNodeHavingAttribute: function (node, attributeName)
+ {
+ if (!node || !attributeName) {
+ return;
+ }
+
+ if (this.hasNodeAttribute(node, attributeName)) {
+ return node;
+ }
+
+ var nodes = this.findNodesHavingAttribute(node, attributeName);
+
+ if (nodes && nodes.length) {
+ return nodes[0];
+ }
+ },
+ findFirstNodeHavingAttributeWithValue: function (node, attributeName)
+ {
+ if (!node || !attributeName) {
+ return;
+ }
+
+ if (this.hasNodeAttributeWithValue(node, attributeName)) {
+ return node;
+ }
+
+ var nodes = this.findNodesHavingAttribute(node, attributeName);
+
+ if (!nodes || !nodes.length) {
+ return;
+ }
+
+ var index;
+ for (index = 0; index < nodes.length; index++) {
+ if (this.getAttributeValueFromNode(nodes[index], attributeName)) {
+ return nodes[index];
+ }
+ }
+ },
+ findNodesHavingCssClass: function (nodeToSearch, className, nodes)
+ {
+ if (!nodes) {
+ nodes = [];
+ }
+
+ if (!nodeToSearch || !className) {
+ return nodes;
+ }
+
+ if (nodeToSearch.getElementsByClassName) {
+ var foundNodes = nodeToSearch.getElementsByClassName(className);
+ return this.htmlCollectionToArray(foundNodes);
+ }
+
+ var children = getChildrenFromNode(nodeToSearch);
+
+ if (!children || !children.length) {
+ return [];
+ }
+
+ var index, child;
+ for (index = 0; index < children.length; index++) {
+ child = children[index];
+ if (this.hasNodeCssClass(child, className)) {
+ nodes.push(child);
+ }
+
+ nodes = this.findNodesHavingCssClass(child, className, nodes);
+ }
+
+ return nodes;
+ },
+ findFirstNodeHavingClass: function (node, className)
+ {
+ if (!node || !className) {
+ return;
+ }
+
+ if (this.hasNodeCssClass(node, className)) {
+ return node;
+ }
+
+ var nodes = this.findNodesHavingCssClass(node, className);
+
+ if (nodes && nodes.length) {
+ return nodes[0];
+ }
+ },
+ isLinkElement: function (node)
+ {
+ if (!node) {
+ return false;
+ }
+
+ var elementName = String(node.nodeName).toLowerCase();
+ var linkElementNames = ['a', 'area'];
+ var pos = indexOfArray(linkElementNames, elementName);
+
+ return pos !== -1;
+ },
+ setAnyAttribute: function (node, attrName, attrValue)
+ {
+ if (!node || !attrName) {
+ return;
+ }
+
+ if (node.setAttribute) {
+ node.setAttribute(attrName, attrValue);
+ } else {
+ node[attrName] = attrValue;
+ }
+ }
+ };
+
+ /************************************************************
+ * Content Tracking
+ ************************************************************/
+
+ var content = {
+ CONTENT_ATTR: 'data-track-content',
+ CONTENT_CLASS: 'piwikTrackContent',
+ CONTENT_NAME_ATTR: 'data-content-name',
+ CONTENT_PIECE_ATTR: 'data-content-piece',
+ CONTENT_PIECE_CLASS: 'piwikContentPiece',
+ CONTENT_TARGET_ATTR: 'data-content-target',
+ CONTENT_TARGET_CLASS: 'piwikContentTarget',
+ CONTENT_IGNOREINTERACTION_ATTR: 'data-content-ignoreinteraction',
+ CONTENT_IGNOREINTERACTION_CLASS: 'piwikContentIgnoreInteraction',
+ location: undefined,
+
+ findContentNodes: function ()
+ {
+
+ var cssSelector = '.' + this.CONTENT_CLASS;
+ var attrSelector = '[' + this.CONTENT_ATTR + ']';
+ var contentNodes = query.findMultiple([cssSelector, attrSelector]);
+
+ return contentNodes;
+ },
+ findContentNodesWithinNode: function (node)
+ {
+ if (!node) {
+ return [];
+ }
+
+ // NOTE: we do not use query.findMultiple here as querySelectorAll would most likely not deliver the result we want
+
+ var nodes1 = query.findNodesHavingCssClass(node, this.CONTENT_CLASS);
+ var nodes2 = query.findNodesHavingAttribute(node, this.CONTENT_ATTR);
+
+ if (nodes2 && nodes2.length) {
+ var index;
+ for (index = 0; index < nodes2.length; index++) {
+ nodes1.push(nodes2[index]);
+ }
+ }
+
+ if (query.hasNodeAttribute(node, this.CONTENT_ATTR)) {
+ nodes1.push(node);
+ } else if (query.hasNodeCssClass(node, this.CONTENT_CLASS)) {
+ nodes1.push(node);
+ }
+
+ nodes1 = query.makeNodesUnique(nodes1);
+
+ return nodes1;
+ },
+ findParentContentNode: function (anyNode)
+ {
+ if (!anyNode) {
+ return;
+ }
+
+ var node = anyNode;
+ var counter = 0;
+
+ while (node && node !== documentAlias && node.parentNode) {
+ if (query.hasNodeAttribute(node, this.CONTENT_ATTR)) {
+ return node;
+ }
+ if (query.hasNodeCssClass(node, this.CONTENT_CLASS)) {
+ return node;
+ }
+
+ node = node.parentNode;
+
+ if (counter > 1000) {
+ break; // prevent loop, should not happen anyway but better we do this
+ }
+ counter++;
+ }
+ },
+ findPieceNode: function (node)
+ {
+ var contentPiece;
+
+ contentPiece = query.findFirstNodeHavingAttribute(node, this.CONTENT_PIECE_ATTR);
+
+ if (!contentPiece) {
+ contentPiece = query.findFirstNodeHavingClass(node, this.CONTENT_PIECE_CLASS);
+ }
+
+ if (contentPiece) {
+ return contentPiece;
+ }
+
+ return node;
+ },
+ findTargetNodeNoDefault: function (node)
+ {
+ if (!node) {
+ return;
+ }
+
+ var target = query.findFirstNodeHavingAttributeWithValue(node, this.CONTENT_TARGET_ATTR);
+ if (target) {
+ return target;
+ }
+
+ target = query.findFirstNodeHavingAttribute(node, this.CONTENT_TARGET_ATTR);
+ if (target) {
+ return target;
+ }
+
+ target = query.findFirstNodeHavingClass(node, this.CONTENT_TARGET_CLASS);
+ if (target) {
+ return target;
+ }
+ },
+ findTargetNode: function (node)
+ {
+ var target = this.findTargetNodeNoDefault(node);
+ if (target) {
+ return target;
+ }
+
+ return node;
+ },
+ findContentName: function (node)
+ {
+ if (!node) {
+ return;
+ }
+
+ var nameNode = query.findFirstNodeHavingAttributeWithValue(node, this.CONTENT_NAME_ATTR);
+
+ if (nameNode) {
+ return query.getAttributeValueFromNode(nameNode, this.CONTENT_NAME_ATTR);
+ }
+
+ var contentPiece = this.findContentPiece(node);
+ if (contentPiece) {
+ return this.removeDomainIfIsInLink(contentPiece);
+ }
+
+ if (query.hasNodeAttributeWithValue(node, 'title')) {
+ return query.getAttributeValueFromNode(node, 'title');
+ }
+
+ var clickUrlNode = this.findPieceNode(node);
+
+ if (query.hasNodeAttributeWithValue(clickUrlNode, 'title')) {
+ return query.getAttributeValueFromNode(clickUrlNode, 'title');
+ }
+
+ var targetNode = this.findTargetNode(node);
+
+ if (query.hasNodeAttributeWithValue(targetNode, 'title')) {
+ return query.getAttributeValueFromNode(targetNode, 'title');
+ }
+ },
+ findContentPiece: function (node)
+ {
+ if (!node) {
+ return;
+ }
+
+ var nameNode = query.findFirstNodeHavingAttributeWithValue(node, this.CONTENT_PIECE_ATTR);
+
+ if (nameNode) {
+ return query.getAttributeValueFromNode(nameNode, this.CONTENT_PIECE_ATTR);
+ }
+
+ var contentNode = this.findPieceNode(node);
+
+ var media = this.findMediaUrlInNode(contentNode);
+ if (media) {
+ return this.toAbsoluteUrl(media);
+ }
+ },
+ findContentTarget: function (node)
+ {
+ if (!node) {
+ return;
+ }
+
+ var targetNode = this.findTargetNode(node);
+
+ if (query.hasNodeAttributeWithValue(targetNode, this.CONTENT_TARGET_ATTR)) {
+ return query.getAttributeValueFromNode(targetNode, this.CONTENT_TARGET_ATTR);
+ }
+
+ var href;
+ if (query.hasNodeAttributeWithValue(targetNode, 'href')) {
+ href = query.getAttributeValueFromNode(targetNode, 'href');
+ return this.toAbsoluteUrl(href);
+ }
+
+ var contentNode = this.findPieceNode(node);
+
+ if (query.hasNodeAttributeWithValue(contentNode, 'href')) {
+ href = query.getAttributeValueFromNode(contentNode, 'href');
+ return this.toAbsoluteUrl(href);
+ }
+ },
+ isSameDomain: function (url)
+ {
+ if (!url || !url.indexOf) {
+ return false;
+ }
+
+ if (0 === url.indexOf(this.getLocation().origin)) {
+ return true;
+ }
+
+ var posHost = url.indexOf(this.getLocation().host);
+ if (8 >= posHost && 0 <= posHost) {
+ return true;
+ }
+
+ return false;
+ },
+ removeDomainIfIsInLink: function (text)
+ {
+ // we will only remove if domain === location.origin meaning is not an outlink
+ var regexContainsProtocol = '^https?:\/\/[^\/]+';
+ var regexReplaceDomain = '^.*\/\/[^\/]+';
+
+ if (text &&
+ text.search &&
+ -1 !== text.search(new RegExp(regexContainsProtocol))
+ && this.isSameDomain(text)) {
+
+ text = text.replace(new RegExp(regexReplaceDomain), '');
+ if (!text) {
+ text = '/';
+ }
+ }
+
+ return text;
+ },
+ findMediaUrlInNode: function (node)
+ {
+ if (!node) {
+ return;
+ }
+
+ var mediaElements = ['img', 'embed', 'video', 'audio'];
+ var elementName = node.nodeName.toLowerCase();
+
+ if (-1 !== indexOfArray(mediaElements, elementName) &&
+ query.findFirstNodeHavingAttributeWithValue(node, 'src')) {
+
+ var sourceNode = query.findFirstNodeHavingAttributeWithValue(node, 'src');
+
+ return query.getAttributeValueFromNode(sourceNode, 'src');
+ }
+
+ if (elementName === 'object' &&
+ query.hasNodeAttributeWithValue(node, 'data')) {
+
+ return query.getAttributeValueFromNode(node, 'data');
+ }
+
+ if (elementName === 'object') {
+ var params = query.findNodesByTagName(node, 'param');
+ if (params && params.length) {
+ var index;
+ for (index = 0; index < params.length; index++) {
+ if ('movie' === query.getAttributeValueFromNode(params[index], 'name') &&
+ query.hasNodeAttributeWithValue(params[index], 'value')) {
+
+ return query.getAttributeValueFromNode(params[index], 'value');
+ }
+ }
+ }
+
+ var embed = query.findNodesByTagName(node, 'embed');
+ if (embed && embed.length) {
+ return this.findMediaUrlInNode(embed[0]);
+ }
+ }
+ },
+ trim: function (text)
+ {
+ return trim(text);
+ },
+ isOrWasNodeInViewport: function (node)
+ {
+ if (!node || !node.getBoundingClientRect || node.nodeType !== 1) {
+ return true;
+ }
+
+ var rect = node.getBoundingClientRect();
+ var html = documentAlias.documentElement || {};
+
+ var wasVisible = rect.top < 0;
+ if (wasVisible && node.offsetTop) {
+ wasVisible = (node.offsetTop + rect.height) > 0;
+ }
+
+ var docWidth = html.clientWidth; // The clientWidth attribute returns the viewport width excluding the size of a rendered scroll bar
+
+ if (windowAlias.innerWidth && docWidth > windowAlias.innerWidth) {
+ docWidth = windowAlias.innerWidth; // The innerWidth attribute must return the viewport width including the size of a rendered scroll bar
+ }
+
+ var docHeight = html.clientHeight; // The clientWidth attribute returns the viewport width excluding the size of a rendered scroll bar
+
+ if (windowAlias.innerHeight && docHeight > windowAlias.innerHeight) {
+ docHeight = windowAlias.innerHeight; // The innerWidth attribute must return the viewport width including the size of a rendered scroll bar
+ }
+
+ return (
+ (rect.bottom > 0 || wasVisible) &&
+ rect.right > 0 &&
+ rect.left < docWidth &&
+ ((rect.top < docHeight) || wasVisible) // rect.top < 0 we assume user has seen all the ones that are above the current viewport
+ );
+ },
+ isNodeVisible: function (node)
+ {
+ var isItVisible = isVisible(node);
+ var isInViewport = this.isOrWasNodeInViewport(node);
+ return isItVisible && isInViewport;
+ },
+ buildInteractionRequestParams: function (interaction, name, piece, target)
+ {
+ var params = '';
+
+ if (interaction) {
+ params += 'c_i='+ encodeWrapper(interaction);
+ }
+ if (name) {
+ if (params) {
+ params += '&';
+ }
+ params += 'c_n='+ encodeWrapper(name);
+ }
+ if (piece) {
+ if (params) {
+ params += '&';
+ }
+ params += 'c_p='+ encodeWrapper(piece);
+ }
+ if (target) {
+ if (params) {
+ params += '&';
+ }
+ params += 'c_t='+ encodeWrapper(target);
+ }
+
+ return params;
+ },
+ buildImpressionRequestParams: function (name, piece, target)
+ {
+ var params = 'c_n=' + encodeWrapper(name) +
+ '&c_p=' + encodeWrapper(piece);
+
+ if (target) {
+ params += '&c_t=' + encodeWrapper(target);
+ }
+
+ return params;
+ },
+ buildContentBlock: function (node)
+ {
+ if (!node) {
+ return;
+ }
+
+ var name = this.findContentName(node);
+ var piece = this.findContentPiece(node);
+ var target = this.findContentTarget(node);
+
+ name = this.trim(name);
+ piece = this.trim(piece);
+ target = this.trim(target);
+
+ return {
+ name: name || 'Unknown',
+ piece: piece || 'Unknown',
+ target: target || ''
+ };
+ },
+ collectContent: function (contentNodes)
+ {
+ if (!contentNodes || !contentNodes.length) {
+ return [];
+ }
+
+ var contents = [];
+
+ var index, contentBlock;
+ for (index = 0; index < contentNodes.length; index++) {
+ contentBlock = this.buildContentBlock(contentNodes[index]);
+ if (isDefined(contentBlock)) {
+ contents.push(contentBlock);
+ }
+ }
+
+ return contents;
+ },
+ setLocation: function (location)
+ {
+ this.location = location;
+ },
+ getLocation: function ()
+ {
+ var locationAlias = this.location || windowAlias.location;
+
+ if (!locationAlias.origin) {
+ locationAlias.origin = locationAlias.protocol + "//" + locationAlias.hostname + (locationAlias.port ? ':' + locationAlias.port: '');
+ }
+
+ return locationAlias;
+ },
+ toAbsoluteUrl: function (url)
+ {
+ if ((!url || String(url) !== url) && url !== '') {
+ // we only handle strings
+ return url;
+ }
+
+ if ('' === url) {
+ return this.getLocation().href;
+ }
+
+ // Eg //example.com/test.jpg
+ if (url.search(/^\/\//) !== -1) {
+ return this.getLocation().protocol + url;
+ }
+
+ // Eg http://example.com/test.jpg
+ if (url.search(/:\/\//) !== -1) {
+ return url;
+ }
+
+ // Eg #test.jpg
+ if (0 === url.indexOf('#')) {
+ return this.getLocation().origin + this.getLocation().pathname + url;
+ }
+
+ // Eg ?x=5
+ if (0 === url.indexOf('?')) {
+ return this.getLocation().origin + this.getLocation().pathname + url;
+ }
+
+ // Eg mailto:x@y.z tel:012345, ... market:... sms:..., javasript:... ecmascript: ... and many more
+ if (0 === url.search('^[a-zA-Z]{2,11}:')) {
+ return url;
+ }
+
+ // Eg /test.jpg
+ if (url.search(/^\//) !== -1) {
+ return this.getLocation().origin + url;
+ }
+
+ // Eg test.jpg
+ var regexMatchDir = '(.*\/)';
+ var base = this.getLocation().origin + this.getLocation().pathname.match(new RegExp(regexMatchDir))[0];
+ return base + url;
+ },
+ isUrlToCurrentDomain: function (url) {
+
+ var absoluteUrl = this.toAbsoluteUrl(url);
+
+ if (!absoluteUrl) {
+ return false;
+ }
+
+ var origin = this.getLocation().origin;
+ if (origin === absoluteUrl) {
+ return true;
+ }
+
+ if (0 === String(absoluteUrl).indexOf(origin)) {
+ if (':' === String(absoluteUrl).substr(origin.length, 1)) {
+ return false; // url has port whereas origin has not => different URL
+ }
+
+ return true;
+ }
+
+ return false;
+ },
+ setHrefAttribute: function (node, url)
+ {
+ if (!node || !url) {
+ return;
+ }
+
+ query.setAnyAttribute(node, 'href', url);
+ },
+ shouldIgnoreInteraction: function (targetNode)
+ {
+ var hasAttr = query.hasNodeAttribute(targetNode, this.CONTENT_IGNOREINTERACTION_ATTR);
+ var hasClass = query.hasNodeCssClass(targetNode, this.CONTENT_IGNOREINTERACTION_CLASS);
+ return hasAttr || hasClass;
+ }
+ };
+
+ /************************************************************
+ * Page Overlay
+ ************************************************************/
+
+ function getPiwikUrlForOverlay(trackerUrl, apiUrl) {
+ if (apiUrl) {
+ return apiUrl;
+ }
+
+ trackerUrl = content.toAbsoluteUrl(trackerUrl);
+
+ // if eg http://www.example.com/js/tracker.php?version=232323 => http://www.example.com/js/tracker.php
+ if (stringContains(trackerUrl, '?')) {
+ var posQuery = trackerUrl.indexOf('?');
+ trackerUrl = trackerUrl.slice(0, posQuery);
+ }
+
+ if (stringEndsWith(trackerUrl, 'matomo.php')) {
+ // if eg without domain or path "matomo.php" => ''
+ trackerUrl = removeCharactersFromEndOfString(trackerUrl, 'matomo.php'.length);
+ } else if (stringEndsWith(trackerUrl, 'piwik.php')) {
+ // if eg without domain or path "piwik.php" => ''
+ trackerUrl = removeCharactersFromEndOfString(trackerUrl, 'piwik.php'.length);
+ } else if (stringEndsWith(trackerUrl, '.php')) {
+ // if eg http://www.example.com/js/piwik.php => http://www.example.com/js/
+ // or if eg http://www.example.com/tracker.php => http://www.example.com/
+ var lastSlash = trackerUrl.lastIndexOf('/');
+ var includeLastSlash = 1;
+ trackerUrl = trackerUrl.slice(0, lastSlash + includeLastSlash);
+ }
+
+ // if eg http://www.example.com/js/ => http://www.example.com/ (when not minified Piwik JS loaded)
+ if (stringEndsWith(trackerUrl, '/js/')) {
+ trackerUrl = removeCharactersFromEndOfString(trackerUrl, 'js/'.length);
+ }
+
+ // http://www.example.com/
+ return trackerUrl;
+ }
+
+ /*
+ * Check whether this is a page overlay session
+ *
+ * @return boolean
+ *
+ * {@internal side-effect: modifies window.name }}
+ */
+ function isOverlaySession(configTrackerSiteId) {
+ var windowName = 'Piwik_Overlay';
+
+ // check whether we were redirected from the piwik overlay plugin
+ var referrerRegExp = new RegExp('index\\.php\\?module=Overlay&action=startOverlaySession'
+ + '&idSite=([0-9]+)&period=([^&]+)&date=([^&]+)(&segment=.*)?$');
+
+ var match = referrerRegExp.exec(documentAlias.referrer);
+
+ if (match) {
+ // check idsite
+ var idsite = match[1];
+
+ if (idsite !== String(configTrackerSiteId)) {
+ return false;
+ }
+
+ // store overlay session info in window name
+ var period = match[2],
+ date = match[3],
+ segment = match[4];
+
+ if (!segment) {
+ segment = '';
+ } else if (segment.indexOf('&segment=') === 0) {
+ segment = segment.substr('&segment='.length);
+ }
+
+ windowAlias.name = windowName + '###' + period + '###' + date + '###' + segment;
+ }
+
+ // retrieve and check data from window name
+ var windowNameParts = windowAlias.name.split('###');
+
+ return windowNameParts.length === 4 && windowNameParts[0] === windowName;
+ }
+
+ /*
+ * Inject the script needed for page overlay
+ */
+ function injectOverlayScripts(configTrackerUrl, configApiUrl, configTrackerSiteId) {
+ var windowNameParts = windowAlias.name.split('###'),
+ period = windowNameParts[1],
+ date = windowNameParts[2],
+ segment = windowNameParts[3],
+ piwikUrl = getPiwikUrlForOverlay(configTrackerUrl, configApiUrl);
+
+ loadScript(
+ piwikUrl + 'plugins/Overlay/client/client.js?v=1',
+ function () {
+ Piwik_Overlay_Client.initialize(piwikUrl, configTrackerSiteId, period, date, segment);
+ }
+ );
+ }
+
+ function isInsideAnIframe () {
+ var frameElement;
+
+ try {
+ // If the parent window has another origin, then accessing frameElement
+ // throws an Error in IE. see issue #10105.
+ frameElement = windowAlias.frameElement;
+ } catch(e) {
+ // When there was an Error, then we know we are inside an iframe.
+ return true;
+ }
+
+ if (isDefined(frameElement)) {
+ return (frameElement && String(frameElement.nodeName).toLowerCase() === 'iframe') ? true : false;
+ }
+
+ try {
+ return windowAlias.self !== windowAlias.top;
+ } catch (e2) {
+ return true;
+ }
+ }
+
+ /************************************************************
+ * End Page Overlay
+ ************************************************************/
+
+ /*
+ * Piwik Tracker class
+ *
+ * trackerUrl and trackerSiteId are optional arguments to the constructor
+ *
+ * See: Tracker.setTrackerUrl() and Tracker.setSiteId()
+ */
+ function Tracker(trackerUrl, siteId) {
+
+ /************************************************************
+ * Private members
+ ************************************************************/
+
+ var
+ /*<DEBUG>*/
+ /*
+ * registered test hooks
+ */
+ registeredHooks = {},
+ /*</DEBUG>*/
+
+ trackerInstance = this,
+
+ // constants
+ CONSENT_COOKIE_NAME = 'mtm_consent',
+ CONSENT_REMOVED_COOKIE_NAME = 'mtm_consent_removed',
+
+ // Current URL and Referrer URL
+ locationArray = urlFixup(documentAlias.domain, windowAlias.location.href, getReferrer()),
+ domainAlias = domainFixup(locationArray[0]),
+ locationHrefAlias = safeDecodeWrapper(locationArray[1]),
+ configReferrerUrl = safeDecodeWrapper(locationArray[2]),
+
+ enableJSErrorTracking = false,
+
+ defaultRequestMethod = 'GET',
+
+ // Request method (GET or POST)
+ configRequestMethod = defaultRequestMethod,
+
+ defaultRequestContentType = 'application/x-www-form-urlencoded; charset=UTF-8',
+
+ // Request Content-Type header value; applicable when POST request method is used for submitting tracking events
+ configRequestContentType = defaultRequestContentType,
+
+ // Tracker URL
+ configTrackerUrl = trackerUrl || '',
+
+ // API URL (only set if it differs from the Tracker URL)
+ configApiUrl = '',
+
+ // This string is appended to the Tracker URL Request (eg. to send data that is not handled by the existing setters/getters)
+ configAppendToTrackingUrl = '',
+
+ // Site ID
+ configTrackerSiteId = siteId || '',
+
+ // User ID
+ configUserId = '',
+
+ // Visitor UUID
+ visitorUUID = '',
+
+ // Document URL
+ configCustomUrl,
+
+ // Document title
+ configTitle = '',
+
+ // Extensions to be treated as download links
+ configDownloadExtensions = ['7z','aac','apk','arc','arj','asf','asx','avi','azw3','bin','csv','deb','dmg','doc','docx','epub','exe','flv','gif','gz','gzip','hqx','ibooks','jar','jpg','jpeg','js','mobi','mp2','mp3','mp4','mpg','mpeg','mov','movie','msi','msp','odb','odf','odg','ods','odt','ogg','ogv','pdf','phps','png','ppt','pptx','qt','qtm','ra','ram','rar','rpm','sea','sit','tar','tbz','tbz2','bz','bz2','tgz','torrent','txt','wav','wma','wmv','wpd','xls','xlsx','xml','z','zip'],
+
+ // Hosts or alias(es) to not treat as outlinks
+ configHostsAlias = [domainAlias],
+
+ // HTML anchor element classes to not track
+ configIgnoreClasses = [],
+
+ // HTML anchor element classes to treat as downloads
+ configDownloadClasses = [],
+
+ // HTML anchor element classes to treat at outlinks
+ configLinkClasses = [],
+
+ // Maximum delay to wait for web bug image to be fetched (in milliseconds)
+ configTrackerPause = 500,
+
+ // Minimum visit time after initial page view (in milliseconds)
+ configMinimumVisitTime,
+
+ // Recurring heart beat after initial ping (in milliseconds)
+ configHeartBeatDelay,
+
+ // alias to circumvent circular function dependency (JSLint requires this)
+ heartBeatPingIfActivityAlias,
+
+ // Disallow hash tags in URL
+ configDiscardHashTag,
+
+ // Custom data
+ configCustomData,
+
+ // Campaign names
+ configCampaignNameParameters = [ 'pk_campaign', 'piwik_campaign', 'utm_campaign', 'utm_source', 'utm_medium' ],
+
+ // Campaign keywords
+ configCampaignKeywordParameters = [ 'pk_kwd', 'piwik_kwd', 'utm_term' ],
+
+ // First-party cookie name prefix
+ configCookieNamePrefix = '_pk_',
+
+ // the URL parameter that will store the visitorId if cross domain linking is enabled
+ // pk_vid = visitor ID
+ // first part of this URL parameter will be 16 char visitor Id.
+ // The second part is the 10 char current timestamp and the third and last part will be a 6 characters deviceId
+ // timestamp is needed to prevent reusing the visitorId when the URL is shared. The visitorId will be
+ // only reused if the timestamp is less than 45 seconds old.
+ // deviceId parameter is needed to prevent reusing the visitorId when the URL is shared. The visitorId
+ // will be only reused if the device is still the same when opening the link.
+ // VDI = visitor device identifier
+ configVisitorIdUrlParameter = 'pk_vid',
+
+ // Cross domain linking, the visitor ID is transmitted only in the 180 seconds following the click.
+ configVisitorIdUrlParameterTimeoutInSeconds = 180,
+
+ // First-party cookie domain
+ // User agent defaults to origin hostname
+ configCookieDomain,
+
+ // First-party cookie path
+ // Default is user agent defined.
+ configCookiePath,
+
+ // Whether to use "Secure" cookies that only work over SSL
+ configCookieIsSecure = false,
+
+ // First-party cookies are disabled
+ configCookiesDisabled = false,
+
+ // Do Not Track
+ configDoNotTrack,
+
+ // Count sites which are pre-rendered
+ configCountPreRendered,
+
+ // Do we attribute the conversion to the first referrer or the most recent referrer?
+ configConversionAttributionFirstReferrer,
+
+ // Life of the visitor cookie (in milliseconds)
+ configVisitorCookieTimeout = 33955200000, // 13 months (365 days + 28days)
+
+ // Life of the session cookie (in milliseconds)
+ configSessionCookieTimeout = 1800000, // 30 minutes
+
+ // Life of the referral cookie (in milliseconds)
+ configReferralCookieTimeout = 15768000000, // 6 months
+
+ // Is performance tracking enabled
+ configPerformanceTrackingEnabled = true,
+
+ // Generation time set from the server
+ configPerformanceGenerationTime = 0,
+
+ // Whether Custom Variables scope "visit" should be stored in a cookie during the time of the visit
+ configStoreCustomVariablesInCookie = false,
+
+ // Custom Variables read from cookie, scope "visit"
+ customVariables = false,
+
+ configCustomRequestContentProcessing,
+
+ // Custom Variables, scope "page"
+ customVariablesPage = {},
+
+ // Custom Variables, scope "event"
+ customVariablesEvent = {},
+
+ // Custom Dimensions (can be any scope)
+ customDimensions = {},
+
+ // Custom Variables names and values are each truncated before being sent in the request or recorded in the cookie
+ customVariableMaximumLength = 200,
+
+ // Ecommerce items
+ ecommerceItems = {},
+
+ // Browser features via client-side data collection
+ browserFeatures = {},
+
+ // Keeps track of previously tracked content impressions
+ trackedContentImpressions = [],
+ isTrackOnlyVisibleContentEnabled = false,
+
+ // Guard to prevent empty visits see #6415. If there is a new visitor and the first 2 (or 3 or 4)
+ // tracking requests are at nearly same time (eg trackPageView and trackContentImpression) 2 or more
+ // visits will be created
+ timeNextTrackingRequestCanBeExecutedImmediately = false,
+
+ // Guard against installing the link tracker more than once per Tracker instance
+ linkTrackingInstalled = false,
+ linkTrackingEnabled = false,
+ crossDomainTrackingEnabled = false,
+
+ // Guard against installing the activity tracker more than once per Tracker instance
+ heartBeatSetUp = false,
+
+ // bool used to detect whether this browser window had focus at least once. So far we cannot really
+ // detect this 100% correct for an iframe so whenever Piwik is loaded inside an iframe we presume
+ // the window had focus at least once.
+ hadWindowFocusAtLeastOnce = isInsideAnIframe(),
+
+ // Timestamp of last tracker request sent to Piwik
+ lastTrackerRequestTime = null,
+
+ // Handle to the current heart beat timeout
+ heartBeatTimeout,
+
+ // Internal state of the pseudo click handler
+ lastButton,
+ lastTarget,
+
+ // Hash function
+ hash = sha1,
+
+ // Domain hash value
+ domainHash,
+
+ configIdPageView,
+
+ // we measure how many pageviews have been tracked so plugins can use it to eg detect if a
+ // pageview was already tracked or not
+ numTrackedPageviews = 0,
+
+ configCookiesToDelete = ['id', 'ses', 'cvar', 'ref'],
+
+ // whether requireConsent() was called or not
+ configConsentRequired = false,
+
+ // we always have the concept of consent. by default consent is assumed unless the end user removes it,
+ // or unless a matomo user explicitly requires consent (via requireConsent())
+ configHasConsent = null, // initialized below
+
+ // holds all pending tracking requests that have not been tracked because we need consent
+ consentRequestsQueue = [];
+
+ // Document title
+ try {
+ configTitle = documentAlias.title;
+ } catch(e) {
+ configTitle = '';
+ }
+
+ /*
+ * Set cookie value
+ */
+ function setCookie(cookieName, value, msToExpire, path, domain, isSecure) {
+ if (configCookiesDisabled) {
+ return;
+ }
+
+ var expiryDate;
+
+ // relative time to expire in milliseconds
+ if (msToExpire) {
+ expiryDate = new Date();
+ expiryDate.setTime(expiryDate.getTime() + msToExpire);
+ }
+
+ documentAlias.cookie = cookieName + '=' + encodeWrapper(value) +
+ (msToExpire ? ';expires=' + expiryDate.toGMTString() : '') +
+ ';path=' + (path || '/') +
+ (domain ? ';domain=' + domain : '') +
+ (isSecure ? ';secure' : '');
+ }
+
+ /*
+ * Get cookie value
+ */
+ function getCookie(cookieName) {
+ if (configCookiesDisabled) {
+ return 0;
+ }
+
+ var cookiePattern = new RegExp('(^|;)[ ]*' + cookieName + '=([^;]*)'),
+ cookieMatch = cookiePattern.exec(documentAlias.cookie);
+
+ return cookieMatch ? decodeWrapper(cookieMatch[2]) : 0;
+ }
+
+ configHasConsent = !getCookie(CONSENT_REMOVED_COOKIE_NAME);
+
+ /*
+ * Removes hash tag from the URL
+ *
+ * URLs are purified before being recorded in the cookie,
+ * or before being sent as GET parameters
+ */
+ function purify(url) {
+ var targetPattern;
+
+ // we need to remove this parameter here, they wouldn't be removed in Piwik tracker otherwise eg
+ // for outlinks or referrers
+ url = removeUrlParameter(url, configVisitorIdUrlParameter);
+
+ if (configDiscardHashTag) {
+ targetPattern = new RegExp('#.*');
+
+ return url.replace(targetPattern, '');
+ }
+
+ return url;
+ }
+
+ /*
+ * Resolve relative reference
+ *
+ * Note: not as described in rfc3986 section 5.2
+ */
+ function resolveRelativeReference(baseUrl, url) {
+ var protocol = getProtocolScheme(url),
+ i;
+
+ if (protocol) {
+ return url;
+ }
+
+ if (url.slice(0, 1) === '/') {
+ return getProtocolScheme(baseUrl) + '://' + getHostName(baseUrl) + url;
+ }
+
+ baseUrl = purify(baseUrl);
+
+ i = baseUrl.indexOf('?');
+ if (i >= 0) {
+ baseUrl = baseUrl.slice(0, i);
+ }
+
+ i = baseUrl.lastIndexOf('/');
+ if (i !== baseUrl.length - 1) {
+ baseUrl = baseUrl.slice(0, i + 1);
+ }
+
+ return baseUrl + url;
+ }
+
+ function isSameHost (hostName, alias) {
+ var offset;
+
+ hostName = String(hostName).toLowerCase();
+ alias = String(alias).toLowerCase();
+
+ if (hostName === alias) {
+ return true;
+ }
+
+ if (alias.slice(0, 1) === '.') {
+ if (hostName === alias.slice(1)) {
+ return true;
+ }
+
+ offset = hostName.length - alias.length;
+
+ if ((offset > 0) && (hostName.slice(offset) === alias)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /*
+ * Extract pathname from URL. element.pathname is actually supported by pretty much all browsers including
+ * IE6 apart from some rare very old ones
+ */
+ function getPathName(url) {
+ var parser = document.createElement('a');
+ if (url.indexOf('//') !== 0 && url.indexOf('http') !== 0) {
+ if (url.indexOf('*') === 0) {
+ url = url.substr(1);
+ }
+ if (url.indexOf('.') === 0) {
+ url = url.substr(1);
+ }
+ url = 'http://' + url;
+ }
+
+ parser.href = content.toAbsoluteUrl(url);
+
+ if (parser.pathname) {
+ return parser.pathname;
+ }
+
+ return '';
+ }
+
+ function isSitePath (path, pathAlias)
+ {
+ if(!stringStartsWith(pathAlias, '/')) {
+ pathAlias = '/' + pathAlias;
+ }
+
+ if(!stringStartsWith(path, '/')) {
+ path = '/' + path;
+ }
+
+ var matchesAnyPath = (pathAlias === '/' || pathAlias === '/*');
+
+ if (matchesAnyPath) {
+ return true;
+ }
+
+ if (path === pathAlias) {
+ return true;
+ }
+
+ pathAlias = String(pathAlias).toLowerCase();
+ path = String(path).toLowerCase();
+
+ // wildcard path support
+ if(stringEndsWith(pathAlias, '*')) {
+ // remove the final '*' before comparing
+ pathAlias = pathAlias.slice(0, -1);
+
+ // Note: this is almost duplicated from just few lines above
+ matchesAnyPath = (!pathAlias || pathAlias === '/');
+
+ if (matchesAnyPath) {
+ return true;
+ }
+
+ if (path === pathAlias) {
+ return true;
+ }
+
+ // wildcard match
+ return path.indexOf(pathAlias) === 0;
+ }
+
+ // we need to append slashes so /foobarbaz won't match a site /foobar
+ if (!stringEndsWith(path, '/')) {
+ path += '/';
+ }
+
+ if (!stringEndsWith(pathAlias, '/')) {
+ pathAlias += '/';
+ }
+
+ return path.indexOf(pathAlias) === 0;
+ }
+
+ /**
+ * Whether the specified domain name and path belong to any of the alias domains (eg. set via setDomains).
+ *
+ * Note: this function is used to determine whether a click on a URL will be considered an "Outlink".
+ *
+ * @param host
+ * @param path
+ * @returns {boolean}
+ */
+ function isSiteHostPath(host, path)
+ {
+ var i,
+ alias,
+ configAlias,
+ aliasHost,
+ aliasPath;
+
+ for (i = 0; i < configHostsAlias.length; i++) {
+ aliasHost = domainFixup(configHostsAlias[i]);
+ aliasPath = getPathName(configHostsAlias[i]);
+
+ if (isSameHost(host, aliasHost) && isSitePath(path, aliasPath)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /*
+ * Is the host local? (i.e., not an outlink)
+ */
+ function isSiteHostName(hostName) {
+
+ var i,
+ alias,
+ offset;
+
+ for (i = 0; i < configHostsAlias.length; i++) {
+ alias = domainFixup(configHostsAlias[i].toLowerCase());
+
+ if (hostName === alias) {
+ return true;
+ }
+
+ if (alias.slice(0, 1) === '.') {
+ if (hostName === alias.slice(1)) {
+ return true;
+ }
+
+ offset = hostName.length - alias.length;
+
+ if ((offset > 0) && (hostName.slice(offset) === alias)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /*
+ * Send image request to Piwik server using GET.
+ * The infamous web bug (or beacon) is a transparent, single pixel (1x1) image
+ */
+ function getImage(request, callback) {
+ // make sure to actually load an image so callback gets invoked
+ request = request.replace("send_image=0","send_image=1");
+
+ var image = new Image(1, 1);
+ image.onload = function () {
+ iterator = 0; // To avoid JSLint warning of empty block
+ if (typeof callback === 'function') { callback(); }
+ };
+ image.src = configTrackerUrl + (configTrackerUrl.indexOf('?') < 0 ? '?' : '&') + request;
+ }
+
+ function sendPostRequestViaSendBeacon(request)
+ {
+ var supportsSendBeacon = 'object' === typeof navigatorAlias
+ && 'function' === typeof navigatorAlias.sendBeacon
+ && 'function' === typeof Blob;
+
+ if (!supportsSendBeacon) {
+ return false;
+ }
+
+ var headers = {type: 'application/x-www-form-urlencoded; charset=UTF-8'};
+ var success = false;
+
+ try {
+ var blob = new Blob([request], headers);
+ success = navigatorAlias.sendBeacon(configTrackerUrl, blob);
+ // returns true if the user agent is able to successfully queue the data for transfer,
+ // Otherwise it returns false and we need to try the regular way
+
+ } catch (e) {
+ return false;
+ }
+
+ return success;
+ }
+
+ /*
+ * POST request to Piwik server using XMLHttpRequest.
+ */
+ function sendXmlHttpRequest(request, callback, fallbackToGet) {
+ if (!isDefined(fallbackToGet) || null === fallbackToGet) {
+ fallbackToGet = true;
+ }
+
+ if (isPageUnloading && sendPostRequestViaSendBeacon(request)) {
+ return;
+ }
+
+ setTimeout(function () {
+ // we execute it with a little delay in case the unload event occurred just after sending this request
+ // this is to avoid the following behaviour: Eg on form submit a tracking request is sent via POST
+ // in this method. Then a few ms later the browser wants to navigate to the new page and the unload
+ // event occurrs and the browser cancels the just triggered POST request. This causes or fallback
+ // method to be triggered and we execute the same request again (either as fallbackGet or sendBeacon).
+ // The problem is that we do not know whether the inital POST request was already fully transferred
+ // to the server or not when the onreadystatechange callback is executed and we might execute the
+ // same request a second time. To avoid this, we delay the actual execution of this POST request just
+ // by 50ms which gives it usually enough time to detect the unload event in most cases.
+
+ if (isPageUnloading && sendPostRequestViaSendBeacon(request)) {
+ return;
+ }
+ var sentViaBeacon;
+
+ try {
+ // we use the progid Microsoft.XMLHTTP because
+ // IE5.5 included MSXML 2.5; the progid MSXML2.XMLHTTP
+ // is pinned to MSXML2.XMLHTTP.3.0
+ var xhr = windowAlias.XMLHttpRequest
+ ? new windowAlias.XMLHttpRequest()
+ : windowAlias.ActiveXObject
+ ? new ActiveXObject('Microsoft.XMLHTTP')
+ : null;
+
+ xhr.open('POST', configTrackerUrl, true);
+
+ // fallback on error
+ xhr.onreadystatechange = function () {
+ if (this.readyState === 4 && !(this.status >= 200 && this.status < 300)) {
+ var sentViaBeacon = isPageUnloading && sendPostRequestViaSendBeacon(request);
+
+ if (!sentViaBeacon && fallbackToGet) {
+ getImage(request, callback);
+ }
+ } else {
+ if (this.readyState === 4 && (typeof callback === 'function')) { callback(); }
+ }
+ };
+
+ xhr.setRequestHeader('Content-Type', configRequestContentType);
+
+ xhr.send(request);
+ } catch (e) {
+ sentViaBeacon = isPageUnloading && sendPostRequestViaSendBeacon(request);
+ if (!sentViaBeacon && fallbackToGet) {
+ getImage(request, callback);
+ }
+ }
+ }, 50);
+
+ }
+
+ function setExpireDateTime(delay) {
+
+ var now = new Date();
+ var time = now.getTime() + delay;
+
+ if (!expireDateTime || time > expireDateTime) {
+ expireDateTime = time;
+ }
+ }
+
+ /*
+ * Sets up the heart beat timeout.
+ */
+ function heartBeatUp(delay) {
+ if (heartBeatTimeout
+ || !configHeartBeatDelay
+ || !configHasConsent
+ ) {
+ return;
+ }
+
+ heartBeatTimeout = setTimeout(function heartBeat() {
+ heartBeatTimeout = null;
+
+ if (!hadWindowFocusAtLeastOnce) {
+ // if browser does not support .hasFocus (eg IE5), we assume that the window has focus.
+ hadWindowFocusAtLeastOnce = (!documentAlias.hasFocus || documentAlias.hasFocus());
+ }
+
+ if (!hadWindowFocusAtLeastOnce) {
+ // only send a ping if the tab actually had focus at least once. For example do not send a ping
+ // if window was opened via "right click => open in new window" and never had focus see #9504
+ heartBeatUp(configHeartBeatDelay);
+ return;
+ }
+
+ if (heartBeatPingIfActivityAlias()) {
+ return;
+ }
+
+ var now = new Date(),
+ heartBeatDelay = configHeartBeatDelay - (now.getTime() - lastTrackerRequestTime);
+ // sanity check
+ heartBeatDelay = Math.min(configHeartBeatDelay, heartBeatDelay);
+ heartBeatUp(heartBeatDelay);
+ }, delay || configHeartBeatDelay);
+ }
+
+ /*
+ * Removes the heart beat timeout.
+ */
+ function heartBeatDown() {
+ if (!heartBeatTimeout) {
+ return;
+ }
+
+ clearTimeout(heartBeatTimeout);
+ heartBeatTimeout = null;
+ }
+
+ function heartBeatOnFocus() {
+ hadWindowFocusAtLeastOnce = true;
+
+ // since it's possible for a user to come back to a tab after several hours or more, we try to send
+ // a ping if the page is active. (after the ping is sent, the heart beat timeout will be set)
+ if (heartBeatPingIfActivityAlias()) {
+ return;
+ }
+
+ heartBeatUp();
+ }
+
+ function heartBeatOnBlur() {
+ heartBeatDown();
+ }
+
+ /*
+ * Setup event handlers and timeout for initial heart beat.
+ */
+ function setUpHeartBeat() {
+ if (heartBeatSetUp
+ || !configHeartBeatDelay
+ ) {
+ return;
+ }
+
+ heartBeatSetUp = true;
+
+ addEventListener(windowAlias, 'focus', heartBeatOnFocus);
+ addEventListener(windowAlias, 'blur', heartBeatOnBlur);
+
+ heartBeatUp();
+ }
+
+ function makeSureThereIsAGapAfterFirstTrackingRequestToPreventMultipleVisitorCreation(callback)
+ {
+ var now = new Date();
+ var timeNow = now.getTime();
+
+ lastTrackerRequestTime = timeNow;
+
+ if (timeNextTrackingRequestCanBeExecutedImmediately && timeNow < timeNextTrackingRequestCanBeExecutedImmediately) {
+ // we are in the time frame shortly after the first request. we have to delay this request a bit to make sure
+ // a visitor has been created meanwhile.
+
+ var timeToWait = timeNextTrackingRequestCanBeExecutedImmediately - timeNow;
+
+ setTimeout(callback, timeToWait);
+ setExpireDateTime(timeToWait + 50); // set timeout is not necessarily executed at timeToWait so delay a bit more
+ timeNextTrackingRequestCanBeExecutedImmediately += 50; // delay next tracking request by further 50ms to next execute them at same time
+
+ return;
+ }
+
+ if (timeNextTrackingRequestCanBeExecutedImmediately === false) {
+ // it is the first request, we want to execute this one directly and delay all the next one(s) within a delay.
+ // All requests after this delay can be executed as usual again
+ var delayInMs = 800;
+ timeNextTrackingRequestCanBeExecutedImmediately = timeNow + delayInMs;
+ }
+
+ callback();
+ }
+
+ /*
+ * Send request
+ */
+ function sendRequest(request, delay, callback) {
+ if (!configHasConsent) {
+ consentRequestsQueue.push(request);
+ return;
+ }
+ if (!configDoNotTrack && request) {
+ if (configConsentRequired && configHasConsent) { // send a consent=1 when explicit consent is given for the apache logs
+ request += '&consent=1';
+ }
+
+ makeSureThereIsAGapAfterFirstTrackingRequestToPreventMultipleVisitorCreation(function () {
+ if (configRequestMethod === 'POST' || String(request).length > 2000) {
+ sendXmlHttpRequest(request, callback);
+ } else {
+ getImage(request, callback);
+ }
+
+ setExpireDateTime(delay);
+ });
+ }
+ if (!heartBeatSetUp) {
+ setUpHeartBeat(); // setup window events too, but only once
+ } else {
+ heartBeatUp();
+ }
+ }
+
+ function canSendBulkRequest(requests)
+ {
+ if (configDoNotTrack) {
+ return false;
+ }
+
+ return (requests && requests.length);
+ }
+
+ /*
+ * Send requests using bulk
+ */
+ function sendBulkRequest(requests, delay)
+ {
+ if (!canSendBulkRequest(requests)) {
+ return;
+ }
+
+ if (!configHasConsent) {
+ consentRequestsQueue.push(requests);
+ return;
+ }
+
+ var bulk = '{"requests":["?' + requests.join('","?') + '"]}';
+
+ makeSureThereIsAGapAfterFirstTrackingRequestToPreventMultipleVisitorCreation(function () {
+ sendXmlHttpRequest(bulk, null, false);
+ setExpireDateTime(delay);
+ });
+ }
+
+ /*
+ * Get cookie name with prefix and domain hash
+ */
+ function getCookieName(baseName) {
+ // NOTE: If the cookie name is changed, we must also update the PiwikTracker.php which
+ // will attempt to discover first party cookies. eg. See the PHP Client method getVisitorId()
+ return configCookieNamePrefix + baseName + '.' + configTrackerSiteId + '.' + domainHash;
+ }
+
+ /*
+ * Does browser have cookies enabled (for this site)?
+ */
+ function hasCookies() {
+ if (configCookiesDisabled) {
+ return '0';
+ }
+
+ if (!isDefined(navigatorAlias.cookieEnabled)) {
+ var testCookieName = getCookieName('testcookie');
+ setCookie(testCookieName, '1');
+
+ return getCookie(testCookieName) === '1' ? '1' : '0';
+ }
+
+ return navigatorAlias.cookieEnabled ? '1' : '0';
+ }
+
+ /*
+ * Update domain hash
+ */
+ function updateDomainHash() {
+ domainHash = hash((configCookieDomain || domainAlias) + (configCookiePath || '/')).slice(0, 4); // 4 hexits = 16 bits
+ }
+
+ /*
+ * Inits the custom variables object
+ */
+ function getCustomVariablesFromCookie() {
+ var cookieName = getCookieName('cvar'),
+ cookie = getCookie(cookieName);
+
+ if (cookie.length) {
+ cookie = JSON_PIWIK.parse(cookie);
+
+ if (isObject(cookie)) {
+ return cookie;
+ }
+ }
+
+ return {};
+ }
+
+ /*
+ * Lazy loads the custom variables from the cookie, only once during this page view
+ */
+ function loadCustomVariables() {
+ if (customVariables === false) {
+ customVariables = getCustomVariablesFromCookie();
+ }
+ }
+
+ /*
+ * Generate a pseudo-unique ID to fingerprint this user
+ * 16 hexits = 64 bits
+ * note: this isn't a RFC4122-compliant UUID
+ */
+ function generateRandomUuid() {
+ return hash(
+ (navigatorAlias.userAgent || '') +
+ (navigatorAlias.platform || '') +
+ JSON_PIWIK.stringify(browserFeatures) +
+ (new Date()).getTime() +
+ Math.random()
+ ).slice(0, 16);
+ }
+
+ function generateBrowserSpecificId() {
+ return hash(
+ (navigatorAlias.userAgent || '') +
+ (navigatorAlias.platform || '') +
+ JSON_PIWIK.stringify(browserFeatures)).slice(0, 6);
+ }
+
+ function getCurrentTimestampInSeconds()
+ {
+ return Math.floor((new Date()).getTime() / 1000);
+ }
+
+ function makeCrossDomainDeviceId()
+ {
+ var timestamp = getCurrentTimestampInSeconds();
+ var browserId = generateBrowserSpecificId();
+ var deviceId = String(timestamp) + browserId;
+
+ return deviceId;
+ }
+
+ function isSameCrossDomainDevice(deviceIdFromUrl)
+ {
+ deviceIdFromUrl = String(deviceIdFromUrl);
+
+ var thisBrowserId = generateBrowserSpecificId();
+ var lengthBrowserId = thisBrowserId.length;
+
+ var browserIdInUrl = deviceIdFromUrl.substr(-1 * lengthBrowserId, lengthBrowserId);
+ var timestampInUrl = parseInt(deviceIdFromUrl.substr(0, deviceIdFromUrl.length - lengthBrowserId), 10);
+
+ if (timestampInUrl && browserIdInUrl && browserIdInUrl === thisBrowserId) {
+ // we only reuse visitorId when used on same device / browser
+
+ var currentTimestampInSeconds = getCurrentTimestampInSeconds();
+
+ if (configVisitorIdUrlParameterTimeoutInSeconds <= 0) {
+ return true;
+ }
+ if (currentTimestampInSeconds >= timestampInUrl
+ && currentTimestampInSeconds <= (timestampInUrl + configVisitorIdUrlParameterTimeoutInSeconds)) {
+ // we only use visitorId if it was generated max 180 seconds ago
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ function getVisitorIdFromUrl(url) {
+ if (!crossDomainTrackingEnabled) {
+ return '';
+ }
+
+ // problem different timezone or when the time on the computer is not set correctly it may re-use
+ // the same visitorId again. therefore we also have a factor like hashed user agent to reduce possible
+ // activation of a visitorId on other device
+ var visitorIdParam = getUrlParameter(url, configVisitorIdUrlParameter);
+
+ if (!visitorIdParam) {
+ return '';
+ }
+
+ visitorIdParam = String(visitorIdParam);
+
+ var pattern = new RegExp("^[a-zA-Z0-9]+$");
+
+ if (visitorIdParam.length === 32 && pattern.test(visitorIdParam)) {
+ var visitorDevice = visitorIdParam.substr(16, 32);
+
+ if (isSameCrossDomainDevice(visitorDevice)) {
+ var visitorId = visitorIdParam.substr(0, 16);
+ return visitorId;
+ }
+ }
+
+ return '';
+ }
+
+ /*
+ * Load visitor ID cookie
+ */
+ function loadVisitorIdCookie() {
+
+ if (!visitorUUID) {
+ // we are using locationHrefAlias and not currentUrl on purpose to for sure get the passed URL parameters
+ // from original URL
+ visitorUUID = getVisitorIdFromUrl(locationHrefAlias);
+ }
+
+ var now = new Date(),
+ nowTs = Math.round(now.getTime() / 1000),
+ visitorIdCookieName = getCookieName('id'),
+ id = getCookie(visitorIdCookieName),
+ cookieValue,
+ uuid;
+
+ // Visitor ID cookie found
+ if (id) {
+ cookieValue = id.split('.');
+
+ // returning visitor flag
+ cookieValue.unshift('0');
+
+ if(visitorUUID.length) {
+ cookieValue[1] = visitorUUID;
+ }
+ return cookieValue;
+ }
+
+ if(visitorUUID.length) {
+ uuid = visitorUUID;
+ } else if ('0' === hasCookies()){
+ uuid = '';
+ } else {
+ uuid = generateRandomUuid();
+ }
+
+ // No visitor ID cookie, let's create a new one
+ cookieValue = [
+ // new visitor
+ '1',
+
+ // uuid
+ uuid,
+
+ // creation timestamp - seconds since Unix epoch
+ nowTs,
+
+ // visitCount - 0 = no previous visit
+ 0,
+
+ // current visit timestamp
+ nowTs,
+
+ // last visit timestamp - blank = no previous visit
+ '',
+
+ // last ecommerce order timestamp
+ ''
+ ];
+
+ return cookieValue;
+ }
+
+
+ /**
+ * Loads the Visitor ID cookie and returns a named array of values
+ */
+ function getValuesFromVisitorIdCookie() {
+ var cookieVisitorIdValue = loadVisitorIdCookie(),
+ newVisitor = cookieVisitorIdValue[0],
+ uuid = cookieVisitorIdValue[1],
+ createTs = cookieVisitorIdValue[2],
+ visitCount = cookieVisitorIdValue[3],
+ currentVisitTs = cookieVisitorIdValue[4],
+ lastVisitTs = cookieVisitorIdValue[5];
+
+ // case migrating from pre-1.5 cookies
+ if (!isDefined(cookieVisitorIdValue[6])) {
+ cookieVisitorIdValue[6] = "";
+ }
+
+ var lastEcommerceOrderTs = cookieVisitorIdValue[6];
+
+ return {
+ newVisitor: newVisitor,
+ uuid: uuid,
+ createTs: createTs,
+ visitCount: visitCount,
+ currentVisitTs: currentVisitTs,
+ lastVisitTs: lastVisitTs,
+ lastEcommerceOrderTs: lastEcommerceOrderTs
+ };
+ }
+
+ function getRemainingVisitorCookieTimeout() {
+ var now = new Date(),
+ nowTs = now.getTime(),
+ cookieCreatedTs = getValuesFromVisitorIdCookie().createTs;
+
+ var createTs = parseInt(cookieCreatedTs, 10);
+ var originalTimeout = (createTs * 1000) + configVisitorCookieTimeout - nowTs;
+ return originalTimeout;
+ }
+
+ /*
+ * Sets the Visitor ID cookie
+ */
+ function setVisitorIdCookie(visitorIdCookieValues) {
+
+ if(!configTrackerSiteId) {
+ // when called before Site ID was set
+ return;
+ }
+
+ var now = new Date(),
+ nowTs = Math.round(now.getTime() / 1000);
+
+ if(!isDefined(visitorIdCookieValues)) {
+ visitorIdCookieValues = getValuesFromVisitorIdCookie();
+ }
+
+ var cookieValue = visitorIdCookieValues.uuid + '.' +
+ visitorIdCookieValues.createTs + '.' +
+ visitorIdCookieValues.visitCount + '.' +
+ nowTs + '.' +
+ visitorIdCookieValues.lastVisitTs + '.' +
+ visitorIdCookieValues.lastEcommerceOrderTs;
+
+ setCookie(getCookieName('id'), cookieValue, getRemainingVisitorCookieTimeout(), configCookiePath, configCookieDomain, configCookieIsSecure);
+ }
+
+ /*
+ * Loads the referrer attribution information
+ *
+ * @returns array
+ * 0: campaign name
+ * 1: campaign keyword
+ * 2: timestamp
+ * 3: raw URL
+ */
+ function loadReferrerAttributionCookie() {
+ // NOTE: if the format of the cookie changes,
+ // we must also update JS tests, PHP tracker, System tests,
+ // and notify other tracking clients (eg. Java) of the changes
+ var cookie = getCookie(getCookieName('ref'));
+
+ if (cookie.length) {
+ try {
+ cookie = JSON_PIWIK.parse(cookie);
+ if (isObject(cookie)) {
+ return cookie;
+ }
+ } catch (ignore) {
+ // Pre 1.3, this cookie was not JSON encoded
+ }
+ }
+
+ return [
+ '',
+ '',
+ 0,
+ ''
+ ];
+ }
+
+ function deleteCookie(cookieName, path, domain) {
+ setCookie(cookieName, '', -86400, path, domain);
+ }
+
+ function isPossibleToSetCookieOnDomain(domainToTest)
+ {
+ var valueToSet = 'testvalue';
+ setCookie('test', valueToSet, 10000, null, domainToTest);
+
+ if (getCookie('test') === valueToSet) {
+ deleteCookie('test', null, domainToTest);
+
+ return true;
+ }
+
+ return false;
+ }
+
+ function deleteCookies() {
+ var savedConfigCookiesDisabled = configCookiesDisabled;
+
+ // Temporarily allow cookies just to delete the existing ones
+ configCookiesDisabled = false;
+
+ var index, cookieName;
+
+ for (index = 0; index < configCookiesToDelete.length; index++) {
+ cookieName = getCookieName(configCookiesToDelete[index]);
+ if (cookieName !== CONSENT_REMOVED_COOKIE_NAME && cookieName !== CONSENT_COOKIE_NAME && 0 !== getCookie(cookieName)) {
+ deleteCookie(cookieName, configCookiePath, configCookieDomain);
+ }
+ }
+
+ configCookiesDisabled = savedConfigCookiesDisabled;
+ }
+
+ function setSiteId(siteId) {
+ configTrackerSiteId = siteId;
+ setVisitorIdCookie();
+ }
+
+ function sortObjectByKeys(value) {
+ if (!value || !isObject(value)) {
+ return;
+ }
+
+ // Object.keys(value) is not supported by all browsers, we get the keys manually
+ var keys = [];
+ var key;
+
+ for (key in value) {
+ if (Object.prototype.hasOwnProperty.call(value, key)) {
+ keys.push(key);
+ }
+ }
+
+ var normalized = {};
+ keys.sort();
+ var len = keys.length;
+ var i;
+
+ for (i = 0; i < len; i++) {
+ normalized[keys[i]] = value[keys[i]];
+ }
+
+ return normalized;
+ }
+
+ /**
+ * Creates the session cookie
+ */
+ function setSessionCookie() {
+ setCookie(getCookieName('ses'), '*', configSessionCookieTimeout, configCookiePath, configCookieDomain, configCookieIsSecure);
+ }
+
+ function generateUniqueId() {
+ var id = '';
+ var chars = 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
+ var charLen = chars.length;
+ var i;
+
+ for (i = 0; i < 6; i++) {
+ id += chars.charAt(Math.floor(Math.random() * charLen));
+ }
+
+ return id;
+ }
+
+ /**
+ * Returns the URL to call piwik.php,
+ * with the standard parameters (plugins, resolution, url, referrer, etc.).
+ * Sends the pageview and browser settings with every request in case of race conditions.
+ */
+ function getRequest(request, customData, pluginMethod, currentEcommerceOrderTs) {
+ var i,
+ now = new Date(),
+ nowTs = Math.round(now.getTime() / 1000),
+ referralTs,
+ referralUrl,
+ referralUrlMaxLength = 1024,
+ currentReferrerHostName,
+ originalReferrerHostName,
+ customVariablesCopy = customVariables,
+ cookieSessionName = getCookieName('ses'),
+ cookieReferrerName = getCookieName('ref'),
+ cookieCustomVariablesName = getCookieName('cvar'),
+ cookieSessionValue = getCookie(cookieSessionName),
+ attributionCookie = loadReferrerAttributionCookie(),
+ currentUrl = configCustomUrl || locationHrefAlias,
+ campaignNameDetected,
+ campaignKeywordDetected;
+
+ if (configCookiesDisabled) {
+ deleteCookies();
+ }
+
+ if (configDoNotTrack) {
+ return '';
+ }
+
+ var cookieVisitorIdValues = getValuesFromVisitorIdCookie();
+ if (!isDefined(currentEcommerceOrderTs)) {
+ currentEcommerceOrderTs = "";
+ }
+
+ // send charset if document charset is not utf-8. sometimes encoding
+ // of urls will be the same as this and not utf-8, which will cause problems
+ // do not send charset if it is utf8 since it's assumed by default in Piwik
+ var charSet = documentAlias.characterSet || documentAlias.charset;
+
+ if (!charSet || charSet.toLowerCase() === 'utf-8') {
+ charSet = null;
+ }
+
+ campaignNameDetected = attributionCookie[0];
+ campaignKeywordDetected = attributionCookie[1];
+ referralTs = attributionCookie[2];
+ referralUrl = attributionCookie[3];
+
+ if (!cookieSessionValue) {
+ // cookie 'ses' was not found: we consider this the start of a 'session'
+
+ // here we make sure that if 'ses' cookie is deleted few times within the visit
+ // and so this code path is triggered many times for one visit,
+ // we only increase visitCount once per Visit window (default 30min)
+ var visitDuration = configSessionCookieTimeout / 1000;
+ if (!cookieVisitorIdValues.lastVisitTs
+ || (nowTs - cookieVisitorIdValues.lastVisitTs) > visitDuration) {
+ cookieVisitorIdValues.visitCount++;
+ cookieVisitorIdValues.lastVisitTs = cookieVisitorIdValues.currentVisitTs;
+ }
+
+
+ // Detect the campaign information from the current URL
+ // Only if campaign wasn't previously set
+ // Or if it was set but we must attribute to the most recent one
+ // Note: we are working on the currentUrl before purify() since we can parse the campaign parameters in the hash tag
+ if (!configConversionAttributionFirstReferrer
+ || !campaignNameDetected.length) {
+ for (i in configCampaignNameParameters) {
+ if (Object.prototype.hasOwnProperty.call(configCampaignNameParameters, i)) {
+ campaignNameDetected = getUrlParameter(currentUrl, configCampaignNameParameters[i]);
+
+ if (campaignNameDetected.length) {
+ break;
+ }
+ }
+ }
+
+ for (i in configCampaignKeywordParameters) {
+ if (Object.prototype.hasOwnProperty.call(configCampaignKeywordParameters, i)) {
+ campaignKeywordDetected = getUrlParameter(currentUrl, configCampaignKeywordParameters[i]);
+
+ if (campaignKeywordDetected.length) {
+ break;
+ }
+ }
+ }
+ }
+
+ // Store the referrer URL and time in the cookie;
+ // referral URL depends on the first or last referrer attribution
+ currentReferrerHostName = getHostName(configReferrerUrl);
+ originalReferrerHostName = referralUrl.length ? getHostName(referralUrl) : '';
+
+ if (currentReferrerHostName.length && // there is a referrer
+ !isSiteHostName(currentReferrerHostName) && // domain is not the current domain
+ (!configConversionAttributionFirstReferrer || // attribute to last known referrer
+ !originalReferrerHostName.length || // previously empty
+ isSiteHostName(originalReferrerHostName))) { // previously set but in current domain
+ referralUrl = configReferrerUrl;
+ }
+
+ // Set the referral cookie if we have either a Referrer URL, or detected a Campaign (or both)
+ if (referralUrl.length
+ || campaignNameDetected.length) {
+ referralTs = nowTs;
+ attributionCookie = [
+ campaignNameDetected,
+ campaignKeywordDetected,
+ referralTs,
+ purify(referralUrl.slice(0, referralUrlMaxLength))
+ ];
+
+ setCookie(cookieReferrerName, JSON_PIWIK.stringify(attributionCookie), configReferralCookieTimeout, configCookiePath, configCookieDomain);
+ }
+ }
+
+ // build out the rest of the request
+ request += '&idsite=' + configTrackerSiteId +
+ '&rec=1' +
+ '&r=' + String(Math.random()).slice(2, 8) + // keep the string to a minimum
+ '&h=' + now.getHours() + '&m=' + now.getMinutes() + '&s=' + now.getSeconds() +
+ '&url=' + encodeWrapper(purify(currentUrl)) +
+ (configReferrerUrl.length ? '&urlref=' + encodeWrapper(purify(configReferrerUrl)) : '') +
+ ((configUserId && configUserId.length) ? '&uid=' + encodeWrapper(configUserId) : '') +
+ '&_id=' + cookieVisitorIdValues.uuid + '&_idts=' + cookieVisitorIdValues.createTs + '&_idvc=' + cookieVisitorIdValues.visitCount +
+ '&_idn=' + cookieVisitorIdValues.newVisitor + // currently unused
+ (campaignNameDetected.length ? '&_rcn=' + encodeWrapper(campaignNameDetected) : '') +
+ (campaignKeywordDetected.length ? '&_rck=' + encodeWrapper(campaignKeywordDetected) : '') +
+ '&_refts=' + referralTs +
+ '&_viewts=' + cookieVisitorIdValues.lastVisitTs +
+ (String(cookieVisitorIdValues.lastEcommerceOrderTs).length ? '&_ects=' + cookieVisitorIdValues.lastEcommerceOrderTs : '') +
+ (String(referralUrl).length ? '&_ref=' + encodeWrapper(purify(referralUrl.slice(0, referralUrlMaxLength))) : '') +
+ (charSet ? '&cs=' + encodeWrapper(charSet) : '') +
+ '&send_image=0';
+
+ // browser features
+ for (i in browserFeatures) {
+ if (Object.prototype.hasOwnProperty.call(browserFeatures, i)) {
+ request += '&' + i + '=' + browserFeatures[i];
+ }
+ }
+
+ var customDimensionIdsAlreadyHandled = [];
+ if (customData) {
+ for (i in customData) {
+ if (Object.prototype.hasOwnProperty.call(customData, i) && /^dimension\d+$/.test(i)) {
+ var index = i.replace('dimension', '');
+ customDimensionIdsAlreadyHandled.push(parseInt(index, 10));
+ customDimensionIdsAlreadyHandled.push(String(index));
+ request += '&' + i + '=' + customData[i];
+ delete customData[i];
+ }
+ }
+ }
+
+ if (customData && isObjectEmpty(customData)) {
+ customData = null;
+ // we deleted all keys from custom data
+ }
+
+ // custom dimensions
+ for (i in customDimensions) {
+ if (Object.prototype.hasOwnProperty.call(customDimensions, i)) {
+ var isNotSetYet = (-1 === indexOfArray(customDimensionIdsAlreadyHandled, i));
+ if (isNotSetYet) {
+ request += '&dimension' + i + '=' + customDimensions[i];
+ }
+ }
+ }
+
+ // custom data
+ if (customData) {
+ request += '&data=' + encodeWrapper(JSON_PIWIK.stringify(customData));
+ } else if (configCustomData) {
+ request += '&data=' + encodeWrapper(JSON_PIWIK.stringify(configCustomData));
+ }
+
+ // Custom Variables, scope "page"
+ function appendCustomVariablesToRequest(customVariables, parameterName) {
+ var customVariablesStringified = JSON_PIWIK.stringify(customVariables);
+ if (customVariablesStringified.length > 2) {
+ return '&' + parameterName + '=' + encodeWrapper(customVariablesStringified);
+ }
+ return '';
+ }
+
+ var sortedCustomVarPage = sortObjectByKeys(customVariablesPage);
+ var sortedCustomVarEvent = sortObjectByKeys(customVariablesEvent);
+
+ request += appendCustomVariablesToRequest(sortedCustomVarPage, 'cvar');
+ request += appendCustomVariablesToRequest(sortedCustomVarEvent, 'e_cvar');
+
+ // Custom Variables, scope "visit"
+ if (customVariables) {
+ request += appendCustomVariablesToRequest(customVariables, '_cvar');
+
+ // Don't save deleted custom variables in the cookie
+ for (i in customVariablesCopy) {
+ if (Object.prototype.hasOwnProperty.call(customVariablesCopy, i)) {
+ if (customVariables[i][0] === '' || customVariables[i][1] === '') {
+ delete customVariables[i];
+ }
+ }
+ }
+
+ if (configStoreCustomVariablesInCookie) {
+ setCookie(cookieCustomVariablesName, JSON_PIWIK.stringify(customVariables), configSessionCookieTimeout, configCookiePath, configCookieDomain);
+ }
+ }
+
+ // performance tracking
+ if (configPerformanceTrackingEnabled) {
+ if (configPerformanceGenerationTime) {
+ request += '&gt_ms=' + configPerformanceGenerationTime;
+ } else if (performanceAlias && performanceAlias.timing
+ && performanceAlias.timing.requestStart && performanceAlias.timing.responseEnd) {
+ request += '&gt_ms=' + (performanceAlias.timing.responseEnd - performanceAlias.timing.requestStart);
+ }
+ }
+
+ if (configIdPageView) {
+ request += '&pv_id=' + configIdPageView;
+ }
+
+ // update cookies
+ cookieVisitorIdValues.lastEcommerceOrderTs = isDefined(currentEcommerceOrderTs) && String(currentEcommerceOrderTs).length ? currentEcommerceOrderTs : cookieVisitorIdValues.lastEcommerceOrderTs;
+ setVisitorIdCookie(cookieVisitorIdValues);
+ setSessionCookie();
+
+ // tracker plugin hook
+ request += executePluginMethod(pluginMethod, {tracker: trackerInstance, request: request});
+
+ if (configAppendToTrackingUrl.length) {
+ request += '&' + configAppendToTrackingUrl;
+ }
+
+ if (isFunction(configCustomRequestContentProcessing)) {
+ request = configCustomRequestContentProcessing(request);
+ }
+
+ return request;
+ }
+
+ /*
+ * If there was user activity since the last check, and it's been configHeartBeatDelay seconds
+ * since the last tracker, send a ping request (the heartbeat timeout will be reset by sendRequest).
+ */
+ heartBeatPingIfActivityAlias = function heartBeatPingIfActivity() {
+ var now = new Date();
+ if (lastTrackerRequestTime + configHeartBeatDelay <= now.getTime()) {
+ var requestPing = getRequest('ping=1', null, 'ping');
+ sendRequest(requestPing, configTrackerPause);
+
+ return true;
+ }
+
+ return false;
+ };
+
+ function logEcommerce(orderId, grandTotal, subTotal, tax, shipping, discount) {
+ var request = 'idgoal=0',
+ lastEcommerceOrderTs,
+ now = new Date(),
+ items = [],
+ sku,
+ isEcommerceOrder = String(orderId).length;
+
+ if (isEcommerceOrder) {
+ request += '&ec_id=' + encodeWrapper(orderId);
+ // Record date of order in the visitor cookie
+ lastEcommerceOrderTs = Math.round(now.getTime() / 1000);
+ }
+
+ request += '&revenue=' + grandTotal;
+
+ if (String(subTotal).length) {
+ request += '&ec_st=' + subTotal;
+ }
+
+ if (String(tax).length) {
+ request += '&ec_tx=' + tax;
+ }
+
+ if (String(shipping).length) {
+ request += '&ec_sh=' + shipping;
+ }
+
+ if (String(discount).length) {
+ request += '&ec_dt=' + discount;
+ }
+
+ if (ecommerceItems) {
+ // Removing the SKU index in the array before JSON encoding
+ for (sku in ecommerceItems) {
+ if (Object.prototype.hasOwnProperty.call(ecommerceItems, sku)) {
+ // Ensure name and category default to healthy value
+ if (!isDefined(ecommerceItems[sku][1])) {
+ ecommerceItems[sku][1] = "";
+ }
+
+ if (!isDefined(ecommerceItems[sku][2])) {
+ ecommerceItems[sku][2] = "";
+ }
+
+ // Set price to zero
+ if (!isDefined(ecommerceItems[sku][3])
+ || String(ecommerceItems[sku][3]).length === 0) {
+ ecommerceItems[sku][3] = 0;
+ }
+
+ // Set quantity to 1
+ if (!isDefined(ecommerceItems[sku][4])
+ || String(ecommerceItems[sku][4]).length === 0) {
+ ecommerceItems[sku][4] = 1;
+ }
+
+ items.push(ecommerceItems[sku]);
+ }
+ }
+ request += '&ec_items=' + encodeWrapper(JSON_PIWIK.stringify(items));
+ }
+ request = getRequest(request, configCustomData, 'ecommerce', lastEcommerceOrderTs);
+ sendRequest(request, configTrackerPause);
+
+ if (isEcommerceOrder) {
+ ecommerceItems = {};
+ }
+ }
+
+ function logEcommerceOrder(orderId, grandTotal, subTotal, tax, shipping, discount) {
+ if (String(orderId).length
+ && isDefined(grandTotal)) {
+ logEcommerce(orderId, grandTotal, subTotal, tax, shipping, discount);
+ }
+ }
+
+ function logEcommerceCartUpdate(grandTotal) {
+ if (isDefined(grandTotal)) {
+ logEcommerce("", grandTotal, "", "", "", "");
+ }
+ }
+
+ /*
+ * Log the page view / visit
+ */
+ function logPageView(customTitle, customData, callback) {
+ configIdPageView = generateUniqueId();
+
+ var request = getRequest('action_name=' + encodeWrapper(titleFixup(customTitle || configTitle)), customData, 'log');
+
+ sendRequest(request, configTrackerPause, callback);
+ }
+
+ /*
+ * Construct regular expression of classes
+ */
+ function getClassesRegExp(configClasses, defaultClass) {
+ var i,
+ classesRegExp = '(^| )(piwik[_-]' + defaultClass;
+
+ if (configClasses) {
+ for (i = 0; i < configClasses.length; i++) {
+ classesRegExp += '|' + configClasses[i];
+ }
+ }
+
+ classesRegExp += ')( |$)';
+
+ return new RegExp(classesRegExp);
+ }
+
+ function startsUrlWithTrackerUrl(url) {
+ return (configTrackerUrl && url && 0 === String(url).indexOf(configTrackerUrl));
+ }
+
+ /*
+ * Link or Download?
+ */
+ function getLinkType(className, href, isInLink, hasDownloadAttribute) {
+ if (startsUrlWithTrackerUrl(href)) {
+ return 0;
+ }
+
+ // does class indicate whether it is an (explicit/forced) outlink or a download?
+ var downloadPattern = getClassesRegExp(configDownloadClasses, 'download'),
+ linkPattern = getClassesRegExp(configLinkClasses, 'link'),
+
+ // does file extension indicate that it is a download?
+ downloadExtensionsPattern = new RegExp('\\.(' + configDownloadExtensions.join('|') + ')([?&#]|$)', 'i');
+
+ if (linkPattern.test(className)) {
+ return 'link';
+ }
+
+ if (hasDownloadAttribute || downloadPattern.test(className) || downloadExtensionsPattern.test(href)) {
+ return 'download';
+ }
+
+ if (isInLink) {
+ return 0;
+ }
+
+ return 'link';
+ }
+
+ function getSourceElement(sourceElement)
+ {
+ var parentElement;
+
+ parentElement = sourceElement.parentNode;
+ while (parentElement !== null &&
+ /* buggy IE5.5 */
+ isDefined(parentElement)) {
+
+ if (query.isLinkElement(sourceElement)) {
+ break;
+ }
+ sourceElement = parentElement;
+ parentElement = sourceElement.parentNode;
+ }
+
+ return sourceElement;
+ }
+
+ function getLinkIfShouldBeProcessed(sourceElement)
+ {
+ sourceElement = getSourceElement(sourceElement);
+
+ if (!query.hasNodeAttribute(sourceElement, 'href')) {
+ return;
+ }
+
+ if (!isDefined(sourceElement.href)) {
+ return;
+ }
+
+ var href = query.getAttributeValueFromNode(sourceElement, 'href');
+
+ if (startsUrlWithTrackerUrl(href)) {
+ return;
+ }
+
+ var originalSourcePath = sourceElement.pathname || getPathName(sourceElement.href);
+
+ // browsers, such as Safari, don't downcase hostname and href
+ var originalSourceHostName = sourceElement.hostname || getHostName(sourceElement.href);
+ var sourceHostName = originalSourceHostName.toLowerCase();
+ var sourceHref = sourceElement.href.replace(originalSourceHostName, sourceHostName);
+
+ // browsers, such as Safari, don't downcase hostname and href
+ var scriptProtocol = new RegExp('^(javascript|vbscript|jscript|mocha|livescript|ecmascript|mailto|tel):', 'i');
+
+ if (!scriptProtocol.test(sourceHref)) {
+ // track outlinks and all downloads
+ var linkType = getLinkType(sourceElement.className, sourceHref, isSiteHostPath(sourceHostName, originalSourcePath), query.hasNodeAttribute(sourceElement, 'download'));
+
+ if (linkType) {
+ return {
+ type: linkType,
+ href: sourceHref
+ };
+ }
+ }
+ }
+
+ function buildContentInteractionRequest(interaction, name, piece, target)
+ {
+ var params = content.buildInteractionRequestParams(interaction, name, piece, target);
+
+ if (!params) {
+ return;
+ }
+
+ return getRequest(params, null, 'contentInteraction');
+ }
+
+ function buildContentInteractionTrackingRedirectUrl(url, contentInteraction, contentName, contentPiece, contentTarget)
+ {
+ if (!isDefined(url)) {
+ return;
+ }
+
+ if (startsUrlWithTrackerUrl(url)) {
+ return url;
+ }
+
+ var redirectUrl = content.toAbsoluteUrl(url);
+ var request = 'redirecturl=' + encodeWrapper(redirectUrl) + '&';
+ request += buildContentInteractionRequest(contentInteraction, contentName, contentPiece, (contentTarget || url));
+
+ var separator = '&';
+ if (configTrackerUrl.indexOf('?') < 0) {
+ separator = '?';
+ }
+
+ return configTrackerUrl + separator + request;
+ }
+
+ function isNodeAuthorizedToTriggerInteraction(contentNode, interactedNode)
+ {
+ if (!contentNode || !interactedNode) {
+ return false;
+ }
+
+ var targetNode = content.findTargetNode(contentNode);
+
+ if (content.shouldIgnoreInteraction(targetNode)) {
+ // interaction should be ignored
+ return false;
+ }
+
+ targetNode = content.findTargetNodeNoDefault(contentNode);
+ if (targetNode && !containsNodeElement(targetNode, interactedNode)) {
+ /**
+ * There is a target node defined but the clicked element is not within the target node. example:
+ * <div data-track-content><a href="Y" data-content-target>Y</a><img src=""/><a href="Z">Z</a></div>
+ *
+ * The user clicked in this case on link Z and not on target Y
+ */
+ return false;
+ }
+
+ return true;
+ }
+
+ function getContentInteractionToRequestIfPossible (anyNode, interaction, fallbackTarget)
+ {
+ if (!anyNode) {
+ return;
+ }
+
+ var contentNode = content.findParentContentNode(anyNode);
+
+ if (!contentNode) {
+ // we are not within a content block
+ return;
+ }
+
+ if (!isNodeAuthorizedToTriggerInteraction(contentNode, anyNode)) {
+ return;
+ }
+
+ var contentBlock = content.buildContentBlock(contentNode);
+
+ if (!contentBlock) {
+ return;
+ }
+
+ if (!contentBlock.target && fallbackTarget) {
+ contentBlock.target = fallbackTarget;
+ }
+
+ return content.buildInteractionRequestParams(interaction, contentBlock.name, contentBlock.piece, contentBlock.target);
+ }
+
+ function wasContentImpressionAlreadyTracked(contentBlock)
+ {
+ if (!trackedContentImpressions || !trackedContentImpressions.length) {
+ return false;
+ }
+
+ var index, trackedContent;
+
+ for (index = 0; index < trackedContentImpressions.length; index++) {
+ trackedContent = trackedContentImpressions[index];
+
+ if (trackedContent &&
+ trackedContent.name === contentBlock.name &&
+ trackedContent.piece === contentBlock.piece &&
+ trackedContent.target === contentBlock.target) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ function replaceHrefIfInternalLink(contentBlock)
+ {
+ if (!contentBlock) {
+ return false;
+ }
+
+ var targetNode = content.findTargetNode(contentBlock);
+
+ if (!targetNode || content.shouldIgnoreInteraction(targetNode)) {
+ return false;
+ }
+
+ var link = getLinkIfShouldBeProcessed(targetNode);
+
+ if (linkTrackingEnabled && link && link.type) {
+
+ return false; // will be handled via outlink or download.
+ }
+
+ if (query.isLinkElement(targetNode) &&
+ query.hasNodeAttributeWithValue(targetNode, 'href')) {
+ var url = String(query.getAttributeValueFromNode(targetNode, 'href'));
+
+ if (0 === url.indexOf('#')) {
+ return false;
+ }
+
+ if (startsUrlWithTrackerUrl(url)) {
+ return true;
+ }
+
+ if (!content.isUrlToCurrentDomain(url)) {
+ return false;
+ }
+
+ var block = content.buildContentBlock(contentBlock);
+
+ if (!block) {
+ return;
+ }
+
+ var contentName = block.name;
+ var contentPiece = block.piece;
+ var contentTarget = block.target;
+
+ if (!query.hasNodeAttributeWithValue(targetNode, content.CONTENT_TARGET_ATTR) || targetNode.wasContentTargetAttrReplaced) {
+ // make sure we still track the correct content target when an interaction is happening
+ targetNode.wasContentTargetAttrReplaced = true;
+ contentTarget = content.toAbsoluteUrl(url);
+ query.setAnyAttribute(targetNode, content.CONTENT_TARGET_ATTR, contentTarget);
+ }
+
+ var targetUrl = buildContentInteractionTrackingRedirectUrl(url, 'click', contentName, contentPiece, contentTarget);
+
+ // location.href does not respect target=_blank so we prefer to use this
+ content.setHrefAttribute(targetNode, targetUrl);
+
+ return true;
+ }
+
+ return false;
+ }
+
+ function replaceHrefsIfInternalLink(contentNodes)
+ {
+ if (!contentNodes || !contentNodes.length) {
+ return;
+ }
+
+ var index;
+ for (index = 0; index < contentNodes.length; index++) {
+ replaceHrefIfInternalLink(contentNodes[index]);
+ }
+ }
+
+ function trackContentImpressionClickInteraction (targetNode)
+ {
+ return function (event) {
+
+ if (!targetNode) {
+ return;
+ }
+
+ var contentBlock = content.findParentContentNode(targetNode);
+
+ var interactedElement;
+ if (event) {
+ interactedElement = event.target || event.srcElement;
+ }
+ if (!interactedElement) {
+ interactedElement = targetNode;
+ }
+
+ if (!isNodeAuthorizedToTriggerInteraction(contentBlock, interactedElement)) {
+ return;
+ }
+
+ setExpireDateTime(configTrackerPause);
+
+ if (query.isLinkElement(targetNode) &&
+ query.hasNodeAttributeWithValue(targetNode, 'href') &&
+ query.hasNodeAttributeWithValue(targetNode, content.CONTENT_TARGET_ATTR)) {
+ // there is a href attribute, the link was replaced with piwik.php but later the href was changed again by the application.
+ var href = query.getAttributeValueFromNode(targetNode, 'href');
+ if (!startsUrlWithTrackerUrl(href) && targetNode.wasContentTargetAttrReplaced) {
+ query.setAnyAttribute(targetNode, content.CONTENT_TARGET_ATTR, '');
+ }
+ }
+
+ var link = getLinkIfShouldBeProcessed(targetNode);
+
+ if (linkTrackingInstalled && link && link.type) {
+ // click ignore, will be tracked via processClick, we do not want to track it twice
+
+ return link.type;
+ }
+
+ if (replaceHrefIfInternalLink(contentBlock)) {
+ return 'href';
+ }
+
+ var block = content.buildContentBlock(contentBlock);
+
+ if (!block) {
+ return;
+ }
+
+ var contentName = block.name;
+ var contentPiece = block.piece;
+ var contentTarget = block.target;
+
+ // click on any non link element, or on a link element that has not an href attribute or on an anchor
+ var request = buildContentInteractionRequest('click', contentName, contentPiece, contentTarget);
+ sendRequest(request, configTrackerPause);
+
+ return request;
+ };
+ }
+
+ function setupInteractionsTracking(contentNodes)
+ {
+ if (!contentNodes || !contentNodes.length) {
+ return;
+ }
+
+ var index, targetNode;
+ for (index = 0; index < contentNodes.length; index++) {
+ targetNode = content.findTargetNode(contentNodes[index]);
+
+ if (targetNode && !targetNode.contentInteractionTrackingSetupDone) {
+ targetNode.contentInteractionTrackingSetupDone = true;
+
+ addEventListener(targetNode, 'click', trackContentImpressionClickInteraction(targetNode));
+ }
+ }
+ }
+
+ /*
+ * Log all content pieces
+ */
+ function buildContentImpressionsRequests(contents, contentNodes)
+ {
+ if (!contents || !contents.length) {
+ return [];
+ }
+
+ var index, request;
+
+ for (index = 0; index < contents.length; index++) {
+
+ if (wasContentImpressionAlreadyTracked(contents[index])) {
+ contents.splice(index, 1);
+ index--;
+ } else {
+ trackedContentImpressions.push(contents[index]);
+ }
+ }
+
+ if (!contents || !contents.length) {
+ return [];
+ }
+
+ replaceHrefsIfInternalLink(contentNodes);
+ setupInteractionsTracking(contentNodes);
+
+ var requests = [];
+
+ for (index = 0; index < contents.length; index++) {
+
+ request = getRequest(
+ content.buildImpressionRequestParams(contents[index].name, contents[index].piece, contents[index].target),
+ undefined,
+ 'contentImpressions'
+ );
+
+ if (request) {
+ requests.push(request);
+ }
+ }
+
+ return requests;
+ }
+
+ /*
+ * Log all content pieces
+ */
+ function getContentImpressionsRequestsFromNodes(contentNodes)
+ {
+ var contents = content.collectContent(contentNodes);
+
+ return buildContentImpressionsRequests(contents, contentNodes);
+ }
+
+ /*
+ * Log currently visible content pieces
+ */
+ function getCurrentlyVisibleContentImpressionsRequestsIfNotTrackedYet(contentNodes)
+ {
+ if (!contentNodes || !contentNodes.length) {
+ return [];
+ }
+
+ var index;
+
+ for (index = 0; index < contentNodes.length; index++) {
+ if (!content.isNodeVisible(contentNodes[index])) {
+ contentNodes.splice(index, 1);
+ index--;
+ }
+ }
+
+ if (!contentNodes || !contentNodes.length) {
+ return [];
+ }
+
+ return getContentImpressionsRequestsFromNodes(contentNodes);
+ }
+
+ function buildContentImpressionRequest(contentName, contentPiece, contentTarget)
+ {
+ var params = content.buildImpressionRequestParams(contentName, contentPiece, contentTarget);
+
+ return getRequest(params, null, 'contentImpression');
+ }
+
+ function buildContentInteractionRequestNode(node, contentInteraction)
+ {
+ if (!node) {
+ return;
+ }
+
+ var contentNode = content.findParentContentNode(node);
+ var contentBlock = content.buildContentBlock(contentNode);
+
+ if (!contentBlock) {
+ return;
+ }
+
+ if (!contentInteraction) {
+ contentInteraction = 'Unknown';
+ }
+
+ return buildContentInteractionRequest(contentInteraction, contentBlock.name, contentBlock.piece, contentBlock.target);
+ }
+
+ function buildEventRequest(category, action, name, value)
+ {
+ return 'e_c=' + encodeWrapper(category)
+ + '&e_a=' + encodeWrapper(action)
+ + (isDefined(name) ? '&e_n=' + encodeWrapper(name) : '')
+ + (isDefined(value) ? '&e_v=' + encodeWrapper(value) : '');
+ }
+
+ /*
+ * Log the event
+ */
+ function logEvent(category, action, name, value, customData, callback)
+ {
+ // Category and Action are required parameters
+ if (trim(String(category)).length === 0 || trim(String(action)).length === 0) {
+ logConsoleError('Error while logging event: Parameters `category` and `action` must not be empty or filled with whitespaces');
+ return false;
+ }
+ var request = getRequest(
+ buildEventRequest(category, action, name, value),
+ customData,
+ 'event'
+ );
+
+ sendRequest(request, configTrackerPause, callback);
+ }
+
+ /*
+ * Log the site search request
+ */
+ function logSiteSearch(keyword, category, resultsCount, customData) {
+ var request = getRequest('search=' + encodeWrapper(keyword)
+ + (category ? '&search_cat=' + encodeWrapper(category) : '')
+ + (isDefined(resultsCount) ? '&search_count=' + resultsCount : ''), customData, 'sitesearch');
+
+ sendRequest(request, configTrackerPause);
+ }
+
+ /*
+ * Log the goal with the server
+ */
+ function logGoal(idGoal, customRevenue, customData) {
+ var request = getRequest('idgoal=' + idGoal + (customRevenue ? '&revenue=' + customRevenue : ''), customData, 'goal');
+
+ sendRequest(request, configTrackerPause);
+ }
+
+ /*
+ * Log the link or click with the server
+ */
+ function logLink(url, linkType, customData, callback, sourceElement) {
+
+ var linkParams = linkType + '=' + encodeWrapper(purify(url));
+
+ var interaction = getContentInteractionToRequestIfPossible(sourceElement, 'click', url);
+
+ if (interaction) {
+ linkParams += '&' + interaction;
+ }
+
+ var request = getRequest(linkParams, customData, 'link');
+
+ sendRequest(request, configTrackerPause, callback);
+ }
+
+ /*
+ * Browser prefix
+ */
+ function prefixPropertyName(prefix, propertyName) {
+ if (prefix !== '') {
+ return prefix + propertyName.charAt(0).toUpperCase() + propertyName.slice(1);
+ }
+
+ return propertyName;
+ }
+
+ /*
+ * Check for pre-rendered web pages, and log the page view/link/goal
+ * according to the configuration and/or visibility
+ *
+ * @see http://dvcs.w3.org/hg/webperf/raw-file/tip/specs/PageVisibility/Overview.html
+ */
+ function trackCallback(callback) {
+ var isPreRendered,
+ i,
+ // Chrome 13, IE10, FF10
+ prefixes = ['', 'webkit', 'ms', 'moz'],
+ prefix;
+
+ if (!configCountPreRendered) {
+ for (i = 0; i < prefixes.length; i++) {
+ prefix = prefixes[i];
+
+ // does this browser support the page visibility API?
+ if (Object.prototype.hasOwnProperty.call(documentAlias, prefixPropertyName(prefix, 'hidden'))) {
+ // if pre-rendered, then defer callback until page visibility changes
+ if (documentAlias[prefixPropertyName(prefix, 'visibilityState')] === 'prerender') {
+ isPreRendered = true;
+ }
+ break;
+ }
+ }
+ }
+
+ if (isPreRendered) {
+ // note: the event name doesn't follow the same naming convention as vendor properties
+ addEventListener(documentAlias, prefix + 'visibilitychange', function ready() {
+ documentAlias.removeEventListener(prefix + 'visibilitychange', ready, false);
+ callback();
+ });
+
+ return;
+ }
+
+ // configCountPreRendered === true || isPreRendered === false
+ callback();
+ }
+
+ function getCrossDomainVisitorId()
+ {
+ var visitorId = getValuesFromVisitorIdCookie().uuid;
+ var deviceId = makeCrossDomainDeviceId();
+ return visitorId + deviceId;
+ }
+
+ function replaceHrefForCrossDomainLink(element)
+ {
+ if (!element) {
+ return;
+ }
+
+ if (!query.hasNodeAttribute(element, 'href')) {
+ return;
+ }
+
+ var link = query.getAttributeValueFromNode(element, 'href');
+
+ if (!link || startsUrlWithTrackerUrl(link)) {
+ return;
+ }
+
+ // we need to remove the parameter and add it again if needed to make sure we have latest timestamp
+ // and visitorId (eg userId might be set etc)
+ link = removeUrlParameter(link, configVisitorIdUrlParameter);
+
+ if (link.indexOf('?') > 0) {
+ link += '&';
+ } else {
+ link += '?';
+ }
+
+ var crossDomainVisitorId = getCrossDomainVisitorId();
+
+ link = addUrlParameter(link, configVisitorIdUrlParameter, crossDomainVisitorId);
+
+ query.setAnyAttribute(element, 'href', link);
+ }
+
+ function isLinkToDifferentDomainButSamePiwikWebsite(element)
+ {
+ var targetLink = query.getAttributeValueFromNode(element, 'href');
+
+ if (!targetLink) {
+ return false;
+ }
+
+ targetLink = String(targetLink);
+
+ var isOutlink = targetLink.indexOf('//') === 0
+ || targetLink.indexOf('http://') === 0
+ || targetLink.indexOf('https://') === 0;
+
+ if (!isOutlink) {
+ return false;
+ }
+
+ var originalSourcePath = element.pathname || getPathName(element.href);
+ var originalSourceHostName = (element.hostname || getHostName(element.href)).toLowerCase();
+
+ if (isSiteHostPath(originalSourceHostName, originalSourcePath)) {
+ // we could also check against config cookie domain but this would require that other website
+ // sets actually same cookie domain and we cannot rely on it.
+ if (!isSameHost(domainAlias, domainFixup(originalSourceHostName))) {
+ return true;
+ }
+
+ return false;
+ }
+
+ return false;
+ }
+
+ /*
+ * Process clicks
+ */
+ function processClick(sourceElement) {
+ var link = getLinkIfShouldBeProcessed(sourceElement);
+
+ // not a link to same domain or the same website (as set in setDomains())
+ if (link && link.type) {
+ link.href = safeDecodeWrapper(link.href);
+ logLink(link.href, link.type, undefined, null, sourceElement);
+ return;
+ }
+
+
+ // a link to same domain or the same website (as set in setDomains())
+ if (crossDomainTrackingEnabled) {
+ // in case the clicked element is within the <a> (for example there is a <div> within the <a>) this will get the actual <a> link element
+ sourceElement = getSourceElement(sourceElement);
+
+ if(isLinkToDifferentDomainButSamePiwikWebsite(sourceElement)) {
+ replaceHrefForCrossDomainLink(sourceElement);
+ }
+
+ }
+ }
+
+ function isIE8orOlder()
+ {
+ return documentAlias.all && !documentAlias.addEventListener;
+ }
+
+ function getKeyCodeFromEvent(event)
+ {
+ // event.which is deprecated https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/which
+ var which = event.which;
+
+ /**
+ 1 : Left mouse button
+ 2 : Wheel button or middle button
+ 3 : Right mouse button
+ */
+
+ var typeOfEventButton = (typeof event.button);
+
+ if (!which && typeOfEventButton !== 'undefined' ) {
+ /**
+ -1: No button pressed
+ 0 : Main button pressed, usually the left button
+ 1 : Auxiliary button pressed, usually the wheel button or themiddle button (if present)
+ 2 : Secondary button pressed, usually the right button
+ 3 : Fourth button, typically the Browser Back button
+ 4 : Fifth button, typically the Browser Forward button
+
+ IE8 and earlier has different values:
+ 1 : Left mouse button
+ 2 : Right mouse button
+ 4 : Wheel button or middle button
+
+ For a left-hand configured mouse, the return values are reversed. We do not take care of that.
+ */
+
+ if (isIE8orOlder()) {
+ if (event.button & 1) {
+ which = 1;
+ } else if (event.button & 2) {
+ which = 3;
+ } else if (event.button & 4) {
+ which = 2;
+ }
+ } else {
+ if (event.button === 0 || event.button === '0') {
+ which = 1;
+ } else if (event.button & 1) {
+ which = 2;
+ } else if (event.button & 2) {
+ which = 3;
+ }
+ }
+ }
+
+ return which;
+ }
+
+ function getNameOfClickedButton(event)
+ {
+ switch (getKeyCodeFromEvent(event)) {
+ case 1:
+ return 'left';
+ case 2:
+ return 'middle';
+ case 3:
+ return 'right';
+ }
+ }
+
+ function getTargetElementFromEvent(event)
+ {
+ return event.target || event.srcElement;
+ }
+
+ /*
+ * Handle click event
+ */
+ function clickHandler(enable) {
+
+ return function (event) {
+
+ event = event || windowAlias.event;
+
+ var button = getNameOfClickedButton(event);
+ var target = getTargetElementFromEvent(event);
+
+ if (event.type === 'click') {
+
+ var ignoreClick = false;
+ if (enable && button === 'middle') {
+ // if enabled, we track middle clicks via mouseup
+ // some browsers (eg chrome) trigger click and mousedown/up events when middle is clicked,
+ // whereas some do not. This way we make "sure" to track them only once, either in click
+ // (default) or in mouseup (if enable == true)
+ ignoreClick = true;
+ }
+
+ if (target && !ignoreClick) {
+ processClick(target);
+ }
+ } else if (event.type === 'mousedown') {
+ if (button === 'middle' && target) {
+ lastButton = button;
+ lastTarget = target;
+ } else {
+ lastButton = lastTarget = null;
+ }
+ } else if (event.type === 'mouseup') {
+ if (button === lastButton && target === lastTarget) {
+ processClick(target);
+ }
+ lastButton = lastTarget = null;
+ } else if (event.type === 'contextmenu') {
+ processClick(target);
+ }
+ };
+ }
+
+ /*
+ * Add click listener to a DOM element
+ */
+ function addClickListener(element, enable) {
+ var enableType = typeof enable;
+ if (enableType === 'undefined') {
+ enable = true;
+ }
+
+ addEventListener(element, 'click', clickHandler(enable), false);
+
+ if (enable) {
+ addEventListener(element, 'mouseup', clickHandler(enable), false);
+ addEventListener(element, 'mousedown', clickHandler(enable), false);
+ addEventListener(element, 'contextmenu', clickHandler(enable), false);
+ }
+ }
+
+ /*
+ * Add click handlers to anchor and AREA elements, except those to be ignored
+ */
+ function addClickListeners(enable, trackerInstance) {
+ linkTrackingInstalled = true;
+
+ // iterate through anchor elements with href and AREA elements
+ var i,
+ ignorePattern = getClassesRegExp(configIgnoreClasses, 'ignore'),
+ linkElements = documentAlias.links,
+ linkElement = null, trackerType = null;
+
+ if (linkElements) {
+ for (i = 0; i < linkElements.length; i++) {
+ linkElement = linkElements[i];
+ if (!ignorePattern.test(linkElement.className)) {
+ trackerType = typeof linkElement.piwikTrackers;
+
+ if ('undefined' === trackerType) {
+ linkElement.piwikTrackers = [];
+ }
+
+ if (-1 === indexOfArray(linkElement.piwikTrackers, trackerInstance)) {
+ // we make sure to setup link only once for each tracker
+ linkElement.piwikTrackers.push(trackerInstance);
+ addClickListener(linkElement, enable);
+ }
+ }
+ }
+ }
+ }
+
+
+ function enableTrackOnlyVisibleContent (checkOnScroll, timeIntervalInMs, tracker) {
+
+ if (isTrackOnlyVisibleContentEnabled) {
+ // already enabled, do not register intervals again
+ return true;
+ }
+
+ isTrackOnlyVisibleContentEnabled = true;
+
+ var didScroll = false;
+ var events, index;
+
+ function setDidScroll() { didScroll = true; }
+
+ trackCallbackOnLoad(function () {
+
+ function checkContent(intervalInMs) {
+ setTimeout(function () {
+ if (!isTrackOnlyVisibleContentEnabled) {
+ return; // the tests stopped tracking only visible content
+ }
+ didScroll = false;
+ tracker.trackVisibleContentImpressions();
+ checkContent(intervalInMs);
+ }, intervalInMs);
+ }
+
+ function checkContentIfDidScroll(intervalInMs) {
+
+ setTimeout(function () {
+ if (!isTrackOnlyVisibleContentEnabled) {
+ return; // the tests stopped tracking only visible content
+ }
+
+ if (didScroll) {
+ didScroll = false;
+ tracker.trackVisibleContentImpressions();
+ }
+
+ checkContentIfDidScroll(intervalInMs);
+ }, intervalInMs);
+ }
+
+ if (checkOnScroll) {
+
+ // scroll event is executed after each pixel, so we make sure not to
+ // execute event too often. otherwise FPS goes down a lot!
+ events = ['scroll', 'resize'];
+ for (index = 0; index < events.length; index++) {
+ if (documentAlias.addEventListener) {
+ documentAlias.addEventListener(events[index], setDidScroll, false);
+ } else {
+ windowAlias.attachEvent('on' + events[index], setDidScroll);
+ }
+ }
+
+ checkContentIfDidScroll(100);
+ }
+
+ if (timeIntervalInMs && timeIntervalInMs > 0) {
+ timeIntervalInMs = parseInt(timeIntervalInMs, 10);
+ checkContent(timeIntervalInMs);
+ }
+
+ });
+ }
+
+ /*
+ * Browser features (plugins, resolution, cookies)
+ */
+ function detectBrowserFeatures() {
+ var i,
+ mimeType,
+ pluginMap = {
+ // document types
+ pdf: 'application/pdf',
+
+ // media players
+ qt: 'video/quicktime',
+ realp: 'audio/x-pn-realaudio-plugin',
+ wma: 'application/x-mplayer2',
+
+ // interactive multimedia
+ dir: 'application/x-director',
+ fla: 'application/x-shockwave-flash',
+
+ // RIA
+ java: 'application/x-java-vm',
+ gears: 'application/x-googlegears',
+ ag: 'application/x-silverlight'
+ };
+
+ // detect browser features except IE < 11 (IE 11 user agent is no longer MSIE)
+ if (!((new RegExp('MSIE')).test(navigatorAlias.userAgent))) {
+ // general plugin detection
+ if (navigatorAlias.mimeTypes && navigatorAlias.mimeTypes.length) {
+ for (i in pluginMap) {
+ if (Object.prototype.hasOwnProperty.call(pluginMap, i)) {
+ mimeType = navigatorAlias.mimeTypes[pluginMap[i]];
+ browserFeatures[i] = (mimeType && mimeType.enabledPlugin) ? '1' : '0';
+ }
+ }
+ }
+
+ // Safari and Opera
+ // IE6/IE7 navigator.javaEnabled can't be aliased, so test directly
+ // on Edge navigator.javaEnabled() always returns `true`, so ignore it
+ if (!((new RegExp('Edge[ /](\\d+[\\.\\d]+)')).test(navigatorAlias.userAgent)) &&
+ typeof navigator.javaEnabled !== 'unknown' &&
+ isDefined(navigatorAlias.javaEnabled) &&
+ navigatorAlias.javaEnabled()) {
+ browserFeatures.java = '1';
+ }
+
+ // Firefox
+ if (isFunction(windowAlias.GearsFactory)) {
+ browserFeatures.gears = '1';
+ }
+
+ // other browser features
+ browserFeatures.cookie = hasCookies();
+ }
+
+ var width = parseInt(screenAlias.width, 10);
+ var height = parseInt(screenAlias.height, 10);
+ browserFeatures.res = parseInt(width, 10) + 'x' + parseInt(height, 10);
+ }
+
+ /*<DEBUG>*/
+ /*
+ * Register a test hook. Using eval() permits access to otherwise
+ * privileged members.
+ */
+ function registerHook(hookName, userHook) {
+ var hookObj = null;
+
+ if (isString(hookName) && !isDefined(registeredHooks[hookName]) && userHook) {
+ if (isObject(userHook)) {
+ hookObj = userHook;
+ } else if (isString(userHook)) {
+ try {
+ eval('hookObj =' + userHook);
+ } catch (ignore) { }
+ }
+
+ registeredHooks[hookName] = hookObj;
+ }
+
+ return hookObj;
+ }
+ /*</DEBUG>*/
+
+ /************************************************************
+ * Constructor
+ ************************************************************/
+
+ /*
+ * initialize tracker
+ */
+ detectBrowserFeatures();
+ updateDomainHash();
+ setVisitorIdCookie();
+
+ /*<DEBUG>*/
+ /*
+ * initialize test plugin
+ */
+ executePluginMethod('run', null, registerHook);
+ /*</DEBUG>*/
+
+ /************************************************************
+ * Public data and methods
+ ************************************************************/
+
+
+ /*<DEBUG>*/
+ /*
+ * Test hook accessors
+ */
+ this.hook = registeredHooks;
+ this.getHook = function (hookName) {
+ return registeredHooks[hookName];
+ };
+ this.getQuery = function () {
+ return query;
+ };
+ this.getContent = function () {
+ return content;
+ };
+ this.setVisitorId = function (visitorId) {
+ visitorUUID = visitorId;
+ };
+
+ this.buildContentImpressionRequest = buildContentImpressionRequest;
+ this.buildContentInteractionRequest = buildContentInteractionRequest;
+ this.buildContentInteractionRequestNode = buildContentInteractionRequestNode;
+ this.buildContentInteractionTrackingRedirectUrl = buildContentInteractionTrackingRedirectUrl;
+ this.getContentImpressionsRequestsFromNodes = getContentImpressionsRequestsFromNodes;
+ this.getCurrentlyVisibleContentImpressionsRequestsIfNotTrackedYet = getCurrentlyVisibleContentImpressionsRequestsIfNotTrackedYet;
+ this.trackCallbackOnLoad = trackCallbackOnLoad;
+ this.trackCallbackOnReady = trackCallbackOnReady;
+ this.buildContentImpressionsRequests = buildContentImpressionsRequests;
+ this.wasContentImpressionAlreadyTracked = wasContentImpressionAlreadyTracked;
+ this.appendContentInteractionToRequestIfPossible = getContentInteractionToRequestIfPossible;
+ this.setupInteractionsTracking = setupInteractionsTracking;
+ this.trackContentImpressionClickInteraction = trackContentImpressionClickInteraction;
+ this.internalIsNodeVisible = isVisible;
+ this.isNodeAuthorizedToTriggerInteraction = isNodeAuthorizedToTriggerInteraction;
+ this.replaceHrefIfInternalLink = replaceHrefIfInternalLink;
+ this.getDomains = function () {
+ return configHostsAlias;
+ };
+ this.getConfigIdPageView = function () {
+ return configIdPageView;
+ };
+ this.getConfigDownloadExtensions = function () {
+ return configDownloadExtensions;
+ };
+ this.enableTrackOnlyVisibleContent = function (checkOnScroll, timeIntervalInMs) {
+ return enableTrackOnlyVisibleContent(checkOnScroll, timeIntervalInMs, this);
+ };
+ this.clearTrackedContentImpressions = function () {
+ trackedContentImpressions = [];
+ };
+ this.getTrackedContentImpressions = function () {
+ return trackedContentImpressions;
+ };
+ this.clearEnableTrackOnlyVisibleContent = function () {
+ isTrackOnlyVisibleContentEnabled = false;
+ };
+ this.disableLinkTracking = function () {
+ linkTrackingInstalled = false;
+ linkTrackingEnabled = false;
+ };
+ this.getConfigVisitorCookieTimeout = function () {
+ return configVisitorCookieTimeout;
+ };
+ this.removeAllAsyncTrackersButFirst = function () {
+ var firstTracker = asyncTrackers[0];
+ asyncTrackers = [firstTracker];
+ };
+ this.getConsentRequestsQueue = function () {
+ return consentRequestsQueue;
+ };
+ this.hasConsent = function () {
+ return configHasConsent;
+ };
+ this.getRemainingVisitorCookieTimeout = getRemainingVisitorCookieTimeout;
+ /*</DEBUG>*/
+
+ /**
+ * Get visitor ID (from first party cookie)
+ *
+ * @return string Visitor ID in hexits (or empty string, if not yet known)
+ */
+ this.getVisitorId = function () {
+ return getValuesFromVisitorIdCookie().uuid;
+ };
+
+ /**
+ * Get the visitor information (from first party cookie)
+ *
+ * @return array
+ */
+ this.getVisitorInfo = function () {
+ // Note: in a new method, we could return also return getValuesFromVisitorIdCookie()
+ // which returns named parameters rather than returning integer indexed array
+ return loadVisitorIdCookie();
+ };
+
+ /**
+ * Get the Attribution information, which is an array that contains
+ * the Referrer used to reach the site as well as the campaign name and keyword
+ * It is useful only when used in conjunction with Tracker API function setAttributionInfo()
+ * To access specific data point, you should use the other functions getAttributionReferrer* and getAttributionCampaign*
+ *
+ * @return array Attribution array, Example use:
+ * 1) Call JSON_PIWIK.stringify(piwikTracker.getAttributionInfo())
+ * 2) Pass this json encoded string to the Tracking API (php or java client): setAttributionInfo()
+ */
+ this.getAttributionInfo = function () {
+ return loadReferrerAttributionCookie();
+ };
+
+ /**
+ * Get the Campaign name that was parsed from the landing page URL when the visitor
+ * landed on the site originally
+ *
+ * @return string
+ */
+ this.getAttributionCampaignName = function () {
+ return loadReferrerAttributionCookie()[0];
+ };
+
+ /**
+ * Get the Campaign keyword that was parsed from the landing page URL when the visitor
+ * landed on the site originally
+ *
+ * @return string
+ */
+ this.getAttributionCampaignKeyword = function () {
+ return loadReferrerAttributionCookie()[1];
+ };
+
+ /**
+ * Get the time at which the referrer (used for Goal Attribution) was detected
+ *
+ * @return int Timestamp or 0 if no referrer currently set
+ */
+ this.getAttributionReferrerTimestamp = function () {
+ return loadReferrerAttributionCookie()[2];
+ };
+
+ /**
+ * Get the full referrer URL that will be used for Goal Attribution
+ *
+ * @return string Raw URL, or empty string '' if no referrer currently set
+ */
+ this.getAttributionReferrerUrl = function () {
+ return loadReferrerAttributionCookie()[3];
+ };
+
+ /**
+ * Specify the Piwik tracking URL
+ *
+ * @param string trackerUrl
+ */
+ this.setTrackerUrl = function (trackerUrl) {
+ configTrackerUrl = trackerUrl;
+ };
+
+ /**
+ * Returns the Piwik tracking URL
+ * @returns string
+ */
+ this.getTrackerUrl = function () {
+ return configTrackerUrl;
+ };
+
+ /**
+ * Returns the Piwik server URL.
+ *
+ * @returns string
+ */
+ this.getPiwikUrl = function () {
+ return getPiwikUrlForOverlay(this.getTrackerUrl(), configApiUrl);
+ };
+
+ /**
+ * Adds a new tracker. All sent requests will be also sent to the given siteId and piwikUrl.
+ *
+ * @param string piwikUrl The tracker URL of the current tracker instance
+ * @param int|string siteId
+ * @return Tracker
+ */
+ this.addTracker = function (piwikUrl, siteId) {
+ if (!siteId) {
+ throw new Error('A siteId must be given to add a new tracker');
+ }
+
+ if (!isDefined(piwikUrl) || null === piwikUrl) {
+ piwikUrl = this.getTrackerUrl();
+ }
+
+ var tracker = new Tracker(piwikUrl, siteId);
+
+ asyncTrackers.push(tracker);
+
+ return tracker;
+ };
+
+ /**
+ * Returns the site ID
+ *
+ * @returns int
+ */
+ this.getSiteId = function() {
+ return configTrackerSiteId;
+ };
+
+ /**
+ * Specify the site ID
+ *
+ * @param int|string siteId
+ */
+ this.setSiteId = function (siteId) {
+ setSiteId(siteId);
+ };
+
+ /**
+ * Clears the User ID and generates a new visitor id.
+ */
+ this.resetUserId = function() {
+ configUserId = '';
+ };
+
+ /**
+ * Sets a User ID to this user (such as an email address or a username)
+ *
+ * @param string User ID
+ */
+ this.setUserId = function (userId) {
+ if(!isDefined(userId) || !userId.length) {
+ return;
+ }
+ configUserId = userId;
+ };
+
+ /**
+ * Gets the User ID if set.
+ *
+ * @returns string User ID
+ */
+ this.getUserId = function() {
+ return configUserId;
+ };
+
+ /**
+ * Pass custom data to the server
+ *
+ * Examples:
+ * tracker.setCustomData(object);
+ * tracker.setCustomData(key, value);
+ *
+ * @param mixed key_or_obj
+ * @param mixed opt_value
+ */
+ this.setCustomData = function (key_or_obj, opt_value) {
+ if (isObject(key_or_obj)) {
+ configCustomData = key_or_obj;
+ } else {
+ if (!configCustomData) {
+ configCustomData = {};
+ }
+ configCustomData[key_or_obj] = opt_value;
+ }
+ };
+
+ /**
+ * Get custom data
+ *
+ * @return mixed
+ */
+ this.getCustomData = function () {
+ return configCustomData;
+ };
+
+ /**
+ * Configure function with custom request content processing logic.
+ * It gets called after request content in form of query parameters string has been prepared and before request content gets sent.
+ *
+ * Examples:
+ * tracker.setCustomRequestProcessing(function(request){
+ * var pairs = request.split('&');
+ * var result = {};
+ * pairs.forEach(function(pair) {
+ * pair = pair.split('=');
+ * result[pair[0]] = decodeURIComponent(pair[1] || '');
+ * });
+ * return JSON.stringify(result);
+ * });
+ *
+ * @param function customRequestContentProcessingLogic
+ */
+ this.setCustomRequestProcessing = function (customRequestContentProcessingLogic) {
+ configCustomRequestContentProcessing = customRequestContentProcessingLogic;
+ };
+
+ /**
+ * Appends the specified query string to the piwik.php?... Tracking API URL
+ *
+ * @param string queryString eg. 'lat=140&long=100'
+ */
+ this.appendToTrackingUrl = function (queryString) {
+ configAppendToTrackingUrl = queryString;
+ };
+
+ /**
+ * Returns the query string for the current HTTP Tracking API request.
+ * Piwik would prepend the hostname and path to Piwik: http://example.org/piwik/piwik.php?
+ * prior to sending the request.
+ *
+ * @param request eg. "param=value&param2=value2"
+ */
+ this.getRequest = function (request) {
+ return getRequest(request);
+ };
+
+ /**
+ * Add plugin defined by a name and a callback function.
+ * The callback function will be called whenever a tracking request is sent.
+ * This can be used to append data to the tracking request, or execute other custom logic.
+ *
+ * @param string pluginName
+ * @param Object pluginObj
+ */
+ this.addPlugin = function (pluginName, pluginObj) {
+ plugins[pluginName] = pluginObj;
+ };
+
+ /**
+ * Set Custom Dimensions. Set Custom Dimensions will not be cleared after a tracked pageview and will
+ * be sent along all following tracking requests. It is possible to remove/clear a value via `deleteCustomDimension`.
+ *
+ * @param int index A Custom Dimension index
+ * @param string value
+ */
+ this.setCustomDimension = function (customDimensionId, value) {
+ customDimensionId = parseInt(customDimensionId, 10);
+ if (customDimensionId > 0) {
+ if (!isDefined(value)) {
+ value = '';
+ }
+ if (!isString(value)) {
+ value = String(value);
+ }
+ customDimensions[customDimensionId] = value;
+ }
+ };
+
+ /**
+ * Get a stored value for a specific Custom Dimension index.
+ *
+ * @param int index A Custom Dimension index
+ */
+ this.getCustomDimension = function (customDimensionId) {
+ customDimensionId = parseInt(customDimensionId, 10);
+ if (customDimensionId > 0 && Object.prototype.hasOwnProperty.call(customDimensions, customDimensionId)) {
+ return customDimensions[customDimensionId];
+ }
+ };
+
+ /**
+ * Delete a custom dimension.
+ *
+ * @param int index Custom dimension Id
+ */
+ this.deleteCustomDimension = function (customDimensionId) {
+ customDimensionId = parseInt(customDimensionId, 10);
+ if (customDimensionId > 0) {
+ delete customDimensions[customDimensionId];
+ }
+ };
+
+ /**
+ * Set custom variable within this visit
+ *
+ * @param int index Custom variable slot ID from 1-5
+ * @param string name
+ * @param string value
+ * @param string scope Scope of Custom Variable:
+ * - "visit" will store the name/value in the visit and will persist it in the cookie for the duration of the visit,
+ * - "page" will store the name/value in the next page view tracked.
+ * - "event" will store the name/value in the next event tracked.
+ */
+ this.setCustomVariable = function (index, name, value, scope) {
+ var toRecord;
+
+ if (!isDefined(scope)) {
+ scope = 'visit';
+ }
+ if (!isDefined(name)) {
+ return;
+ }
+ if (!isDefined(value)) {
+ value = "";
+ }
+ if (index > 0) {
+ name = !isString(name) ? String(name) : name;
+ value = !isString(value) ? String(value) : value;
+ toRecord = [name.slice(0, customVariableMaximumLength), value.slice(0, customVariableMaximumLength)];
+ // numeric scope is there for GA compatibility
+ if (scope === 'visit' || scope === 2) {
+ loadCustomVariables();
+ customVariables[index] = toRecord;
+ } else if (scope === 'page' || scope === 3) {
+ customVariablesPage[index] = toRecord;
+ } else if (scope === 'event') { /* GA does not have 'event' scope but we do */
+ customVariablesEvent[index] = toRecord;
+ }
+ }
+ };
+
+ /**
+ * Get custom variable
+ *
+ * @param int index Custom variable slot ID from 1-5
+ * @param string scope Scope of Custom Variable: "visit" or "page" or "event"
+ */
+ this.getCustomVariable = function (index, scope) {
+ var cvar;
+
+ if (!isDefined(scope)) {
+ scope = "visit";
+ }
+
+ if (scope === "page" || scope === 3) {
+ cvar = customVariablesPage[index];
+ } else if (scope === "event") {
+ cvar = customVariablesEvent[index];
+ } else if (scope === "visit" || scope === 2) {
+ loadCustomVariables();
+ cvar = customVariables[index];
+ }
+
+ if (!isDefined(cvar)
+ || (cvar && cvar[0] === '')) {
+ return false;
+ }
+
+ return cvar;
+ };
+
+ /**
+ * Delete custom variable
+ *
+ * @param int index Custom variable slot ID from 1-5
+ * @param string scope
+ */
+ this.deleteCustomVariable = function (index, scope) {
+ // Only delete if it was there already
+ if (this.getCustomVariable(index, scope)) {
+ this.setCustomVariable(index, '', '', scope);
+ }
+ };
+
+ /**
+ * Deletes all custom variables for a certain scope.
+ *
+ * @param string scope
+ */
+ this.deleteCustomVariables = function (scope) {
+ if (scope === "page" || scope === 3) {
+ customVariablesPage = {};
+ } else if (scope === "event") {
+ customVariablesEvent = {};
+ } else if (scope === "visit" || scope === 2) {
+ customVariables = {};
+ }
+ };
+
+ /**
+ * When called then the Custom Variables of scope "visit" will be stored (persisted) in a first party cookie
+ * for the duration of the visit. This is useful if you want to call getCustomVariable later in the visit.
+ *
+ * By default, Custom Variables of scope "visit" are not stored on the visitor's computer.
+ */
+ this.storeCustomVariablesInCookie = function () {
+ configStoreCustomVariablesInCookie = true;
+ };
+
+ /**
+ * Set delay for link tracking (in milliseconds)
+ *
+ * @param int delay
+ */
+ this.setLinkTrackingTimer = function (delay) {
+ configTrackerPause = delay;
+ };
+
+ /**
+ * Get delay for link tracking (in milliseconds)
+ *
+ * @param int delay
+ */
+ this.getLinkTrackingTimer = function () {
+ return configTrackerPause;
+ };
+
+ /**
+ * Set list of file extensions to be recognized as downloads
+ *
+ * @param string|array extensions
+ */
+ this.setDownloadExtensions = function (extensions) {
+ if(isString(extensions)) {
+ extensions = extensions.split('|');
+ }
+ configDownloadExtensions = extensions;
+ };
+
+ /**
+ * Specify additional file extensions to be recognized as downloads
+ *
+ * @param string|array extensions for example 'custom' or ['custom1','custom2','custom3']
+ */
+ this.addDownloadExtensions = function (extensions) {
+ var i;
+ if(isString(extensions)) {
+ extensions = extensions.split('|');
+ }
+ for (i=0; i < extensions.length; i++) {
+ configDownloadExtensions.push(extensions[i]);
+ }
+ };
+
+ /**
+ * Removes specified file extensions from the list of recognized downloads
+ *
+ * @param string|array extensions for example 'custom' or ['custom1','custom2','custom3']
+ */
+ this.removeDownloadExtensions = function (extensions) {
+ var i, newExtensions = [];
+ if(isString(extensions)) {
+ extensions = extensions.split('|');
+ }
+ for (i=0; i < configDownloadExtensions.length; i++) {
+ if (indexOfArray(extensions, configDownloadExtensions[i]) === -1) {
+ newExtensions.push(configDownloadExtensions[i]);
+ }
+ }
+ configDownloadExtensions = newExtensions;
+ };
+
+ /**
+ * Set array of domains to be treated as local. Also supports path, eg '.piwik.org/subsite1'. In this
+ * case all links that don't go to '*.piwik.org/subsite1/ *' would be treated as outlinks.
+ * For example a link to 'piwik.org/' or 'piwik.org/subsite2' both would be treated as outlinks.
+ *
+ * Also supports page wildcard, eg 'piwik.org/index*'. In this case all links
+ * that don't go to piwik.org/index* would be treated as outlinks.
+ *
+ * The current domain will be added automatically if no given host alias contains a path and if no host
+ * alias is already given for the current host alias. Say you are on "example.org" and set
+ * "hostAlias = ['example.com', 'example.org/test']" then the current "example.org" domain will not be
+ * added as there is already a more restrictive hostAlias 'example.org/test' given. We also do not add
+ * it automatically if there was any other host specifying any path like
+ * "['example.com', 'example2.com/test']". In this case we would also not add the current
+ * domain "example.org" automatically as the "path" feature is used. As soon as someone uses the path
+ * feature, for Piwik JS Tracker to work correctly in all cases, one needs to specify all hosts
+ * manually.
+ *
+ * @param string|array hostsAlias
+ */
+ this.setDomains = function (hostsAlias) {
+ configHostsAlias = isString(hostsAlias) ? [hostsAlias] : hostsAlias;
+
+ var hasDomainAliasAlready = false, i = 0, alias;
+ for (i; i < configHostsAlias.length; i++) {
+ alias = String(configHostsAlias[i]);
+
+ if (isSameHost(domainAlias, domainFixup(alias))) {
+ hasDomainAliasAlready = true;
+ break;
+ }
+
+ var pathName = getPathName(alias);
+ if (pathName && pathName !== '/' && pathName !== '/*') {
+ hasDomainAliasAlready = true;
+ break;
+ }
+ }
+
+ // The current domain will be added automatically if no given host alias contains a path
+ // and if no host alias is already given for the current host alias.
+ if (!hasDomainAliasAlready) {
+ /**
+ * eg if domainAlias = 'piwik.org' and someone set hostsAlias = ['piwik.org/foo'] then we should
+ * not add piwik.org as it would increase the allowed scope.
+ */
+ configHostsAlias.push(domainAlias);
+ }
+ };
+
+ /**
+ * Enables cross domain linking. By default, the visitor ID that identifies a unique visitor is stored in
+ * the browser's first party cookies. This means the cookie can only be accessed by pages on the same domain.
+ * If you own multiple domains and would like to track all the actions and pageviews of a specific visitor
+ * into the same visit, you may enable cross domain linking. Whenever a user clicks on a link it will append
+ * a URL parameter pk_vid to the clicked URL which consists of these parts: 16 char visitorId, a 10 character
+ * current timestamp and the last 6 characters are an id based on the userAgent to identify the users device).
+ * This way the current visitorId is forwarded to the page of the different domain.
+ *
+ * On the different domain, the Piwik tracker will recognize the set visitorId from the URL parameter and
+ * reuse this parameter if the page was loaded within 45 seconds. If cross domain linking was not enabled,
+ * it would create a new visit on that page because we wouldn't be able to access the previously created
+ * cookie. By enabling cross domain linking you can track several different domains into one website and
+ * won't lose for example the original referrer.
+ *
+ * To make cross domain linking work you need to set which domains should be considered as your domains by
+ * calling the method "setDomains()" first. We will add the URL parameter to links that go to a
+ * different domain but only if the domain was previously set with "setDomains()" to make sure not to append
+ * the URL parameters when a link actually goes to a third-party URL.
+ */
+ this.enableCrossDomainLinking = function () {
+ crossDomainTrackingEnabled = true;
+ };
+
+ /**
+ * Disable cross domain linking if it was previously enabled. See enableCrossDomainLinking();
+ */
+ this.disableCrossDomainLinking = function () {
+ crossDomainTrackingEnabled = false;
+ };
+
+ /**
+ * Detect whether cross domain linking is enabled or not. See enableCrossDomainLinking();
+ * @returns bool
+ */
+ this.isCrossDomainLinkingEnabled = function () {
+ return crossDomainTrackingEnabled;
+ };
+
+
+ /**
+ * By default, the two visits across domains will be linked together
+ * when the link is click and the page is loaded within 180 seconds.
+ * @param timeout in seconds
+ */
+ this.setCrossDomainLinkingTimeout = function (timeout) {
+ configVisitorIdUrlParameterTimeoutInSeconds = timeout;
+ };
+
+ /**
+ * Returns the query parameter appended to link URLs so cross domain visits
+ * can be detected.
+ *
+ * If your application creates links dynamically, then you'll have to add this
+ * query parameter manually to those links (since the JavaScript tracker cannot
+ * detect when those links are added).
+ *
+ * Eg:
+ *
+ * var url = 'http://myotherdomain.com/?' + piwikTracker.getCrossDomainLinkingUrlParameter();
+ * $element.append('<a href="' + url + '"/>');
+ */
+ this.getCrossDomainLinkingUrlParameter = function () {
+ return encodeWrapper(configVisitorIdUrlParameter) + '=' + encodeWrapper(getCrossDomainVisitorId());
+ };
+
+ /**
+ * Set array of classes to be ignored if present in link
+ *
+ * @param string|array ignoreClasses
+ */
+ this.setIgnoreClasses = function (ignoreClasses) {
+ configIgnoreClasses = isString(ignoreClasses) ? [ignoreClasses] : ignoreClasses;
+ };
+
+ /**
+ * Set request method
+ *
+ * @param string method GET or POST; default is GET
+ */
+ this.setRequestMethod = function (method) {
+ configRequestMethod = method || defaultRequestMethod;
+ };
+
+ /**
+ * Set request Content-Type header value, applicable when POST request method is used for submitting tracking events.
+ * See XMLHttpRequest Level 2 spec, section 4.7.2 for invalid headers
+ * @link http://dvcs.w3.org/hg/xhr/raw-file/tip/Overview.html
+ *
+ * @param string requestContentType; default is 'application/x-www-form-urlencoded; charset=UTF-8'
+ */
+ this.setRequestContentType = function (requestContentType) {
+ configRequestContentType = requestContentType || defaultRequestContentType;
+ };
+
+ /**
+ * Override referrer
+ *
+ * @param string url
+ */
+ this.setReferrerUrl = function (url) {
+ configReferrerUrl = url;
+ };
+
+ /**
+ * Override url
+ *
+ * @param string url
+ */
+ this.setCustomUrl = function (url) {
+ configCustomUrl = resolveRelativeReference(locationHrefAlias, url);
+ };
+
+ /**
+ * Returns the current url of the page that is currently being visited. If a custom URL was set, the
+ * previously defined custom URL will be returned.
+ */
+ this.getCurrentUrl = function () {
+ return configCustomUrl || locationHrefAlias;
+ };
+
+ /**
+ * Override document.title
+ *
+ * @param string title
+ */
+ this.setDocumentTitle = function (title) {
+ configTitle = title;
+ };
+
+ /**
+ * Set the URL of the Piwik API. It is used for Page Overlay.
+ * This method should only be called when the API URL differs from the tracker URL.
+ *
+ * @param string apiUrl
+ */
+ this.setAPIUrl = function (apiUrl) {
+ configApiUrl = apiUrl;
+ };
+
+ /**
+ * Set array of classes to be treated as downloads
+ *
+ * @param string|array downloadClasses
+ */
+ this.setDownloadClasses = function (downloadClasses) {
+ configDownloadClasses = isString(downloadClasses) ? [downloadClasses] : downloadClasses;
+ };
+
+ /**
+ * Set array of classes to be treated as outlinks
+ *
+ * @param string|array linkClasses
+ */
+ this.setLinkClasses = function (linkClasses) {
+ configLinkClasses = isString(linkClasses) ? [linkClasses] : linkClasses;
+ };
+
+ /**
+ * Set array of campaign name parameters
+ *
+ * @see http://piwik.org/faq/how-to/#faq_120
+ * @param string|array campaignNames
+ */
+ this.setCampaignNameKey = function (campaignNames) {
+ configCampaignNameParameters = isString(campaignNames) ? [campaignNames] : campaignNames;
+ };
+
+ /**
+ * Set array of campaign keyword parameters
+ *
+ * @see http://piwik.org/faq/how-to/#faq_120
+ * @param string|array campaignKeywords
+ */
+ this.setCampaignKeywordKey = function (campaignKeywords) {
+ configCampaignKeywordParameters = isString(campaignKeywords) ? [campaignKeywords] : campaignKeywords;
+ };
+
+ /**
+ * Strip hash tag (or anchor) from URL
+ * Note: this can be done in the Piwik>Settings>Websites on a per-website basis
+ *
+ * @deprecated
+ * @param bool enableFilter
+ */
+ this.discardHashTag = function (enableFilter) {
+ configDiscardHashTag = enableFilter;
+ };
+
+ /**
+ * Set first-party cookie name prefix
+ *
+ * @param string cookieNamePrefix
+ */
+ this.setCookieNamePrefix = function (cookieNamePrefix) {
+ configCookieNamePrefix = cookieNamePrefix;
+ // Re-init the Custom Variables cookie
+ customVariables = getCustomVariablesFromCookie();
+ };
+
+ /**
+ * Set first-party cookie domain
+ *
+ * @param string domain
+ */
+ this.setCookieDomain = function (domain) {
+ var domainFixed = domainFixup(domain);
+
+ if (isPossibleToSetCookieOnDomain(domainFixed)) {
+ configCookieDomain = domainFixed;
+ updateDomainHash();
+ }
+ };
+
+ /**
+ * Get first-party cookie domain
+ */
+ this.getCookieDomain = function () {
+ return configCookieDomain;
+ };
+
+ /**
+ * Detect if cookies are enabled and supported by browser.
+ */
+ this.hasCookies = function () {
+ return '1' === hasCookies();
+ };
+
+ /**
+ * Set a first-party cookie for the duration of the session.
+ *
+ * @param string cookieName
+ * @param string cookieValue
+ * @param int msToExpire Defaults to session cookie timeout
+ */
+ this.setSessionCookie = function (cookieName, cookieValue, msToExpire) {
+ if (!cookieName) {
+ throw new Error('Missing cookie name');
+ }
+
+ if (!isDefined(msToExpire)) {
+ msToExpire = configSessionCookieTimeout;
+ }
+
+ configCookiesToDelete.push(cookieName);
+
+ setCookie(getCookieName(cookieName), cookieValue, msToExpire, configCookiePath, configCookieDomain);
+ };
+
+ /**
+ * Get first-party cookie value.
+ *
+ * Returns null if cookies are disabled or if no cookie could be found for this name.
+ *
+ * @param string cookieName
+ */
+ this.getCookie = function (cookieName) {
+ var cookieValue = getCookie(getCookieName(cookieName));
+
+ if (cookieValue === 0) {
+ return null;
+ }
+
+ return cookieValue;
+ };
+
+ /**
+ * Set first-party cookie path.
+ *
+ * @param string domain
+ */
+ this.setCookiePath = function (path) {
+ configCookiePath = path;
+ updateDomainHash();
+ };
+
+ /**
+ * Get first-party cookie path.
+ *
+ * @param string domain
+ */
+ this.getCookiePath = function (path) {
+ return configCookiePath;
+ };
+
+ /**
+ * Set visitor cookie timeout (in seconds)
+ * Defaults to 13 months (timeout=33955200)
+ *
+ * @param int timeout
+ */
+ this.setVisitorCookieTimeout = function (timeout) {
+ configVisitorCookieTimeout = timeout * 1000;
+ };
+
+ /**
+ * Set session cookie timeout (in seconds).
+ * Defaults to 30 minutes (timeout=1800)
+ *
+ * @param int timeout
+ */
+ this.setSessionCookieTimeout = function (timeout) {
+ configSessionCookieTimeout = timeout * 1000;
+ };
+
+ /**
+ * Get session cookie timeout (in seconds).
+ */
+ this.getSessionCookieTimeout = function () {
+ return configSessionCookieTimeout;
+ };
+
+ /**
+ * Set referral cookie timeout (in seconds).
+ * Defaults to 6 months (15768000000)
+ *
+ * @param int timeout
+ */
+ this.setReferralCookieTimeout = function (timeout) {
+ configReferralCookieTimeout = timeout * 1000;
+ };
+
+ /**
+ * Set conversion attribution to first referrer and campaign
+ *
+ * @param bool if true, use first referrer (and first campaign)
+ * if false, use the last referrer (or campaign)
+ */
+ this.setConversionAttributionFirstReferrer = function (enable) {
+ configConversionAttributionFirstReferrer = enable;
+ };
+
+ /**
+ * Enable the Secure cookie flag on all first party cookies.
+ * This should be used when your website is only available under HTTPS
+ * so that all tracking cookies are always sent over secure connection.
+ *
+ * @param bool
+ */
+ this.setSecureCookie = function (enable) {
+ configCookieIsSecure = enable;
+ };
+
+ /**
+ * Disables all cookies from being set
+ *
+ * Existing cookies will be deleted on the next call to track
+ */
+ this.disableCookies = function () {
+ configCookiesDisabled = true;
+ browserFeatures.cookie = '0';
+
+ if (configTrackerSiteId) {
+ deleteCookies();
+ }
+ };
+
+ /**
+ * One off cookies clearing. Useful to call this when you know for sure a new visitor is using the same browser,
+ * it maybe helps to "reset" tracking cookies to prevent data reuse for different users.
+ */
+ this.deleteCookies = function () {
+ deleteCookies();
+ };
+
+ /**
+ * Handle do-not-track requests
+ *
+ * @param bool enable If true, don't track if user agent sends 'do-not-track' header
+ */
+ this.setDoNotTrack = function (enable) {
+ var dnt = navigatorAlias.doNotTrack || navigatorAlias.msDoNotTrack;
+ configDoNotTrack = enable && (dnt === 'yes' || dnt === '1');
+
+ // do not track also disables cookies and deletes existing cookies
+ if (configDoNotTrack) {
+ this.disableCookies();
+ }
+ };
+
+ /**
+ * Add click listener to a specific link element.
+ * When clicked, Piwik will log the click automatically.
+ *
+ * @param DOMElement element
+ * @param bool enable If false, do not use pseudo click-handler (middle click + context menu)
+ */
+ this.addListener = function (element, enable) {
+ addClickListener(element, enable);
+ };
+
+ /**
+ * Install link tracker.
+ *
+ * If you change the DOM of your website or web application you need to make sure to call this method
+ * again so Piwik can detect links that were added newly.
+ *
+ * The default behaviour is to use actual click events. However, some browsers
+ * (e.g., Firefox, Opera, and Konqueror) don't generate click events for the middle mouse button.
+ *
+ * To capture more "clicks", the pseudo click-handler uses mousedown + mouseup events.
+ * This is not industry standard and is vulnerable to false positives (e.g., drag events).
+ *
+ * There is a Safari/Chrome/Webkit bug that prevents tracking requests from being sent
+ * by either click handler. The workaround is to set a target attribute (which can't
+ * be "_self", "_top", or "_parent").
+ *
+ * @see https://bugs.webkit.org/show_bug.cgi?id=54783
+ *
+ * @param bool enable Defaults to true.
+ * * If "true", use pseudo click-handler (treat middle click and open contextmenu as
+ * left click). A right click (or any click that opens the context menu) on a link
+ * will be tracked as clicked even if "Open in new tab" is not selected.
+ * * If "false" (default), nothing will be tracked on open context menu or middle click.
+ * The context menu is usually opened to open a link / download in a new tab
+ * therefore you can get more accurate results by treat it as a click but it can lead
+ * to wrong click numbers.
+ */
+ this.enableLinkTracking = function (enable) {
+ linkTrackingEnabled = true;
+
+ var self = this;
+ trackCallback(function () {
+ trackCallbackOnReady(function () {
+ addClickListeners(enable, self);
+ });
+ });
+ };
+
+ /**
+ * Enable tracking of uncatched JavaScript errors
+ *
+ * If enabled, uncaught JavaScript Errors will be tracked as an event by defining a
+ * window.onerror handler. If a window.onerror handler is already defined we will make
+ * sure to call this previously registered error handler after tracking the error.
+ *
+ * By default we return false in the window.onerror handler to make sure the error still
+ * appears in the browser's console etc. Note: Some older browsers might behave differently
+ * so it could happen that an actual JavaScript error will be suppressed.
+ * If a window.onerror handler was registered we will return the result of this handler.
+ *
+ * Make sure not to overwrite the window.onerror handler after enabling the JS error
+ * tracking as the error tracking won't work otherwise. To capture all JS errors we
+ * recommend to include the Piwik JavaScript tracker in the HTML as early as possible.
+ * If possible directly in <head></head> before loading any other JavaScript.
+ */
+ this.enableJSErrorTracking = function () {
+ if (enableJSErrorTracking) {
+ return;
+ }
+
+ enableJSErrorTracking = true;
+ var onError = windowAlias.onerror;
+
+ windowAlias.onerror = function (message, url, linenumber, column, error) {
+ trackCallback(function () {
+ var category = 'JavaScript Errors';
+
+ var action = url + ':' + linenumber;
+ if (column) {
+ action += ':' + column;
+ }
+
+ logEvent(category, action, message);
+ });
+
+ if (onError) {
+ return onError(message, url, linenumber, column, error);
+ }
+
+ return false;
+ };
+ };
+
+ /**
+ * Disable automatic performance tracking
+ */
+ this.disablePerformanceTracking = function () {
+ configPerformanceTrackingEnabled = false;
+ };
+
+ /**
+ * Set the server generation time.
+ * If set, the browser's performance.timing API in not used anymore to determine the time.
+ *
+ * @param int generationTime
+ */
+ this.setGenerationTimeMs = function (generationTime) {
+ configPerformanceGenerationTime = parseInt(generationTime, 10);
+ };
+
+ /**
+ * Set heartbeat (in seconds)
+ *
+ * @param int heartBeatDelayInSeconds Defaults to 15. Cannot be lower than 1.
+ */
+ this.enableHeartBeatTimer = function (heartBeatDelayInSeconds) {
+ heartBeatDelayInSeconds = Math.max(heartBeatDelayInSeconds, 1);
+ configHeartBeatDelay = (heartBeatDelayInSeconds || 15) * 1000;
+
+ // if a tracking request has already been sent, start the heart beat timeout
+ if (lastTrackerRequestTime !== null) {
+ setUpHeartBeat();
+ }
+ };
+
+ /**
+ * Disable heartbeat if it was previously activated.
+ */
+ this.disableHeartBeatTimer = function () {
+ heartBeatDown();
+
+ if (configHeartBeatDelay || heartBeatSetUp) {
+ if (windowAlias.removeEventListener) {
+ windowAlias.removeEventListener('focus', heartBeatOnFocus, true);
+ windowAlias.removeEventListener('blur', heartBeatOnBlur, true);
+ } else if (windowAlias.detachEvent) {
+ windowAlias.detachEvent('onfocus', heartBeatOnFocus);
+ windowAlias.detachEvent('onblur', heartBeatOnBlur);
+ }
+ }
+
+ configHeartBeatDelay = null;
+ heartBeatSetUp = false;
+ };
+
+ /**
+ * Frame buster
+ */
+ this.killFrame = function () {
+ if (windowAlias.location !== windowAlias.top.location) {
+ windowAlias.top.location = windowAlias.location;
+ }
+ };
+
+ /**
+ * Redirect if browsing offline (aka file: buster)
+ *
+ * @param string url Redirect to this URL
+ */
+ this.redirectFile = function (url) {
+ if (windowAlias.location.protocol === 'file:') {
+ windowAlias.location = url;
+ }
+ };
+
+ /**
+ * Count sites in pre-rendered state
+ *
+ * @param bool enable If true, track when in pre-rendered state
+ */
+ this.setCountPreRendered = function (enable) {
+ configCountPreRendered = enable;
+ };
+
+ /**
+ * Trigger a goal
+ *
+ * @param int|string idGoal
+ * @param int|float customRevenue
+ * @param mixed customData
+ */
+ this.trackGoal = function (idGoal, customRevenue, customData) {
+ trackCallback(function () {
+ logGoal(idGoal, customRevenue, customData);
+ });
+ };
+
+ /**
+ * Manually log a click from your own code
+ *
+ * @param string sourceUrl
+ * @param string linkType
+ * @param mixed customData
+ * @param function callback
+ */
+ this.trackLink = function (sourceUrl, linkType, customData, callback) {
+ trackCallback(function () {
+ logLink(sourceUrl, linkType, customData, callback);
+ });
+ };
+
+ /**
+ * Get the number of page views that have been tracked so far within the currently loaded page.
+ */
+ this.getNumTrackedPageViews = function () {
+ return numTrackedPageviews;
+ };
+
+ /**
+ * Log visit to this page
+ *
+ * @param string customTitle
+ * @param mixed customData
+ * @param function callback
+ */
+ this.trackPageView = function (customTitle, customData, callback) {
+ trackedContentImpressions = [];
+ consentRequestsQueue = [];
+
+ if (isOverlaySession(configTrackerSiteId)) {
+ trackCallback(function () {
+ injectOverlayScripts(configTrackerUrl, configApiUrl, configTrackerSiteId);
+ });
+ } else {
+ trackCallback(function () {
+ numTrackedPageviews++;
+ logPageView(customTitle, customData, callback);
+ });
+ }
+ };
+
+ /**
+ * Scans the entire DOM for all content blocks and tracks all impressions once the DOM ready event has
+ * been triggered.
+ *
+ * If you only want to track visible content impressions have a look at `trackVisibleContentImpressions()`.
+ * We do not track an impression of the same content block twice if you call this method multiple times
+ * unless `trackPageView()` is called meanwhile. This is useful for single page applications.
+ */
+ this.trackAllContentImpressions = function () {
+ if (isOverlaySession(configTrackerSiteId)) {
+ return;
+ }
+
+ trackCallback(function () {
+ trackCallbackOnReady(function () {
+ // we have to wait till DOM ready
+ var contentNodes = content.findContentNodes();
+ var requests = getContentImpressionsRequestsFromNodes(contentNodes);
+
+ sendBulkRequest(requests, configTrackerPause);
+ });
+ });
+ };
+
+ /**
+ * Scans the entire DOM for all content blocks as soon as the page is loaded. It tracks an impression
+ * only if a content block is actually visible. Meaning it is not hidden and the content is or was at
+ * some point in the viewport.
+ *
+ * If you want to track all content blocks have a look at `trackAllContentImpressions()`.
+ * We do not track an impression of the same content block twice if you call this method multiple times
+ * unless `trackPageView()` is called meanwhile. This is useful for single page applications.
+ *
+ * Once you have called this method you can no longer change `checkOnScroll` or `timeIntervalInMs`.
+ *
+ * If you do want to only track visible content blocks but not want us to perform any automatic checks
+ * as they can slow down your frames per second you can call `trackVisibleContentImpressions()` or
+ * `trackContentImpressionsWithinNode()` manually at any time to rescan the entire DOM for newly
+ * visible content blocks.
+ * o Call `trackVisibleContentImpressions(false, 0)` to initially track only visible content impressions
+ * o Call `trackVisibleContentImpressions()` at any time again to rescan the entire DOM for newly visible content blocks or
+ * o Call `trackContentImpressionsWithinNode(node)` at any time to rescan only a part of the DOM for newly visible content blocks
+ *
+ * @param boolean [checkOnScroll=true] Optional, you can disable rescanning the entire DOM automatically
+ * after each scroll event by passing the value `false`. If enabled,
+ * we check whether a previously hidden content blocks became visible
+ * after a scroll and if so track the impression.
+ * Note: If a content block is placed within a scrollable element
+ * (`overflow: scroll`), we can currently not detect when this block
+ * becomes visible.
+ * @param integer [timeIntervalInMs=750] Optional, you can define an interval to rescan the entire DOM
+ * for new impressions every X milliseconds by passing
+ * for instance `timeIntervalInMs=500` (rescan DOM every 500ms).
+ * Rescanning the entire DOM and detecting the visible state of content
+ * blocks can take a while depending on the browser and amount of content.
+ * In case your frames per second goes down you might want to increase
+ * this value or disable it by passing the value `0`.
+ */
+ this.trackVisibleContentImpressions = function (checkOnScroll, timeIntervalInMs) {
+ if (isOverlaySession(configTrackerSiteId)) {
+ return;
+ }
+
+ if (!isDefined(checkOnScroll)) {
+ checkOnScroll = true;
+ }
+
+ if (!isDefined(timeIntervalInMs)) {
+ timeIntervalInMs = 750;
+ }
+
+ enableTrackOnlyVisibleContent(checkOnScroll, timeIntervalInMs, this);
+
+ trackCallback(function () {
+ trackCallbackOnLoad(function () {
+ // we have to wait till CSS parsed and applied
+ var contentNodes = content.findContentNodes();
+ var requests = getCurrentlyVisibleContentImpressionsRequestsIfNotTrackedYet(contentNodes);
+
+ sendBulkRequest(requests, configTrackerPause);
+ });
+ });
+ };
+
+ /**
+ * Tracks a content impression using the specified values. You should not call this method too often
+ * as each call causes an XHR tracking request and can slow down your site or your server.
+ *
+ * @param string contentName For instance "Ad Sale".
+ * @param string [contentPiece='Unknown'] For instance a path to an image or the text of a text ad.
+ * @param string [contentTarget] For instance the URL of a landing page.
+ */
+ this.trackContentImpression = function (contentName, contentPiece, contentTarget) {
+ if (isOverlaySession(configTrackerSiteId)) {
+ return;
+ }
+
+ contentName = trim(contentName);
+ contentPiece = trim(contentPiece);
+ contentTarget = trim(contentTarget);
+
+ if (!contentName) {
+ return;
+ }
+
+ contentPiece = contentPiece || 'Unknown';
+
+ trackCallback(function () {
+ var request = buildContentImpressionRequest(contentName, contentPiece, contentTarget);
+ sendRequest(request, configTrackerPause);
+ });
+ };
+
+ /**
+ * Scans the given DOM node and its children for content blocks and tracks an impression for them if
+ * no impression was already tracked for it. If you have called `trackVisibleContentImpressions()`
+ * upfront only visible content blocks will be tracked. You can use this method if you, for instance,
+ * dynamically add an element using JavaScript to your DOM after we have tracked the initial impressions.
+ *
+ * @param Element domNode
+ */
+ this.trackContentImpressionsWithinNode = function (domNode) {
+ if (isOverlaySession(configTrackerSiteId) || !domNode) {
+ return;
+ }
+
+ trackCallback(function () {
+ if (isTrackOnlyVisibleContentEnabled) {
+ trackCallbackOnLoad(function () {
+ // we have to wait till CSS parsed and applied
+ var contentNodes = content.findContentNodesWithinNode(domNode);
+
+ var requests = getCurrentlyVisibleContentImpressionsRequestsIfNotTrackedYet(contentNodes);
+ sendBulkRequest(requests, configTrackerPause);
+ });
+ } else {
+ trackCallbackOnReady(function () {
+ // we have to wait till DOM ready
+ var contentNodes = content.findContentNodesWithinNode(domNode);
+
+ var requests = getContentImpressionsRequestsFromNodes(contentNodes);
+ sendBulkRequest(requests, configTrackerPause);
+ });
+ }
+ });
+ };
+
+ /**
+ * Tracks a content interaction using the specified values. You should use this method only in conjunction
+ * with `trackContentImpression()`. The specified `contentName` and `contentPiece` has to be exactly the
+ * same as the ones that were used in `trackContentImpression()`. Otherwise the interaction will not count.
+ *
+ * @param string contentInteraction The type of interaction that happened. For instance 'click' or 'submit'.
+ * @param string contentName The name of the content. For instance "Ad Sale".
+ * @param string [contentPiece='Unknown'] The actual content. For instance a path to an image or the text of a text ad.
+ * @param string [contentTarget] For instance the URL of a landing page.
+ */
+ this.trackContentInteraction = function (contentInteraction, contentName, contentPiece, contentTarget) {
+ if (isOverlaySession(configTrackerSiteId)) {
+ return;
+ }
+
+ contentInteraction = trim(contentInteraction);
+ contentName = trim(contentName);
+ contentPiece = trim(contentPiece);
+ contentTarget = trim(contentTarget);
+
+ if (!contentInteraction || !contentName) {
+ return;
+ }
+
+ contentPiece = contentPiece || 'Unknown';
+
+ trackCallback(function () {
+ var request = buildContentInteractionRequest(contentInteraction, contentName, contentPiece, contentTarget);
+ sendRequest(request, configTrackerPause);
+ });
+ };
+
+ /**
+ * Tracks an interaction with the given DOM node / content block.
+ *
+ * By default we track interactions on click but sometimes you might want to track interactions yourself.
+ * For instance you might want to track an interaction manually on a double click or a form submit.
+ * Make sure to disable the automatic interaction tracking in this case by specifying either the CSS
+ * class `piwikContentIgnoreInteraction` or the attribute `data-content-ignoreinteraction`.
+ *
+ * @param Element domNode This element itself or any of its parent elements has to be a content block
+ * element. Meaning one of those has to have a `piwikTrackContent` CSS class or
+ * a `data-track-content` attribute.
+ * @param string [contentInteraction='Unknown] The name of the interaction that happened. For instance
+ * 'click', 'formSubmit', 'DblClick', ...
+ */
+ this.trackContentInteractionNode = function (domNode, contentInteraction) {
+ if (isOverlaySession(configTrackerSiteId) || !domNode) {
+ return;
+ }
+
+ trackCallback(function () {
+ var request = buildContentInteractionRequestNode(domNode, contentInteraction);
+ sendRequest(request, configTrackerPause);
+ });
+ };
+
+ /**
+ * Useful to debug content tracking. This method will log all detected content blocks to console
+ * (if the browser supports the console). It will list the detected name, piece, and target of each
+ * content block.
+ */
+ this.logAllContentBlocksOnPage = function () {
+ var contentNodes = content.findContentNodes();
+ var contents = content.collectContent(contentNodes);
+
+ // needed to write it this way for jslint
+ var consoleType = typeof console;
+ if (consoleType !== 'undefined' && console && console.log) {
+ console.log(contents);
+ }
+ };
+
+ /**
+ * Records an event
+ *
+ * @param string category The Event Category (Videos, Music, Games...)
+ * @param string action The Event's Action (Play, Pause, Duration, Add Playlist, Downloaded, Clicked...)
+ * @param string name (optional) The Event's object Name (a particular Movie name, or Song name, or File name...)
+ * @param float value (optional) The Event's value
+ * @param function callback
+ * @param mixed customData
+ */
+ this.trackEvent = function (category, action, name, value, customData, callback) {
+ trackCallback(function () {
+ logEvent(category, action, name, value, customData, callback);
+ });
+ };
+
+ /**
+ * Log special pageview: Internal search
+ *
+ * @param string keyword
+ * @param string category
+ * @param int resultsCount
+ * @param mixed customData
+ */
+ this.trackSiteSearch = function (keyword, category, resultsCount, customData) {
+ trackCallback(function () {
+ logSiteSearch(keyword, category, resultsCount, customData);
+ });
+ };
+
+ /**
+ * Used to record that the current page view is an item (product) page view, or a Ecommerce Category page view.
+ * This must be called before trackPageView() on the product/category page.
+ * It will set 3 custom variables of scope "page" with the SKU, Name and Category for this page view.
+ * Note: Custom Variables of scope "page" slots 3, 4 and 5 will be used.
+ *
+ * On a category page, you can set the parameter category, and set the other parameters to empty string or false
+ *
+ * Tracking Product/Category page views will allow Piwik to report on Product & Categories
+ * conversion rates (Conversion rate = Ecommerce orders containing this product or category / Visits to the product or category)
+ *
+ * @param string sku Item's SKU code being viewed
+ * @param string name Item's Name being viewed
+ * @param string category Category page being viewed. On an Item's page, this is the item's category
+ * @param float price Item's display price, not use in standard Piwik reports, but output in API product reports.
+ */
+ this.setEcommerceView = function (sku, name, category, price) {
+ if (!isDefined(category) || !category.length) {
+ category = "";
+ } else if (category instanceof Array) {
+ category = JSON_PIWIK.stringify(category);
+ }
+
+ customVariablesPage[5] = ['_pkc', category];
+
+ if (isDefined(price) && String(price).length) {
+ customVariablesPage[2] = ['_pkp', price];
+ }
+
+ // On a category page, do not track Product name not defined
+ if ((!isDefined(sku) || !sku.length)
+ && (!isDefined(name) || !name.length)) {
+ return;
+ }
+
+ if (isDefined(sku) && sku.length) {
+ customVariablesPage[3] = ['_pks', sku];
+ }
+
+ if (!isDefined(name) || !name.length) {
+ name = "";
+ }
+
+ customVariablesPage[4] = ['_pkn', name];
+ };
+
+ /**
+ * Adds an item (product) that is in the current Cart or in the Ecommerce order.
+ * This function is called for every item (product) in the Cart or the Order.
+ * The only required parameter is sku.
+ * The items are deleted from this JavaScript object when the Ecommerce order is tracked via the method trackEcommerceOrder.
+ *
+ * If there is already a saved item for the given sku, it will be updated with the
+ * new information.
+ *
+ * @param string sku (required) Item's SKU Code. This is the unique identifier for the product.
+ * @param string name (optional) Item's name
+ * @param string name (optional) Item's category, or array of up to 5 categories
+ * @param float price (optional) Item's price. If not specified, will default to 0
+ * @param float quantity (optional) Item's quantity. If not specified, will default to 1
+ */
+ this.addEcommerceItem = function (sku, name, category, price, quantity) {
+ if (sku.length) {
+ ecommerceItems[sku] = [ sku, name, category, price, quantity ];
+ }
+ };
+
+ /**
+ * Removes a single ecommerce item by SKU from the current cart.
+ *
+ * @param string sku (required) Item's SKU Code. This is the unique identifier for the product.
+ */
+ this.removeEcommerceItem = function (sku) {
+ if (sku.length) {
+ delete ecommerceItems[sku];
+ }
+ };
+
+ /**
+ * Clears the current cart, removing all saved ecommerce items. Call this method to manually clear
+ * the cart before sending an ecommerce order.
+ */
+ this.clearEcommerceCart = function () {
+ ecommerceItems = {};
+ };
+
+ /**
+ * Tracks an Ecommerce order.
+ * If the Ecommerce order contains items (products), you must call first the addEcommerceItem() for each item in the order.
+ * All revenues (grandTotal, subTotal, tax, shipping, discount) will be individually summed and reported in Piwik reports.
+ * Parameters orderId and grandTotal are required. For others, you can set to false if you don't need to specify them.
+ * After calling this method, items added to the cart will be removed from this JavaScript object.
+ *
+ * @param string|int orderId (required) Unique Order ID.
+ * This will be used to count this order only once in the event the order page is reloaded several times.
+ * orderId must be unique for each transaction, even on different days, or the transaction will not be recorded by Piwik.
+ * @param float grandTotal (required) Grand Total revenue of the transaction (including tax, shipping, etc.)
+ * @param float subTotal (optional) Sub total amount, typically the sum of items prices for all items in this order (before Tax and Shipping costs are applied)
+ * @param float tax (optional) Tax amount for this order
+ * @param float shipping (optional) Shipping amount for this order
+ * @param float discount (optional) Discounted amount in this order
+ */
+ this.trackEcommerceOrder = function (orderId, grandTotal, subTotal, tax, shipping, discount) {
+ logEcommerceOrder(orderId, grandTotal, subTotal, tax, shipping, discount);
+ };
+
+ /**
+ * Tracks a Cart Update (add item, remove item, update item).
+ * On every Cart update, you must call addEcommerceItem() for each item (product) in the cart, including the items that haven't been updated since the last cart update.
+ * Then you can call this function with the Cart grandTotal (typically the sum of all items' prices)
+ * Calling this method does not remove from this JavaScript object the items that were added to the cart via addEcommerceItem
+ *
+ * @param float grandTotal (required) Items (products) amount in the Cart
+ */
+ this.trackEcommerceCartUpdate = function (grandTotal) {
+ logEcommerceCartUpdate(grandTotal);
+ };
+
+ /**
+ * Sends a tracking request with custom request parameters.
+ * Piwik will prepend the hostname and path to Piwik, as well as all other needed tracking request
+ * parameters prior to sending the request. Useful eg if you track custom dimensions via a plugin.
+ *
+ * @param request eg. "param=value&param2=value2"
+ * @param customData
+ * @param callback
+ * @param pluginMethod
+ */
+ this.trackRequest = function (request, customData, callback, pluginMethod) {
+ trackCallback(function () {
+ var fullRequest = getRequest(request, customData, pluginMethod);
+ sendRequest(fullRequest, configTrackerPause, callback);
+ });
+ };
+
+ /**
+ * If the user has given consent previously and this consent was remembered, it will return the number
+ * in milliseconds since 1970/01/01 which is the date when the user has given consent. Please note that
+ * the returned time depends on the users local time which may not always be correct.
+ *
+ * @returns number|string
+ */
+ this.getRememberedConsent = function () {
+ var value = getCookie(CONSENT_COOKIE_NAME);
+ if (getCookie(CONSENT_REMOVED_COOKIE_NAME)) {
+ // if for some reason the consent_removed cookie is also set with the consent cookie, the
+ // consent_removed cookie overrides the consent one, and we make sure to delete the consent
+ // cookie.
+ if (value) {
+ deleteCookie(CONSENT_COOKIE_NAME, configCookiePath, configCookieDomain);
+ }
+ return null;
+ }
+
+ if (!value || value === 0) {
+ return null;
+ }
+ return value;
+ };
+
+ /**
+ * Detects whether the user has given consent previously.
+ *
+ * @returns bool
+ */
+ this.hasRememberedConsent = function () {
+ return !!this.getRememberedConsent();
+ };
+
+ /**
+ * When called, no tracking request will be sent to the Matomo server until you have called `setConsentGiven()`
+ * unless consent was given previously AND you called {@link rememberConsentGiven()} when the user gave her
+ * or his consent.
+ *
+ * This may be useful when you want to implement for example a popup to ask for consent before tracking the user.
+ * Once the user has given consent, you should call {@link setConsentGiven()} or {@link rememberConsentGiven()}.
+ *
+ * Please note that when consent is required, we will temporarily set cookies but not track any data. Those
+ * cookies will only exist during this page view and deleted as soon as the user navigates to a different page
+ * or closes the browser.
+ *
+ * If you require consent for tracking personal data for example, you should first call
+ * `_paq.push(['requireConsent'])`.
+ *
+ * If the user has already given consent in the past, you can either decide to not call `requireConsent` at all
+ * or call `_paq.push(['setConsentGiven'])` on each page view at any time after calling `requireConsent`.
+ *
+ * When the user gives you the consent to track data, you can also call `_paq.push(['rememberConsentGiven', optionalTimeoutInHours])`
+ * and for the duration while the consent is remembered, any call to `requireConsent` will be automatically ignored until you call `forgetConsentGiven`.
+ * `forgetConsentGiven` needs to be called when the user removes consent for tracking. This means if you call `rememberConsentGiven` at the
+ * time the user gives you consent, you do not need to ever call `_paq.push(['setConsentGiven'])`.
+ */
+ this.requireConsent = function () {
+ configConsentRequired = true;
+ configHasConsent = this.hasRememberedConsent();
+ // Piwik.addPlugin might not be defined at this point, we add the plugin directly also to make JSLint happy
+ // We also want to make sure to define an unload listener for each tracker, not only one tracker.
+ coreConsentCounter++;
+ plugins['CoreConsent' + coreConsentCounter] = {
+ unload: function () {
+ if (!configHasConsent) {
+ // we want to make sure to remove all previously set cookies again
+ deleteCookies();
+ }
+ }
+ };
+ };
+
+ /**
+ * Call this method once the user has given consent. This will cause all tracking requests from this
+ * page view to be sent. Please note that the given consent won't be remembered across page views. If you
+ * want to remember consent across page views, call {@link rememberConsentGiven()} instead.
+ */
+ this.setConsentGiven = function () {
+ configHasConsent = true;
+ deleteCookie(CONSENT_REMOVED_COOKIE_NAME, configCookiePath, configCookieDomain);
+ var i, requestType;
+ for (i = 0; i < consentRequestsQueue.length; i++) {
+ requestType = typeof consentRequestsQueue[i];
+ if (requestType === 'string') {
+ sendRequest(consentRequestsQueue[i], configTrackerPause);
+ } else if (requestType === 'object') {
+ sendBulkRequest(consentRequestsQueue[i], configTrackerPause);
+ }
+ }
+ consentRequestsQueue = [];
+ };
+
+ /**
+ * Calling this method will remember that the user has given consent across multiple requests by setting
+ * a cookie. You can optionally define the lifetime of that cookie in milliseconds using a parameter.
+ *
+ * When you call this method, we imply that the user has given consent for this page view, and will also
+ * imply consent for all future page views unless the cookie expires (if timeout defined) or the user
+ * deletes all her or his cookies. This means even if you call {@link requireConsent()}, then all requests
+ * will still be tracked.
+ *
+ * Please note that this feature requires you to set the `cookieDomain` and `cookiePath` correctly and requires
+ * that you do not disable cookies. Please also note that when you call this method, consent will be implied
+ * for all sites that match the configured cookieDomain and cookiePath. Depending on your website structure,
+ * you may need to restrict or widen the scope of the cookie domain/path to ensure the consent is applied
+ * to the sites you want.
+ */
+ this.rememberConsentGiven = function (hoursToExpire) {
+ if (configCookiesDisabled) {
+ logConsoleError('rememberConsentGiven is called but cookies are disabled, consent will not be remembered');
+ return;
+ }
+ if (hoursToExpire) {
+ hoursToExpire = hoursToExpire * 60 * 60 * 1000;
+ }
+ this.setConsentGiven();
+ var now = new Date().getTime();
+ setCookie(CONSENT_COOKIE_NAME, now, hoursToExpire, configCookiePath, configCookieDomain, configCookieIsSecure);
+ };
+
+ /**
+ * Calling this method will remove any previously given consent and during this page view no request
+ * will be sent anymore ({@link requireConsent()}) will be called automatically to ensure the removed
+ * consent will be enforced. You may call this method if the user removes consent manually, or if you
+ * want to re-ask for consent after a specific time period.
+ */
+ this.forgetConsentGiven = function () {
+ if (configCookiesDisabled) {
+ logConsoleError('forgetConsentGiven is called but cookies are disabled, consent will not be forgotten');
+ return;
+ }
+
+ deleteCookie(CONSENT_COOKIE_NAME, configCookiePath, configCookieDomain);
+ setCookie(CONSENT_REMOVED_COOKIE_NAME, new Date().getTime(), 0, configCookiePath, configCookieDomain, configCookieIsSecure);
+ this.requireConsent();
+ };
+
+ /**
+ * Returns true if user is opted out, false if otherwise.
+ *
+ * @returns {boolean}
+ */
+ this.isUserOptedOut = function () {
+ return !configHasConsent;
+ };
+
+ /**
+ * Alias for forgetConsentGiven(). After calling this function, the user will no longer be tracked,
+ * (even if they come back to the site).
+ */
+ this.optUserOut = this.forgetConsentGiven;
+
+ /**
+ * Alias for rememberConsentGiven(). After calling this function, the current user will be tracked.
+ */
+ this.forgetUserOptOut = this.rememberConsentGiven;
+
+ Piwik.trigger('TrackerSetup', [this]);
+ }
+
+ function TrackerProxy() {
+ return {
+ push: apply
+ };
+ }
+
+ /**
+ * Applies the given methods in the given order if they are present in paq.
+ *
+ * @param {Array} paq
+ * @param {Array} methodsToApply an array containing method names in the order that they should be applied
+ * eg ['setSiteId', 'setTrackerUrl']
+ * @returns {Array} the modified paq array with the methods that were already applied set to undefined
+ */
+ function applyMethodsInOrder(paq, methodsToApply)
+ {
+ var appliedMethods = {};
+ var index, iterator;
+
+ for (index = 0; index < methodsToApply.length; index++) {
+ var methodNameToApply = methodsToApply[index];
+ appliedMethods[methodNameToApply] = 1;
+
+ for (iterator = 0; iterator < paq.length; iterator++) {
+ if (paq[iterator] && paq[iterator][0]) {
+ var methodName = paq[iterator][0];
+
+ if (methodNameToApply === methodName) {
+ apply(paq[iterator]);
+ delete paq[iterator];
+
+ if (appliedMethods[methodName] > 1
+ && methodName !== "addTracker") {
+ logConsoleError('The method ' + methodName + ' is registered more than once in "_paq" variable. Only the last call has an effect. Please have a look at the multiple Piwik trackers documentation: https://developer.piwik.org/guides/tracking-javascript-guide#multiple-piwik-trackers');
+ }
+
+ appliedMethods[methodName]++;
+ }
+ }
+ }
+ }
+
+ return paq;
+ }
+
+ /************************************************************
+ * Constructor
+ ************************************************************/
+
+ var applyFirst = ['addTracker', 'disableCookies', 'setTrackerUrl', 'setAPIUrl', 'enableCrossDomainLinking', 'setCrossDomainLinkingTimeout', 'setSecureCookie', 'setCookiePath', 'setCookieDomain', 'setDomains', 'setUserId', 'setSiteId', 'enableLinkTracking', 'requireConsent', 'setConsentGiven'];
+
+ function createFirstTracker(piwikUrl, siteId)
+ {
+ var tracker = new Tracker(piwikUrl, siteId);
+ asyncTrackers.push(tracker);
+
+ _paq = applyMethodsInOrder(_paq, applyFirst);
+
+ // apply the queue of actions
+ for (iterator = 0; iterator < _paq.length; iterator++) {
+ if (_paq[iterator]) {
+ apply(_paq[iterator]);
+ }
+ }
+
+ // replace initialization array with proxy object
+ _paq = new TrackerProxy();
+
+ return tracker;
+ }
+
+ /************************************************************
+ * Proxy object
+ * - this allows the caller to continue push()'ing to _paq
+ * after the Tracker has been initialized and loaded
+ ************************************************************/
+
+ // initialize the Piwik singleton
+ addEventListener(windowAlias, 'beforeunload', beforeUnloadHandler, false);
+
+ Date.prototype.getTimeAlias = Date.prototype.getTime;
+
+ /************************************************************
+ * Public data and methods
+ ************************************************************/
+
+ Piwik = {
+ initialized: false,
+
+ JSON: JSON_PIWIK,
+
+ /**
+ * DOM Document related methods
+ */
+ DOM: {
+ /**
+ * Adds an event listener to the given element.
+ * @param element
+ * @param eventType
+ * @param eventHandler
+ * @param useCapture Optional
+ */
+ addEventListener: function (element, eventType, eventHandler, useCapture) {
+ var captureType = typeof useCapture;
+ if (captureType === 'undefined') {
+ useCapture = false;
+ }
+
+ addEventListener(element, eventType, eventHandler, useCapture);
+ },
+ /**
+ * Specify a function to execute when the DOM is fully loaded.
+ *
+ * If the DOM is already loaded, the function will be executed immediately.
+ *
+ * @param function callback
+ */
+ onLoad: trackCallbackOnLoad,
+
+ /**
+ * Specify a function to execute when the DOM is ready.
+ *
+ * If the DOM is already ready, the function will be executed immediately.
+ *
+ * @param function callback
+ */
+ onReady: trackCallbackOnReady,
+
+ /**
+ * Detect whether a node is visible right now.
+ */
+ isNodeVisible: isVisible,
+
+ /**
+ * Detect whether a node has been visible at some point
+ */
+ isOrWasNodeVisible: content.isNodeVisible
+ },
+
+ /**
+ * Listen to an event and invoke the handler when a the event is triggered.
+ *
+ * @param string event
+ * @param function handler
+ */
+ on: function (event, handler) {
+ if (!eventHandlers[event]) {
+ eventHandlers[event] = [];
+ }
+
+ eventHandlers[event].push(handler);
+ },
+
+ /**
+ * Remove a handler to no longer listen to the event. Must pass the same handler that was used when
+ * attaching the event via ".on".
+ * @param string event
+ * @param function handler
+ */
+ off: function (event, handler) {
+ if (!eventHandlers[event]) {
+ return;
+ }
+
+ var i = 0;
+ for (i; i < eventHandlers[event].length; i++) {
+ if (eventHandlers[event][i] === handler) {
+ eventHandlers[event].splice(i, 1);
+ }
+ }
+ },
+
+ /**
+ * Triggers the given event and passes the parameters to all handlers.
+ *
+ * @param string event
+ * @param Array extraParameters
+ * @param Object context If given the handler will be executed in this context
+ */
+ trigger: function (event, extraParameters, context) {
+ if (!eventHandlers[event]) {
+ return;
+ }
+
+ var i = 0;
+ for (i; i < eventHandlers[event].length; i++) {
+ eventHandlers[event][i].apply(context || windowAlias, extraParameters);
+ }
+ },
+
+ /**
+ * Add plugin
+ *
+ * @param string pluginName
+ * @param Object pluginObj
+ */
+ addPlugin: function (pluginName, pluginObj) {
+ plugins[pluginName] = pluginObj;
+ },
+
+ /**
+ * Get Tracker (factory method)
+ *
+ * @param string piwikUrl
+ * @param int|string siteId
+ * @return Tracker
+ */
+ getTracker: function (piwikUrl, siteId) {
+ if (!isDefined(siteId)) {
+ siteId = this.getAsyncTracker().getSiteId();
+ }
+ if (!isDefined(piwikUrl)) {
+ piwikUrl = this.getAsyncTracker().getTrackerUrl();
+ }
+
+ return new Tracker(piwikUrl, siteId);
+ },
+
+ /**
+ * Get all created async trackers
+ *
+ * @return Tracker[]
+ */
+ getAsyncTrackers: function () {
+ return asyncTrackers;
+ },
+
+ /**
+ * Adds a new tracker. All sent requests will be also sent to the given siteId and piwikUrl.
+ * If piwikUrl is not set, current url will be used.
+ *
+ * @param null|string piwikUrl If null, will reuse the same tracker URL of the current tracker instance
+ * @param int|string siteId
+ * @return Tracker
+ */
+ addTracker: function (piwikUrl, siteId) {
+ var tracker;
+ if (!asyncTrackers.length) {
+ tracker = createFirstTracker(piwikUrl, siteId);
+ } else {
+ tracker = asyncTrackers[0].addTracker(piwikUrl, siteId);
+ }
+ return tracker;
+ },
+
+ /**
+ * Get internal asynchronous tracker object.
+ *
+ * If no parameters are given, it returns the internal asynchronous tracker object. If a piwikUrl and idSite
+ * is given, it will try to find an optional
+ *
+ * @param string piwikUrl
+ * @param int|string siteId
+ * @return Tracker
+ */
+ getAsyncTracker: function (piwikUrl, siteId) {
+
+ var firstTracker;
+ if (asyncTrackers && asyncTrackers.length && asyncTrackers[0]) {
+ firstTracker = asyncTrackers[0];
+ } else {
+ return createFirstTracker(piwikUrl, siteId);
+ }
+
+ if (!siteId && !piwikUrl) {
+ // for BC and by default we just return the initially created tracker
+ return firstTracker;
+ }
+
+ // we look for another tracker created via `addTracker` method
+ if ((!isDefined(siteId) || null === siteId) && firstTracker) {
+ siteId = firstTracker.getSiteId();
+ }
+
+ if ((!isDefined(piwikUrl) || null === piwikUrl) && firstTracker) {
+ piwikUrl = firstTracker.getTrackerUrl();
+ }
+
+ var tracker, i = 0;
+ for (i; i < asyncTrackers.length; i++) {
+ tracker = asyncTrackers[i];
+ if (tracker
+ && String(tracker.getSiteId()) === String(siteId)
+ && tracker.getTrackerUrl() === piwikUrl) {
+
+ return tracker;
+ }
+ }
+ },
+
+ /**
+ * When calling plugin methods via "_paq.push(['...'])" and the plugin is loaded separately because
+ * matomo.js is not writable then there is a chance that first matomo.js is loaded and later the plugin.
+ * In this case we would have already executed all "_paq.push" methods and they would not have succeeded
+ * because the plugin will be loaded only later. In this case, once a plugin is loaded, it should call
+ * "Piwik.retryMissedPluginCalls()" so they will be executed after all.
+ *
+ * @param string piwikUrl
+ * @param int|string siteId
+ * @return Tracker
+ */
+ retryMissedPluginCalls: function () {
+ var missedCalls = missedPluginTrackerCalls;
+ missedPluginTrackerCalls = [];
+ var i = 0;
+ for (i; i < missedCalls.length; i++) {
+ apply(missedCalls[i]);
+ }
+ }
+ };
+
+ // Expose Piwik as an AMD module
+ if (typeof define === 'function' && define.amd) {
+ define('piwik', [], function () { return Piwik; });
+ define('matomo', [], function () { return Piwik; });
+ }
+
+ return Piwik;
+ }());
+}
+
+/*!!! pluginTrackerHook */
+
+/* GENERATED: tracker.js */
+/*!
+ * Copyright (C) InnoCraft Ltd - All rights reserved.
+ *
+ * All information contained herein is, and remains the property of InnoCraft Ltd.
+ *
+ * @link https://www.innocraft.com/
+ * @license For license details see https://www.innocraft.com/license
+ */
+
+/**
+ * To minify this version call
+ * cat tracker.js | java -jar ../../js/yuicompressor-2.4.8.jar --type js --line-break 1000 | sed 's/^[/][*]/\/*!/' > tracker.min.js
+ */
+
+(function () {
+ var timeWhenScriptLoaded = new Date().getTime();
+ var timeFirstTrackingRequest = null;
+ var debugMode = false;
+ var pingIntervalInSeconds = 10;
+ var usesCustomInterval = false;
+ var isMediaTrackingEnabled = true;
+ var customPiwikTrackers = null;
+ var stopTrackingAfterXMs = 1000 * 60 * 60 * 3; // we stop after 3 hours
+ var documentAlias = document;
+ var windowAlias = window;
+ var numMediaPlaysTotal = 0;
+ var numMediaPlaysTotalOffScreen = 0;
+
+ var mediaTitleFallback = function (){
+ return '';
+ };
+
+ var mediaTrackerInstances = [];
+
+ function getJson()
+ {
+ if (typeof Piwik === 'object' && typeof Piwik.JSON === 'object') {
+ return Piwik.JSON;
+ } else if (windowAlias.JSON && windowAlias.JSON.parse && windowAlias.JSON.stringify) {
+ return windowAlias.JSON;
+ } else if (typeof windowAlias.JSON2 === 'object' && windowAlias.JSON2.parse && windowAlias.JSON2.stringify) {
+ return windowAlias.JSON2;
+ } else {
+ return {parse: function () { return {}; }, stringify: function () { return ''; }}
+ }
+ }
+
+ var isFirstPlay = true;
+
+ function logConsoleMessage() {
+ if (debugMode && 'undefined' !== typeof console && console && console.debug) {
+ console.debug.apply(console, arguments);
+ }
+ }
+
+ function isArray(variable)
+ {
+ return typeof variable === 'object' && typeof variable.length === 'number';
+ }
+
+ function isOpenCast()
+ {
+ return documentAlias.getElementById('engage_video') && documentAlias.getElementById('videoDisplay1_wrapper');
+ }
+
+ function hasJwPlayer()
+ {
+ return 'function' === typeof jwplayer;
+ }
+
+ function hasFlowPlayer()
+ {
+ return 'function' === typeof flowplayer;
+ }
+
+ function setDefaultFallbackTitle(node, tracker)
+ {
+ if (!tracker.getMediaTitle() && 'function' === typeof mediaTitleFallback) {
+ var fallbackTitle = mediaTitleFallback(node);
+ if (fallbackTitle) {
+ tracker.setMediaTitle(fallbackTitle);
+ }
+ }
+ }
+
+ // first letter is upper as we use it for event tracking as in MediaAudio, MediaVideo
+ var mediaType = {AUDIO: 'Audio', VIDEO: 'Video'};
+
+ var urlHelper = {
+ getLocation: function ()
+ {
+ var location = this.location || windowAlias.location;
+
+ if (!location.origin) {
+ location.origin = location.protocol + "//" + location.hostname + (location.port ? ':' + location.port: '');
+ }
+
+ return location;
+ },
+ setLocation: function (location)
+ {
+ this.location = location;
+ },
+ makeUrlAbsolute: function (url)
+ {
+ if ((!url || String(url) !== url) && url !== '') {
+ // it has to be a string
+ return url;
+ }
+
+ if (url.indexOf('//') === 0) {
+ // eg url without protocol //innocraft.com/movie.mp4
+ return this.getLocation().protocol + url;
+ }
+
+ if (url.indexOf('://') !== -1) {
+ // eg absolute url http://innocraft.com/movie.mp4
+ return url;
+ }
+
+ if (url.indexOf('/') === 0) {
+ // eg url without domain /movie.mp4
+ return this.getLocation().origin + url;
+ }
+
+ if (url.indexOf('#') === 0 || url.indexOf('?') === 0) {
+ // eg only query or hash ?movie=movie.mp4 or #movie
+ return this.getLocation().origin + this.getLocation().pathname + url;
+ }
+
+ if ('' === url) {
+ return this.getLocation().href;
+ }
+
+ // eg relative path movie.mp4
+ var regexToMatchDir = '(.*\/)';
+ var basePath = this.getLocation().origin + this.getLocation().pathname.match(new RegExp(regexToMatchDir))[0];
+ return basePath + url;
+ }
+ };
+
+ var utils = {
+ getCurrentTime: function () {
+ return new Date().getTime();
+ },
+ roundTimeToSeconds: function (timeInMs) {
+ return Math.round(timeInMs / 1000);
+ },
+ isNumber: function (text) {
+ return !isNaN(text);
+ },
+ getTimeScriptLoaded: function (text) {
+ return timeWhenScriptLoaded;
+ },
+ generateUniqueId: function () {
+ var id = '';
+ var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+ var charLen = chars.length;
+
+ for (var i = 0; i < 16; i++) {
+ id += chars.charAt(Math.floor(Math.random() * charLen));
+ }
+
+ return id;
+ },
+ trim: function (text)
+ {
+ if (text && String(text) === text) {
+ return text.replace(/^\s+|\s+$/g, '');
+ }
+
+ return text;
+ },
+ getQueryParameter: function (url, parameter) {
+ var regexp = new RegExp('[?&]' + parameter + '(=([^&#]*)|&|#|$)');
+ var matches = regexp.exec(url);
+
+ if (!matches) {
+ return null;
+ }
+
+ if (!matches[2]) {
+ return '';
+ }
+
+ var value = matches[2].replace(/\+/g, " ");
+
+ return decodeURIComponent(value);
+ },
+ isDocumentOffScreen: function () {
+ return documentAlias && 'undefined' !== documentAlias.hidden && documentAlias.hidden;
+ }
+ };
+
+ var element = {
+ getAttribute: function (node, attributeName) {
+ if (node && node.getAttribute && attributeName) {
+ return node.getAttribute(attributeName);
+ }
+
+ return null;
+ },
+ setAttribute: function (node, attributeName, attributeValue) {
+ if (node && node.setAttribute) {
+ node.setAttribute(attributeName, attributeValue);
+ }
+ },
+ isMediaIgnored: function (node) {
+ var ignore = element.getAttribute(node, 'data-piwik-ignore');
+ if (!!ignore || ignore === '') {
+ return true;
+ }
+ ignore = element.getAttribute(node, 'data-matomo-ignore');
+ if (!!ignore || ignore === '') {
+ return true;
+ }
+ return false;
+ },
+ getMediaResource: function (node, defaultResource) {
+ var src = element.getAttribute(node, 'data-matomo-resource');
+
+ if (src) {
+ return src;
+ }
+
+ src = element.getAttribute(node, 'data-piwik-resource');
+
+ if (src) {
+ return src;
+ }
+
+ src = element.getAttribute(node, 'src');
+
+ if (src) {
+ return src;
+ }
+
+ return defaultResource;
+ },
+ getMediaTitle: function (node) {
+ var title = element.getAttribute(node, 'data-matomo-title');
+
+ if (!title) {
+ title = element.getAttribute(node, 'data-piwik-title');
+ }
+
+ if (!title) {
+ title = element.getAttribute(node, 'title');
+ }
+
+ if (!title) {
+ title = element.getAttribute(node, 'alt');
+ }
+
+ return title;
+ },
+ hasCssClass: function (node, theClass)
+ {
+ if (node && node.className) {
+ var classes = ('' + node.className).split(' ');
+ for (var i = 0; i < classes.length; i++) {
+ if (classes[i] === theClass) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ },
+ getFirstParentWithClass: function (node, theClass, maxLevels) {
+ if (maxLevels <= 0 || !node || !node.parentNode) {
+ return null;
+ }
+
+ var parent = node.parentNode;
+
+ if (this.hasCssClass(parent, theClass)) {
+ return parent;
+ } else {
+ return this.getFirstParentWithClass(parent, theClass, --maxLevels);
+ }
+ },
+ isFullscreen: function (node) {
+ if (node && documentAlias.fullScreenElement === node
+ || documentAlias.mozFullScreenElement === node
+ || documentAlias.webkitFullscreenElement === node
+ || documentAlias.msFullscreenElement === node) {
+ // msFullscreenElement is only ie11
+ return true;
+ }
+
+ return false;
+ }
+ };
+
+ function getPiwikTrackers()
+ {
+ if (null === customPiwikTrackers) {
+ if ('object' === typeof Piwik && Piwik.getAsyncTrackers) {
+ return Piwik.getAsyncTrackers();
+ }
+ }
+
+ if (isArray(customPiwikTrackers)) {
+ return customPiwikTrackers;
+ }
+
+ return [];
+ }
+
+ function MediaTracker(playerName, type, resource) {
+ this.playerName = playerName;
+ this.type = type;
+ this.resource = resource;
+ this.disabled = false;
+ this.reset();
+ }
+
+ MediaTracker.piwikTrackers = [];
+
+ MediaTracker.prototype.disable = function () {
+ this.disabled = true;
+ };
+
+ MediaTracker.prototype.reset = function () {
+ this.id = utils.generateUniqueId();
+ this.mediaTitle = null;
+ this.timeToInitialPlay = null;
+ this.width = null;
+ this.height = null;
+ this.fullscreen = false;
+ this.timeout = null;
+ this.watchedTime = 0;
+ this.lastTimeCheck = null;
+ this.isPlaying = false;
+ this.isPaused = false;
+ this.mediaProgressInSeconds = 0;
+ this.mediaLengthInSeconds = 0;
+ this.disabled = false;
+ this.numPlaysSameMedia = 0;
+ this.numPlaysSameMediaOffScreen = 0;
+ };
+
+ MediaTracker.prototype.setResource = function (resource) {
+ this.resource = resource;
+ };
+
+ MediaTracker.prototype.getResource = function () {
+ return this.resource;
+ };
+
+ MediaTracker.prototype.trackEvent = function (action)
+ {
+ if (this.disabled) {
+ return;
+ }
+
+ if (!timeFirstTrackingRequest) {
+ timeFirstTrackingRequest = utils.getCurrentTime();
+ } else if ((utils.getCurrentTime() - timeFirstTrackingRequest) > stopTrackingAfterXMs) {
+ this.disable();
+ return;
+ }
+
+ var asyncTrackers = getPiwikTrackers();
+
+ var mediaType = 'Media' + this.type;
+ var mediaResource = this.mediaTitle || this.resource;
+
+ var args = [mediaType, action, mediaResource];
+ args.push(parseInt(Math.round(this.mediaProgressInSeconds), 10));
+
+ if (asyncTrackers && asyncTrackers.length) {
+ var i = 0, tracker;
+
+ for (i; i < asyncTrackers.length; i++) {
+ tracker = asyncTrackers[i];
+ if (tracker && tracker.MediaAnalytics && tracker.MediaAnalytics.isTrackEventsEnabled()) {
+ tracker.trackEvent.apply(tracker, args);
+ }
+ }
+ } else {
+
+ if (typeof _paq === 'undefined') {
+ _paq = [];
+ }
+
+ args.unshift('trackEvent');
+ _paq.push(args);
+
+ logConsoleMessage('piwikWasNotYetInitialized. This means players were scanning too early for media or there are no async trackers');
+ }
+
+ logConsoleMessage('trackEvent', mediaType, mediaResource, action);
+ };
+
+ MediaTracker.prototype.trackProgress = function (idView, mediaTitle, playerName, mediaType, mediaResource, watchedTimeInSeconds, progressInSeconds, mediaLength, timeToInitialPlay, width, height, fullscreen) {
+
+ if (this.disabled) {
+ return;
+ }
+
+ if (!timeFirstTrackingRequest) {
+ timeFirstTrackingRequest = utils.getCurrentTime();
+ } else if ((utils.getCurrentTime() - timeFirstTrackingRequest) > stopTrackingAfterXMs) {
+ this.disable();
+ return;
+ }
+
+ var params = {
+ ma_id: idView,
+ ma_ti: mediaTitle !== null ? mediaTitle : '',
+ ma_pn: playerName,
+ ma_mt: mediaType,
+ ma_re: mediaResource,
+ ma_st: parseInt(watchedTimeInSeconds, 10),
+ ma_ps: parseInt(progressInSeconds, 10),
+ ma_le: mediaLength,
+ ma_ttp: timeToInitialPlay !== null ? timeToInitialPlay : '',
+ ma_w: width ? width : '',
+ ma_h: height ? height : '',
+ ma_fs: fullscreen ? '1' : '0'
+ };
+
+ var requestUrl = '';
+ for (var index in params) {
+ if (Object.prototype.hasOwnProperty.call(params, index)) {
+ requestUrl += index + '=' + encodeURIComponent(params[index]) + '&';
+ }
+ }
+
+ var asyncTrackers = getPiwikTrackers();
+
+ if (asyncTrackers && asyncTrackers.length) {
+ var i = 0, tracker;
+
+ for (i; i < asyncTrackers.length; i++) {
+ tracker = asyncTrackers[i];
+ if (tracker && tracker.MediaAnalytics && tracker.MediaAnalytics.isTrackProgressEnabled()) {
+ tracker.trackRequest(requestUrl);
+ }
+ }
+ } else {
+
+ if (typeof _paq === 'undefined') {
+ _paq = [];
+ }
+
+ _paq.push(['trackRequest', requestUrl]);
+
+ logConsoleMessage('piwikWasNotYetInitialized. This means players were scanning too early for media or there are no async trackers');
+ }
+
+ if (debugMode) {
+ // check for debug mode is not really needed but better only to stringify when needed
+ logConsoleMessage('trackProgress', getJson().stringify(params));
+ }
+ };
+
+ MediaTracker.prototype.setFullscreen = function (isFullscreen) {
+ if (!this.fullscreen) {
+ this.fullscreen = !!isFullscreen;
+ }
+ };
+
+ MediaTracker.prototype.setWidth = function (width) {
+ if (utils.isNumber(width)) {
+ this.width = parseInt(width, 10);
+ }
+ };
+
+ MediaTracker.prototype.setHeight = function (height) {
+ if (utils.isNumber(height)) {
+ this.height = parseInt(height, 10);
+ }
+ };
+
+ MediaTracker.prototype.setMediaTitle = function (title) {
+ this.mediaTitle = title;
+ };
+
+ MediaTracker.prototype.getMediaTitle = function () {
+ return this.mediaTitle;
+ };
+
+ MediaTracker.prototype.setMediaProgressInSeconds = function (mediaProgressInSeconds) {
+ this.mediaProgressInSeconds = mediaProgressInSeconds;
+ };
+
+ MediaTracker.prototype.getMediaProgressInSeconds = function () {
+ return this.mediaProgressInSeconds;
+ };
+
+ MediaTracker.prototype.setMediaTotalLengthInSeconds = function (mediaLengthInSeconds) {
+ this.mediaLengthInSeconds = mediaLengthInSeconds;
+ };
+
+ MediaTracker.prototype.getMediaTotalLengthInSeconds = function () {
+ return this.mediaLengthInSeconds;
+ };
+
+ MediaTracker.prototype.play = function () {
+ if (this.isPlaying) {
+ return; // already playing
+ }
+
+ this.isPlaying = true;
+ this.startWatchedTime();
+
+ if (isFirstPlay && this.timeToInitialPlay === null) {
+ // we want to track time to initial play only once for the first play
+ this.timeToInitialPlay = utils.roundTimeToSeconds(utils.getCurrentTime() - utils.getTimeScriptLoaded());
+ }
+
+ isFirstPlay = false;
+
+ if (this.isPaused) {
+ this.isPaused = false;
+ this.trackEvent('resume');
+ } else {
+ this.trackEvent('play');
+
+ var isOffScreen = utils.isDocumentOffScreen();
+ this.numPlaysSameMedia++;
+ numMediaPlaysTotal++;
+
+ if (isOffScreen) {
+ this.numPlaysSameMediaOffScreen++;
+ numMediaPlaysTotalOffScreen++;
+ }
+
+ if (this.numPlaysSameMedia > 25 || numMediaPlaysTotal > 50) {
+ this.disable();
+ } else if (this.numPlaysSameMediaOffScreen > 10 || numMediaPlaysTotalOffScreen > 15) {
+ this.disable();
+ }
+ }
+
+ this.trackUpdate();
+ };
+
+ MediaTracker.prototype.startWatchedTime = function () {
+ this.lastTimeCheck = utils.getCurrentTime();
+ };
+
+ MediaTracker.prototype.stopWatchedTime = function () {
+ if (this.lastTimeCheck) {
+ this.watchedTime += utils.getCurrentTime() - this.lastTimeCheck;
+ this.lastTimeCheck = null;
+ }
+ };
+
+ // also when buffer start
+ MediaTracker.prototype.seekStart = function () {
+ if (this.isPlaying) {
+ // if the media player is currently not playing, we can easily ignore the seek as it has no effect. Makes
+ // sure we do not accidentally start tracking or set video to playing when the video is seeking/buffering
+ // initally before the video has even played or when it is not playing
+ this.stopWatchedTime();
+ }
+ };
+
+ // also when buffer finish and media continues playing
+ MediaTracker.prototype.seekFinish = function () {
+ if (this.isPlaying) {
+ // if the media player is currently not playing, we can easily ignore the seek as it has no effect. Makes
+ // sure we do not accidentally start tracking or set video to playing when the video is seeking/buffering
+ // initally before the video has even played or when it is not playing
+ this.startWatchedTime();
+ }
+ };
+
+ MediaTracker.prototype.pause = function () {
+ if (this.isPlaying) {
+ this.isPaused = true;
+ this.isPlaying = false;
+
+ if (this.timeout) {
+ clearTimeout(this.timeout);
+ this.timeout = null;
+ }
+
+ this.stopWatchedTime();
+
+ this.trackUpdate();
+
+ this.trackEvent('pause');
+ }
+ };
+
+ MediaTracker.prototype.finish = function () {
+ if (this.timeout) {
+ clearTimeout(this.timeout);
+ this.timeout = null;
+ }
+
+ this.stopWatchedTime();
+ this.trackUpdate();
+
+ this.trackEvent('finish');
+
+ // we generate a new id from now on all events will be counted towards a new "media session".
+ // we do not call .reset as it would result in changed media title etc. but only because a media is finished
+ // it does not mean the media actually changed so we should also not change the media title
+ this.id = utils.generateUniqueId();
+ this.timeToInitialPlay = null;
+ this.lastTimeCheck = null;
+ this.isPlaying = false;
+ this.isPaused = false;
+ this.watchedTime = 0;
+ this.mediaProgressInSeconds = 0;
+ };
+
+ MediaTracker.prototype.trackUpdate = function () {
+ if (this.timeout) {
+ // we are just tracking an update below... if there was an update scheduled... cancel it... otherwise
+ // may send eg 2 updates within few seconds
+ clearTimeout(this.timeout);
+ this.timeout = null;
+ }
+
+ var crtTime = utils.getCurrentTime();
+
+ if (this.lastTimeCheck) {
+ this.watchedTime += (crtTime - this.lastTimeCheck);
+ this.lastTimeCheck = crtTime;
+ }
+
+ var mediaLength = this.mediaLengthInSeconds;
+ if (!mediaLength || !utils.isNumber(mediaLength)) {
+ mediaLength = '';
+ } else {
+ mediaLength = parseInt(this.mediaLengthInSeconds, 10);
+ }
+
+ var watchedTimeInSeconds = utils.roundTimeToSeconds(this.watchedTime);
+ var progressInSeconds = this.mediaProgressInSeconds;
+
+ if (progressInSeconds > mediaLength && mediaLength) {
+ progressInSeconds = mediaLength;
+ }
+
+ this.trackProgress(this.id, this.mediaTitle, this.playerName, this.type, this.resource, watchedTimeInSeconds, progressInSeconds, mediaLength, this.timeToInitialPlay, this.width, this.height, this.fullscreen);
+ };
+
+ MediaTracker.prototype.update = function () {
+ if (this.timeout) {
+ return;
+ }
+
+ var watchedTimeInSeconds = utils.roundTimeToSeconds(this.watchedTime);
+
+ var interval = pingIntervalInSeconds;
+
+ if (!usesCustomInterval && (watchedTimeInSeconds >= 3600 || numMediaPlaysTotal > 15)) {
+ interval = 120;
+ } else if (!usesCustomInterval && (watchedTimeInSeconds >= 1800 || numMediaPlaysTotal > 10)) {
+ interval = 90;
+ } else if (!usesCustomInterval && (watchedTimeInSeconds >= 600 || numMediaPlaysTotal > 4)) {
+ interval = 60;
+ } else if (!usesCustomInterval && (watchedTimeInSeconds >= 300 || numMediaPlaysTotal > 2)) {
+ interval = 40;
+ } else if (!usesCustomInterval && watchedTimeInSeconds >= 60) {
+ interval = 20;
+ }
+
+ interval = interval * 1000;
+
+ var self = this;
+ this.timeout = setTimeout(function () {
+ self.trackUpdate();
+ self.timeout = null;
+ }, interval);
+ };
+
+ var players = {
+ players: {},
+ // when registering we also will directly search for media
+ registerPlayer: function (name, player) {
+ if (!player || !player.scanForMedia || 'function' !== typeof player.scanForMedia) {
+ throw new Error('A registered player does not implement the scanForMedia function');
+ }
+ name = name.toLowerCase();
+ this.players[name] = player;
+ },
+ removePlayer: function (name) {
+ name = name.toLowerCase();
+
+ delete this.players[name];
+ },
+ getPlayer: function (name) {
+ name = name.toLowerCase();
+
+ if (name in this.players) {
+ return this.players[name];
+ }
+
+ return null;
+ },
+ getPlayers: function () {
+ return this.players;
+ },
+ // can be used to re-scan the dom or a particular part of the page for new medias
+ scanForMedia: function (documentOrElement) {
+ if (!isMediaTrackingEnabled) {
+ return;
+ }
+
+ if ('undefined' === typeof documentOrElement || !documentOrElement) {
+ documentOrElement = document;
+ }
+
+ var i;
+ for (i in this.players) {
+ if (Object.prototype.hasOwnProperty.call(this.players, i)) {
+ this.players[i].scanForMedia(documentOrElement);
+ }
+ }
+ }
+ };
+
+ var Html5Player = function (node, type) {
+ if (!node) {
+ return;
+ }
+
+ if (!windowAlias.addEventListener) {
+ // html5 audio / video is not supported in this browser
+ return;
+ }
+
+ if (node.hasPlayerInstance) {
+ // when scanning for media multiple times prevent from creating multiple trackers for the same video
+ return;
+ }
+
+ node.hasPlayerInstance = true;
+
+ var isVideo = mediaType.VIDEO === type;
+ var absoluteResource = urlHelper.makeUrlAbsolute(node.currentSrc);
+ var resource = element.getMediaResource(node, absoluteResource);
+
+ var playerName = 'html5' + type.toLowerCase();
+ if (typeof paella === 'object' && typeof paella.opencast === 'object') {
+ playerName = 'paella-opencast';
+ } else if (element.getFirstParentWithClass(node, 'video-js', 1)) {
+ playerName = 'video.js';
+ } else if (element.hasCssClass(node, 'jw-video')) {
+ playerName = 'jwplayer';
+ } else if (element.getFirstParentWithClass(node, 'flowplayer', 3)) {
+ playerName = 'flowplayer';
+ }
+
+ var tracker = new MediaTracker(playerName, type, resource);
+ mediaTrackerInstances.push(tracker);
+
+ function updateDuration()
+ {
+ if (node.duration) {
+ // duration might be only available now, likely it will be going into the if below and track then the
+ // media duration
+ tracker.setMediaTotalLengthInSeconds(node.duration);
+ }
+ }
+
+ function updateDimensions() {
+ if (isVideo) {
+ if ('undefined' !== typeof node.videoWidth && node.videoWidth) {
+ tracker.setWidth(node.videoWidth);
+ } else if ('undefined' !== typeof node.clientWidth && node.clientWidth) {
+ tracker.setWidth(node.clientWidth);
+ }
+
+ if ('undefined' !== typeof node.videoHeight && node.videoHeight) {
+ tracker.setHeight(node.videoHeight);
+ } else if ('undefined' !== typeof node.clientHeight && node.clientHeight) {
+ tracker.setHeight(node.clientHeight);
+ }
+ tracker.setFullscreen(element.isFullscreen(node));
+ }
+ }
+
+ function updateCurrentTime() {
+ tracker.setMediaProgressInSeconds(node.currentTime);
+ }
+
+ function updateMediaTitle() {
+ var title = element.getMediaTitle(node);
+ if (title) {
+ tracker.setMediaTitle(title);
+ } else {
+ findCustomPlayerTitleIfNeeded(node, tracker);
+ }
+ }
+
+ // eg jwplayer or flowplayer may provide custom resource information
+ findCustomPlayerResource(node, tracker);
+
+ updateDimensions();
+ updateMediaTitle();
+ updateDuration();
+ updateCurrentTime();
+
+ var isPlaying = false;
+ var hasTrackedMediaView = false;
+ var currentSource = null;
+
+ if (node.currentSrc) {
+ currentSource = node.currentSrc;
+ }
+
+ function findCustomPlayerTitleIfNeeded(node, tracker)
+ {
+ // jwplayer does not let users set an html attribute like title or data-piwik-title so we retrieve it
+ // from the player directly if it is loaded. We can get the player Instance which is 2 levels further up
+ // in a div.jwplayer element
+
+ if (hasJwPlayer() && !tracker.getMediaTitle()) {
+ var jwPlayerDiv = element.getFirstParentWithClass(node, 'jwplayer', 2);
+
+ if (!jwPlayerDiv) {
+ // jwplayer 5 support
+ jwPlayerDiv = element.getFirstParentWithClass(node, 'jwplayer-video', 3);
+ if (jwPlayerDiv && 'undefined' !== typeof jwPlayerDiv.children && jwPlayerDiv.children && jwPlayerDiv.children.length && jwPlayerDiv.children[0]) {
+ // better be to use firstElementChild but not supported in eg IE8/9 afaik
+ jwPlayerDiv = jwPlayerDiv.children[0];
+ }
+ }
+ if (jwPlayerDiv) {
+ try {
+ var player = jwplayer(jwPlayerDiv);
+ if (player && player.getPlaylistItem) {
+ var item = player.getPlaylistItem();
+ if (item && item.matomoTitle) {
+ tracker.setMediaTitle(item.matomoTitle)
+ } else if (item && item.piwikTitle) {
+ tracker.setMediaTitle(item.piwikTitle)
+ } else if (item && item.title) {
+ tracker.setMediaTitle(item.title)
+ }
+ }
+ } catch (e) {
+ logConsoleMessage(e);
+ }
+ }
+ }
+
+ if (hasFlowPlayer() && !tracker.getMediaTitle()) {
+ var flowPlayerDiv = element.getFirstParentWithClass(node, 'flowplayer', 4);
+ if (flowPlayerDiv) {
+ var player = flowplayer(flowPlayerDiv);
+
+ if (player && player.video && player.video.matomoTitle) {
+ tracker.setMediaTitle(player.video.matomoTitle);
+ } else if (player && player.video && player.video.piwikTitle) {
+ tracker.setMediaTitle(player.video.piwikTitle);
+ } else if (player && player.video && player.video.title) {
+ tracker.setMediaTitle(player.video.title);
+ }
+ }
+ }
+
+ if (!tracker.getMediaTitle()) {
+ var openCastTitle = documentAlias.getElementById('engage_basic_description_title');
+ if (openCastTitle && openCastTitle.innerText) {
+ var title = utils.trim(openCastTitle.innerText);
+ if (title) {
+ tracker.setMediaTitle(title);
+ }
+ } else if (typeof paella === 'object'
+ && typeof paella.opencast === 'object'
+ && typeof paella.opencast._episode === 'object'
+ && paella.opencast._episode.dcTitle) {
+ var title = utils.trim(paella.opencast._episode.dcTitle);
+ if (title) {
+ tracker.setMediaTitle(title);
+ }
+ }
+ }
+
+ setDefaultFallbackTitle(node, tracker);
+ }
+
+ function findCustomPlayerResource(node, tracker)
+ {
+ // jwplayer does not let users set an html attribute like title or data-piwik-title so we retrieve it
+ // from the player directly if it is loaded. We can get the player Instance which is 2 levels further up
+ // in a div.jwplayer element
+
+ if (hasJwPlayer()) {
+ var jwPlayerDiv = element.getFirstParentWithClass(node, 'jwplayer', 2);
+
+ if (!jwPlayerDiv) {
+ // jwplayer 5 support
+ jwPlayerDiv = element.getFirstParentWithClass(node, 'jwplayer-video', 3);
+ if (jwPlayerDiv && 'undefined' !== typeof jwPlayerDiv.children && jwPlayerDiv.children && jwPlayerDiv.children.length && jwPlayerDiv.children[0]) {
+ // better be to use firstElementChild but not supported in eg IE8/9 afaik
+ jwPlayerDiv = jwPlayerDiv.children[0];
+ }
+ }
+
+ if (jwPlayerDiv) {
+ try {
+ var player = jwplayer(jwPlayerDiv);
+ if (player && player.getPlaylistItem) {
+ // lets overwrite resource by possible playlist item. Useful when item changes after a while
+ var item = player.getPlaylistItem();
+
+ if (item && 'undefined' !== typeof item.matomoResource && item.matomoResource) {
+ tracker.setResource(item.matomoResource)
+ } else if (item && 'undefined' !== typeof item.piwikResource && item.piwikResource) {
+ tracker.setResource(item.piwikResource)
+ }
+ }
+ } catch (e) {
+ logConsoleMessage(e);
+ }
+ }
+ }
+
+ if (hasFlowPlayer()) {
+ var flowPlayerDiv = element.getFirstParentWithClass(node, 'flowplayer', 4);
+ if (flowPlayerDiv) {
+ var player = flowplayer(flowPlayerDiv);
+ if (player && player.video && 'undefined' !== typeof player.video.matomoResource && player.video.matomoResource) {
+ tracker.setResource(player.video.matomoResource);
+ } else if (player && player.video && 'undefined' !== typeof player.video.piwikResource && player.video.piwikResource) {
+ tracker.setResource(player.video.piwikResource);
+ }
+ }
+ }
+ }
+
+ function checkVideoChanged()
+ {
+ if (!currentSource && node.currentSrc) {
+ currentSource = node.currentSrc;
+ } else if (currentSource && node.currentSrc && currentSource != node.currentSrc) {
+ currentSource = node.currentSrc;
+ var absoluteUrl = urlHelper.makeUrlAbsolute(currentSource);
+ var previousTitle = tracker.getMediaTitle();
+ // the URL has changed and we need to start tracking a new video play
+ isPlaying = false;
+ tracker.reset();
+ tracker.setResource(absoluteUrl);
+ tracker.setMediaTitle('');
+
+ var title = element.getMediaTitle(node)
+ if (title && title !== previousTitle) {
+ // we make sure the title actually changed and otherwise rather set it to an empty title
+ tracker.setMediaTitle(title);
+ } else {
+ findCustomPlayerTitleIfNeeded(node, tracker);
+ }
+
+ findCustomPlayerResource(node, tracker);
+ updateDuration();
+ }
+ }
+
+ function trackMediaViewIfPossible()
+ {
+ if (!hasTrackedMediaView && (tracker.getResource() || tracker.getMediaTitle())) {
+ hasTrackedMediaView = true;
+ // by now we might also have updated video title information which might not be available initially
+ updateMediaTitle(node, tracker);
+ findCustomPlayerResource(node, tracker);
+ tracker.trackUpdate();
+ // we make sure to now track it with video width and height as initially the "has media viewed request"
+ // did not have the width/height
+ }
+ }
+
+ function onResizeOrMetadataUpdate() {
+ checkVideoChanged();
+ updateDimensions();
+ updateDuration();
+ updateCurrentTime();
+ trackMediaViewIfPossible();
+ }
+
+ var seekLastTime = null;
+ if (node.loop) {
+ seekLastTime = 0; // we set the seek last time to zero as it would otherwise trigger a seek event for the first repeat.
+ }
+ var numSeekEvents = 0;
+
+ var isHeaderVideo = false;
+ if (node.loop && node.autoplay && node.muted) {
+ // likely a header video embedded in the top of the website continuously playing...
+ // we don't really want to track such videos very often and want to delay sending the updates
+ isHeaderVideo = true;
+ }
+
+ node.addEventListener('playing', function() {
+ checkVideoChanged();
+
+ if ('undefined' !== typeof node.paused && node.paused) {
+ return;
+ }
+
+ if ('undefined' !== typeof node.ended && node.ended) {
+ return;
+ }
+
+ if (!isPlaying) {
+ updateCurrentTime();
+ isPlaying = true;
+ tracker.play();
+ }
+
+ }, true);
+ node.addEventListener('durationchange', updateDuration, true);
+ node.addEventListener('loadedmetadata', onResizeOrMetadataUpdate, true);
+ node.addEventListener('loadeddata', onResizeOrMetadataUpdate, true);
+ node.addEventListener('pause', function() {
+ if (node.currentTime && node.duration && node.currentTime === node.duration) {
+ // html5 triggers a pause event followed by a finish event when the video is over. We should not
+ // track a pause in such a case
+ return;
+ }
+ if (node.seeking) {
+ // we are actually seeking and not pausing. Some players still trigger pause event in this case
+ return;
+ }
+
+ updateCurrentTime();
+ isPlaying = false;
+ tracker.pause();
+ }, true);
+ node.addEventListener('seeking', function() {
+ if (node.seeking) {
+ updateCurrentTime();
+ var progress = parseInt(tracker.getMediaProgressInSeconds(), 10);
+ if ((seekLastTime === null || seekLastTime !== progress) && numSeekEvents < 25) {
+ // do not trigger event for the same second twice!
+ // also we track max 20 seek events
+ seekLastTime = progress;
+ tracker.trackEvent('seek');
+ numSeekEvents++;
+ }
+ }
+ }, true);
+ node.addEventListener('ended', function() {
+ isPlaying = false;
+ tracker.finish();
+ }, true);
+ node.addEventListener('timeupdate', function() {
+ updateCurrentTime();
+ updateDuration();
+
+ if (isVideo && !tracker.width) {
+ // sometimes html5 video player does not get a width right away
+ updateDimensions();
+ }
+
+ if ('undefined' !== typeof node.paused && node.paused) {
+ return;
+ }
+
+ if ('undefined' !== typeof node.ended && node.ended) {
+ return;
+ }
+
+ if (isHeaderVideo) {
+ var watched = utils.roundTimeToSeconds(tracker.watchedTime);
+ var duration = tracker.getMediaTotalLengthInSeconds();
+ if (watched >= 30 && duration >= 1 && duration < 30 && (watched / duration) >= 3) {
+ // we stop tracking this after 3 repeats but only if at least played for 30 seconds...
+ tracker.disable();
+ }
+ }
+
+ // we track below, so it will be counted as viewed for sure
+ hasTrackedMediaView = true;
+
+ if (!isPlaying) {
+ // in case it is already playing when being loaded
+ isPlaying = true;
+ tracker.play();
+ } else {
+ tracker.update();
+ }
+ }, true);
+ node.addEventListener('seeking', function() {
+ tracker.seekStart();
+ }, true);
+ node.addEventListener('seeked', function() {
+ updateCurrentTime();
+ updateDuration();
+
+ tracker.seekFinish();
+ }, true);
+
+ if (isVideo) {
+ node.addEventListener('resize', onResizeOrMetadataUpdate, true);
+
+ windowAlias.addEventListener('resize', function () {
+ updateDimensions(); // in this case user resized only the browser, no need to check for new media title etc
+ }, false);
+ }
+
+ // we track the view a little delayed hoping more information becomes available by then, for example a late loaded
+ // jwplayer title, etc.
+ // tracker.timeout so if page is unloaded before timeout, we make sure the video view will be tracked onunload
+ tracker.timeout = setTimeout(function () {
+ // we wait for another second and then track even if resource OR title exists, total length not needed
+ onResizeOrMetadataUpdate();
+ tracker.timeout = null;
+ }, 1500);
+ };
+ Html5Player.scanForMedia = function (theDocumentOrNode) {
+ if (!windowAlias.addEventListener) {
+ // html5 audio / video is not supported in this browser
+ return;
+ }
+
+ var is_open_cast = isOpenCast();
+
+ var html5VideoPlayers = theDocumentOrNode.getElementsByTagName('video');
+ var elementId;
+ for (var i = 0; i < html5VideoPlayers.length; i++) {
+ if (!element.isMediaIgnored(html5VideoPlayers[i])) {
+ elementId = element.getAttribute(html5VideoPlayers[i], 'id');
+ if (is_open_cast) {
+ var wrapper1 = theDocumentOrNode.getElementById('videoDisplay1_wrapper');
+ if (wrapper1 && ('function' === typeof wrapper1.contains) && !wrapper1.contains(html5VideoPlayers[i])) {
+ // for opencast, we only track the first video. It can eg show a presenter and the presentation
+ // in this case we only track the presenter / main video.
+ continue;
+ }
+ }
+
+ if (elementId !== 'video_0'
+ && theDocumentOrNode.getElementById('videoPlayerWrapper_0')
+ && theDocumentOrNode.getElementById('video_0')) {
+ // for opencast with paella player 6.X, we only track the first video.
+ // It can eg show a presenter and the presentation plus many other videos.
+ // in this case we only track the presenter / main video.
+ continue;
+ }
+
+ new Html5Player(html5VideoPlayers[i], mediaType.VIDEO);
+ }
+ }
+ html5VideoPlayers = null;
+
+ var html5AudioPlayers = theDocumentOrNode.getElementsByTagName('audio');
+ for (var i = 0; i < html5AudioPlayers.length; i++) {
+ if (!element.isMediaIgnored(html5AudioPlayers[i])) {
+ new Html5Player(html5AudioPlayers[i], mediaType.AUDIO);
+ }
+ }
+ html5AudioPlayers = null;
+
+ if ('undefined' !== typeof soundManager && soundManager && 'undefined' !== typeof soundManager.sounds) {
+ for (var i in soundManager.sounds) {
+ if (Object.prototype.hasOwnProperty.call(soundManager.sounds, i)) {
+ var sound = soundManager.sounds[i];
+ if (sound && sound.isHTML5 && sound._a) {
+ if (!element.isMediaIgnored(sound._a)) {
+ new Html5Player(sound._a, mediaType.AUDIO);
+ }
+ }
+ }
+ }
+ }
+ };
+
+ var JwPlayerInt = function (node, type) {
+ if (!node || !windowAlias.addEventListener) {
+ // html5 audio / video is not supported in this browser
+ return;
+ }
+
+ if (node.hasPlayerInstance || !hasJwPlayer()) {
+ // when scanning for media multiple times prevent from creating multiple trackers for the same video
+ return;
+ }
+
+ var jwPlayerDiv = element.getFirstParentWithClass(node, 'jwplayer', 2);
+ if (!jwPlayerDiv) {
+ return;
+ }
+
+ var player = jwplayer(jwPlayerDiv);
+
+ if (!player || !player.getItem || 'undefined' === (typeof player.getItem())) {
+ return;
+ }
+
+ node.hasPlayerInstance = true;
+
+ function getResoure(player)
+ {
+ var item = player.getPlaylistItem();
+
+ if (item && item.matomoResource) {
+ return item.matomoResource;
+ }
+
+ if (item && item.piwikResource) {
+ return item.piwikResource;
+ }
+
+ if (item && item.file) {
+ return item.file;
+ }
+
+ return '';
+ }
+
+ function getMediaTitle(player)
+ {
+ var item = player.getPlaylistItem();
+
+ if (item && item.matomoTitle) {
+ return item.matomoTitle;
+ }
+
+ if (item && item.piwikTitle) {
+ return item.piwikTitle;
+ }
+
+ if (item && item.title) {
+ return item.title;
+ }
+
+ if ('function' === typeof mediaTitleFallback) {
+ var fallbackTitle = mediaTitleFallback(node);
+ if (fallbackTitle) {
+ return fallbackTitle;
+ }
+ }
+
+ return null;
+ }
+
+ function maybeResetPlayer(player, tracker, currentSource)
+ {
+ var resource = getResoure(player);
+ if (currentSource && resource && currentSource != resource) {
+ currentSource = resource;
+ // the URL has changed and we need to start tracking a new video play
+ tracker.reset();
+ tracker.setResource(urlHelper.makeUrlAbsolute(currentSource));
+ tracker.setMediaTitle(getMediaTitle(player));
+ tracker.setWidth(player.getWidth());
+ tracker.setHeight(player.getHeight());
+ tracker.setFullscreen(player.getFullscreen());
+ return true;
+ }
+
+ return false;
+ }
+
+ var playerResource = getResoure(player);
+ var absoluteResource = urlHelper.makeUrlAbsolute(playerResource);
+ var resource = element.getMediaResource(node, absoluteResource);
+
+ var tracker = new MediaTracker('jwplayer', type, resource);
+ tracker.setMediaTitle(getMediaTitle(player));
+ tracker.setWidth(player.getWidth());
+ tracker.setHeight(player.getHeight());
+ tracker.setFullscreen(player.getFullscreen());
+ mediaTrackerInstances.push(tracker);
+
+ var duration = player.getDuration();
+ if (duration) {
+ tracker.setMediaTotalLengthInSeconds(duration);
+ }
+
+ var isPlaying = false, currentSource = playerResource;
+ var seekLastTime = null, numSeekEvents = 0;
+
+ player.on('play', function() {
+ maybeResetPlayer(player, tracker, currentSource);
+
+ isPlaying = true;
+ tracker.play();
+ }, true);
+
+ player.on('playlistItem', function() {
+ maybeResetPlayer(player, tracker, currentSource);
+ if (player.getState() !== 'playing') {
+ isPlaying = false;
+ }
+ }, true);
+
+ player.on('pause', function() {
+ if (player.getPosition() && player.getDuration() && player.getPosition() === player.getDuration()) {
+ // it may trigger a pause event followed by a finish event when the video is over. We should not
+ // track a pause in such a case
+ return;
+ }
+
+ tracker.pause();
+ }, true);
+ player.on('complete', function() { tracker.finish(); }, true);
+ player.on('time', function() {
+ var position = player.getPosition();
+ if (position) {
+ tracker.setMediaProgressInSeconds(position);
+ }
+
+ var duration = player.getDuration();
+ if (duration) {
+ tracker.setMediaTotalLengthInSeconds(duration);
+ }
+
+ if (isPlaying) {
+ tracker.update();
+ } else {
+ // in case it is already playing when being loaded
+ isPlaying = true;
+ tracker.play();
+ }
+ }, true);
+ player.on('seek', function() { tracker.seekStart(); }, true);
+ player.on('seeked', function() {
+ var position = player.getPosition();
+ if (position) {
+ tracker.setMediaProgressInSeconds(position);
+ }
+
+ var duration = player.getDuration();
+ if (duration) {
+ tracker.setMediaTotalLengthInSeconds(duration);
+ }
+ tracker.seekFinish();
+
+ var progress = parseInt(tracker.getMediaProgressInSeconds(), 10);
+ if ((seekLastTime === null || seekLastTime !== progress) && numSeekEvents < 25) {
+ // do not trigger event for the same second twice!
+ // also we track max 20 seek events
+ seekLastTime = progress;
+ tracker.trackEvent('seek');
+ numSeekEvents++;
+ }
+ }, true);
+
+ player.on('resize', function() {
+ tracker.setWidth(player.getWidth());
+ tracker.setHeight(player.getHeight());
+ tracker.setFullscreen(player.getFullscreen());
+ }, true);
+
+ player.on('fullscreen', function () {
+ tracker.setWidth(player.getWidth());
+ tracker.setHeight(player.getHeight());
+ tracker.setFullscreen(player.getFullscreen());
+ }, false);
+
+ tracker.trackUpdate();
+ };
+ JwPlayerInt.scanForMedia = function (theDocumentOrNode) {
+ if (!windowAlias.addEventListener || !hasJwPlayer()) {
+ // this browser is not supported
+ return;
+ }
+
+ var objects = theDocumentOrNode.getElementsByTagName('object');
+ for (var i = 0; i < objects.length; i++) {
+ if (!element.isMediaIgnored(objects[i]) && element.hasCssClass(objects[i], 'jw-swf')) {
+ new JwPlayerInt(objects[i], mediaType.VIDEO);
+ }
+ }
+ objects = null;
+ };
+
+ var VimeoPlayer = function (node, type) {
+ // detect universally embedded videos
+ if (!node) {
+ return;
+ }
+
+ if (!windowAlias.addEventListener) {
+ // html5 audio / video is not supported in this browser
+ return;
+ }
+
+ if (node.playerInstance) {
+ // when scanning for media multiple times. Prevent creating multiple trackers for the same video
+ return;
+ }
+
+ node.playerInstance = true;
+
+ var src = element.getAttribute(node, 'src');
+
+ var resourceToTrack = element.getMediaResource(node, null)
+
+ var tracker = new MediaTracker('vimeo', type, resourceToTrack);
+ tracker.setWidth(node.clientWidth);
+ tracker.setHeight(node.clientHeight);
+ tracker.setFullscreen(element.isFullscreen(node));
+ mediaTrackerInstances.push(tracker);
+
+ windowAlias.addEventListener('resize', function () {
+ tracker.setWidth(node.clientWidth);
+ tracker.setHeight(node.clientHeight);
+ tracker.setFullscreen(element.isFullscreen(node));
+ }, false);
+
+ var title = element.getMediaTitle(node);
+
+ if (title) {
+ tracker.setMediaTitle(title);
+ }
+
+ node.matomoNumSeekEvents = 0;
+ node.matomoSeekLastTime = null;
+
+ var onMessageReceived = function (event) {
+ if (!(/^(https?:)?\/\/(player.)?vimeo.com(?=$|\/)/).test(event.origin)) {
+ return false;
+ }
+
+ if (!event || !event.data) {
+ return;
+ }
+
+ if (node.contentWindow && event.source && node.contentWindow !== event.source) {
+ return;
+ }
+
+ var data = event.data;
+
+ if ('string' === typeof data) {
+ data = getJson().parse(event.data);
+ }
+
+ if (('event' in data && data.event === 'ready') || ('method' in data && data.method === 'ping')) {
+ if (playerOrigin === '*') {
+ playerOrigin = event.origin;
+ }
+
+ if (!node.isVimeoReady) {
+ node.isVimeoReady = true;
+ postAction('addEventListener', 'play');
+ postAction('addEventListener', 'pause');
+ postAction('addEventListener', 'finish');
+ postAction('addEventListener', 'seek');
+ postAction('addEventListener', 'seeked');
+ postAction('addEventListener', 'playProgress');
+ postAction('getVideoTitle');
+ }
+
+ return;
+ }
+
+ if ('method' in data) {
+
+ logConsoleMessage('vimeoMethod', data.method);
+
+ switch (data.method) {
+ case 'getVideoTitle':
+ if (data.value) {
+ tracker.setMediaTitle(data.value);
+ } else {
+ setDefaultFallbackTitle(node, tracker);
+ }
+ tracker.trackUpdate();
+ break;
+
+ case 'getPaused':
+ if (data.value) {
+ tracker.pause();
+ }
+ }
+
+ return;
+ }
+
+ if ('event' in data) {
+
+ var eventName = data.event;
+
+ logConsoleMessage('vimeoEvent', eventName);
+
+ if (data && data.data) {
+ data = data.data;
+ }
+
+ if (tracker && data && data.seconds) {
+ if (tracker.getMediaProgressInSeconds() === data.seconds
+ && (eventName === 'playProgress' || eventName === 'timeupdate')) {
+ // vimeo does eg send a playProgress event every 2 hours, even when it is inactive. To prevent
+ // this bug we do not track anything unless it is updated.
+ // this way we also make it a little faster as we do not have to track an update for the
+ // very same second 4 or 5 times per second.
+ return;
+ }
+
+ tracker.setMediaProgressInSeconds(data.seconds);
+ }
+
+ if (tracker && data && data.duration) {
+ tracker.setMediaTotalLengthInSeconds(data.duration);
+ }
+
+ switch (eventName) {
+ case 'play':
+ tracker.play();
+ break;
+
+ case 'timeupdate':
+ case 'playProgress':
+
+ if (tracker._isSeeking) {
+ tracker._isSeeking = false;
+ tracker.seekFinish();
+ }
+
+ tracker.update();
+ break;
+
+ case 'seek':
+ tracker.seekStart();
+ tracker._isSeeking = true;
+ break;
+ case 'seeked':
+ var progress = parseInt(tracker.getMediaProgressInSeconds(), 10);
+ if ((node.matomoSeekLastTime === null || node.matomoSeekLastTime !== progress) && node.matomoNumSeekEvents < 25) {
+ // do not trigger event for the same second twice!
+ // also we track max 20 seek events
+ node.matomoSeekLastTime = progress;
+ tracker.trackEvent('seek');
+ node.matomoNumSeekEvents++;
+ }
+ break;
+
+ case 'pause':
+ if (data && data.seconds && data && data.duration && data.seconds === data.duration) {
+ // vimeo triggers a pause event followed by a finish event when the video is over. We should not
+ // track a pause in such a case
+ logConsoleMessage('ignoring pause event because video is finished');
+ break;
+ }
+
+ setTimeout(function () {
+ // we only track a pause event, if it is still paused in like a second. otherwise it is likely a seek
+ postAction('getPaused');
+ }, 700);
+
+ break;
+
+ case 'finish':
+ tracker.finish();
+ break;
+ }
+ }
+ }
+
+ windowAlias.addEventListener('message', onMessageReceived, true);
+
+ var playerOrigin = '*';
+ tracker._isSeeking = false;
+
+ function postAction(method, value) {
+ var data = {method: method};
+
+ if (value !== undefined) {
+ data.value = value;
+ }
+
+ if (node && node.contentWindow) {
+ if (navigator && navigator.userAgent) {
+ var ieVersion = parseFloat(navigator.userAgent.toLowerCase().replace(/^.*msie (\d+).*$/, '$1'));
+ if (ieVersion >= 8 && ieVersion < 10) {
+ data = getJson().stringify(data);
+ }
+ }
+
+ node.contentWindow.postMessage(data, playerOrigin);
+ }
+ }
+
+ postAction('ping');
+ };
+ VimeoPlayer.scanForMedia = function (theDocumentOrNode) {
+
+ if (!windowAlias.addEventListener) {
+ // vimeo iframe api is not supported in this browser
+ return;
+ }
+
+ var videos = theDocumentOrNode.getElementsByTagName('iframe');
+ for (var i = 0; i < videos.length; i++) {
+ if (element.isMediaIgnored(videos[i])) {
+ continue;
+ }
+
+ var src = element.getAttribute(videos[i], 'src');
+ if (src && src.indexOf('player.vimeo.com') > 0) {
+ new VimeoPlayer(videos[i], mediaType.VIDEO);
+ }
+ }
+ videos = null;
+ };
+
+ var YoutubePlayer = function (node, type) {
+ if (!node) {
+ return;
+ }
+
+ if (!windowAlias.addEventListener) {
+ // youtube does not support this browser
+ return;
+ }
+
+ if (node.playerInstance) {
+ // when scanning for media multiple times prevent from creating multiple trackers for the same video
+ return;
+ }
+
+ var resourceToTrack = element.getMediaResource(node, null);
+
+ var tracker = new MediaTracker('youtube', type, resourceToTrack);
+ tracker.setWidth(node.clientWidth);
+ tracker.setHeight(node.clientHeight);
+ tracker.setFullscreen(element.isFullscreen(node));
+ mediaTrackerInstances.push(tracker);
+
+ windowAlias.addEventListener('resize', function () {
+ tracker.setWidth(node.clientWidth);
+ tracker.setHeight(node.clientHeight);
+ tracker.setFullscreen(element.isFullscreen(node));
+ }, false);
+
+ var title = element.getMediaTitle(node);
+
+ if (title) {
+ tracker.setMediaTitle(title);
+ }
+
+ var isSeeking = false;
+ var updateInterval = null;
+
+ // we may overwrite the title if no data-piwik-title is set. We can get from the YT API a much better
+ // name than from the attributes title or alt
+ var canOverwriteTitle = !element.getAttribute(node, 'data-piwik-title') && !element.getAttribute(node, 'data-matomo-title');
+
+ var hasPlayingInitialized = false;
+ var isPaused = false;
+
+ var currentVideoId = null;
+
+ node.playerInstance = new YT.Player(node, {
+ events: {
+ 'onReady': function (event) {
+ if (!event || !event.target) {
+ return;
+ }
+
+ if (canOverwriteTitle && event.target && event.target.getVideoData) {
+ var videoData = event.target.getVideoData();
+ if (videoData && videoData.title) {
+ tracker.setMediaTitle(videoData.title);
+ } else {
+ setDefaultFallbackTitle(node, tracker);
+ }
+ }
+
+ tracker.trackUpdate();
+ },
+ 'onStateChange': function(event) {
+ if (!event || !event.target) {
+ return;
+ }
+
+ var target = event.target;
+
+ var playerState;
+ if (event && 'undefined' !== typeof event.data) {
+ playerState = event.data;
+ } else {
+ if (!target.getPlayerState) {
+ logConsoleMessage('youtubeMissingPlayerState');
+ return;
+ }
+
+ playerState = target.getPlayerState();
+ }
+
+ logConsoleMessage('youtubeStateChange', playerState);
+
+ switch (playerState) {
+
+ case YT.PlayerState.ENDED:
+
+ if (target.getCurrentTime) {
+ tracker.setMediaProgressInSeconds(target.getCurrentTime());
+ }
+
+ if (target.getDuration) {
+ tracker.setMediaTotalLengthInSeconds(target.getDuration());
+ }
+
+ tracker.finish();
+ if (updateInterval) {
+ clearInterval(updateInterval);
+ updateInterval = null;
+ }
+ break;
+
+ case YT.PlayerState.PLAYING: // playing
+
+ var videoData = null;
+ if (target.getVideoData) {
+ videoData = target.getVideoData();
+ }
+
+ if (!currentVideoId && videoData && videoData.video_id) {
+ currentVideoId = videoData.video_id;
+ } else if (currentVideoId && videoData && videoData.video_id && currentVideoId != videoData.video_id) {
+ currentVideoId = videoData.video_id;
+ // the URL has changed and we need to start playing another video (playlist)
+ tracker.reset();
+ if (target.getVideoUrl) {
+ tracker.setResource(target.getVideoUrl());
+ }
+ canOverwriteTitle = true;
+ hasPlayingInitialized = false;
+ isSeeking = false;
+ logConsoleMessage('currentVideoId has changed to ' + currentVideoId);
+ }
+
+ if (target.getCurrentTime) {
+ tracker.setMediaProgressInSeconds(target.getCurrentTime());
+ }
+
+ if (target.getDuration) {
+ tracker.setMediaTotalLengthInSeconds(target.getDuration());
+ }
+
+ if (canOverwriteTitle) {
+ if (videoData && videoData.title) {
+ tracker.setMediaTitle(videoData.title);
+ }
+
+ canOverwriteTitle = false; // no need from now on to set it again
+ }
+
+ if (!hasPlayingInitialized || isPaused) {
+ hasPlayingInitialized = true;
+ isPaused = false;
+ isSeeking = false;
+ tracker.play();
+ } else if (isSeeking) {
+ isSeeking = false;
+ tracker.seekFinish();
+ }
+
+ tracker.update();
+
+ if (!updateInterval) {
+ updateInterval = setInterval(function () {
+ if (tracker.isPlaying) {
+ if (target && target.getCurrentTime) {
+ tracker.setMediaProgressInSeconds(target.getCurrentTime());
+ }
+ tracker.update();
+ }
+ }, 1 * 1000);
+ // try to send ping every second
+ }
+ break;
+
+ case -1:
+ case YT.PlayerState.PAUSED:
+ setTimeout(function() {
+ // we need to track pauses with a second delay to differentiate seeks from pauses
+ if (target && target.getPlayerState && target.getPlayerState() == YT.PlayerState.PAUSED) {
+
+ if (target && target.getCurrentTime) {
+ tracker.setMediaProgressInSeconds(target.getCurrentTime());
+ }
+
+ // if still paused after one second, we assume it was actually paused and not soomed
+ tracker.pause();
+ isPaused = true;
+ if (updateInterval) {
+ clearInterval(updateInterval);
+ updateInterval = null;
+ }
+ }
+ }, 1000);
+
+ break;
+
+ case YT.PlayerState.BUFFERING:
+ tracker.seekStart();
+ isSeeking = true;
+ if (updateInterval) {
+ clearInterval(updateInterval);
+ updateInterval = null;
+ }
+ break;
+
+ }
+ }
+ }
+ });
+ }
+
+ YoutubePlayer.scanForMedia = function (theDocumentOrNode) {
+
+ if (!windowAlias.addEventListener) {
+ // youtube is not supported in this browser
+ return;
+ }
+
+ var youtubeVideos = [];
+ var iframePlayers = theDocumentOrNode.getElementsByTagName('iframe');
+ for (var i = 0; i < iframePlayers.length; i++) {
+ if (element.isMediaIgnored(iframePlayers[i])) {
+ continue;
+ }
+
+ var src = element.getAttribute(iframePlayers[i], 'src');
+ if (src && (src.indexOf('youtube.com') > 0 || src.indexOf('youtube-nocookie.com') > 0)) {
+ element.setAttribute(iframePlayers[i], 'enablejsapi', 'true');
+ youtubeVideos.push(iframePlayers[i]);
+ }
+ }
+ iframePlayers = null;
+
+ function replaceMethod(methodNameToReplace, theFunction)
+ {
+ if (!(methodNameToReplace in window)) {
+ return;
+ }
+
+ var oldMethodBackup = window[methodNameToReplace];
+
+ if ('function' !== typeof oldMethodBackup) {
+ return;
+ }
+
+ try {
+ if (oldMethodBackup.toString && oldMethodBackup.toString().indexOf('function replaceMe') === 0) {
+ // the method is already replaced, to not replace it again and again and again
+ return;
+ }
+ } catch (e) {}
+
+ function replaceMe() {
+ try {
+ oldMethodBackup.apply(window, [].slice.call(arguments, 0));
+ theFunction();
+ } catch (error) {
+ // in case the users method has an error we ignore it.
+ theFunction();
+ throw error;
+ }
+ };
+
+ window[methodNameToReplace] = replaceMe;
+ }
+
+ function isYoutubeLoaded()
+ {
+ return 'object' === typeof YT && YT && YT.Player;
+ }
+
+ function onYoutubeReady() {
+ if (!isYoutubeLoaded()) {
+ return;
+ }
+ var iframePlayers = theDocumentOrNode.getElementsByTagName('iframe');
+ for (var i = 0; i < iframePlayers.length; i++) {
+ if (element.isMediaIgnored(iframePlayers[i])) {
+ continue;
+ }
+
+ var src = element.getAttribute(iframePlayers[i], 'src');
+ if (src && (src.indexOf('youtube.com') > 0 || src.indexOf('youtube-nocookie.com') > 0)) {
+ if (iframePlayers[i].setAttribute) {
+ iframePlayers[i].setAttribute('enablejsapi', '1');
+ }
+ new YoutubePlayer(iframePlayers[i], mediaType.VIDEO);
+ }
+ }
+ }
+
+ if (youtubeVideos && youtubeVideos.length) {
+ if (isYoutubeLoaded()) {
+ onYoutubeReady();
+ } else {
+ if (windowAlias.onYouTubeIframeAPIReady) {
+ // we need to replace each time this method is called if not loaded yet as eg a user could have
+ // overwritten our onYouTubeIframeAPIReady with their custom callback eg between "onReady" and "onLoad"
+ replaceMethod('onYouTubeIframeAPIReady', onYoutubeReady);
+
+ // we make sure to not load the API again as we assume either we loaded it initially in the else,
+ // or the user loaded it if the user defined the callback
+ } else if (windowAlias.onYouTubePlayerAPIReady) {
+ // we need to replace each time this method is called if not loaded yet as eg a user could have
+ // overwritten our onYouTubePlayerAPIReady with their custom callback eg between "onReady" and "onLoad"
+ replaceMethod('onYouTubePlayerAPIReady', onYoutubeReady);
+
+ // we make sure to not load the API again as we assume either we loaded it initially in the else,
+ // or the user loaded it if the user defined the callback
+ } else {
+ windowAlias.onYouTubeIframeAPIReady = onYoutubeReady;
+
+ var tag = documentAlias.createElement('script');
+ tag.src = "https://www.youtube.com/iframe_api";
+ var scripts = documentAlias.getElementsByTagName('script');
+ if (scripts && scripts.length) {
+ var scriptTag = scripts[0];
+ scriptTag.parentNode.insertBefore(tag, scriptTag);
+ } else if (documentAlias.body) {
+ documentAlias.body.appendChild(tag);
+ }
+ }
+ }
+ }
+
+ youtubeVideos = null;
+ };
+
+ players.registerPlayer('html5', Html5Player);
+ players.registerPlayer('vimeo', VimeoPlayer);
+ players.registerPlayer('youtube', YoutubePlayer);
+ players.registerPlayer('jwplayer', JwPlayerInt);
+
+ function enrichTracker(tracker)
+ {
+ if ('undefined' !== typeof tracker.MediaAnalytics) {
+ return;
+ }
+
+ tracker.MediaAnalytics = {
+ enableEvents: true,
+ enableProgress: true,
+
+ disableTrackEvents: function () {
+ this.enableEvents = false;
+ },
+ enableTrackEvents: function () {
+ this.enableEvents = true;
+ },
+ isTrackEventsEnabled: function () {
+ return isMediaTrackingEnabled && this.enableEvents;
+ },
+ disableTrackProgress: function () {
+ this.enableProgress = false;
+ },
+ enableTrackProgress: function () {
+ this.enableProgress = true;
+ },
+ isTrackProgressEnabled: function () {
+ return isMediaTrackingEnabled && this.enableProgress;
+ }
+ };
+
+ Piwik.trigger('MediaAnalytics.TrackerInitialized', [tracker]);
+ }
+
+ function callAsyncReadyMethod()
+ {
+ if (typeof window === 'object' && 'function' === typeof windowAlias.piwikMediaAnalyticsAsyncInit) {
+ windowAlias.piwikMediaAnalyticsAsyncInit();
+ }
+ }
+
+ var jwPlayerFound = false;
+ var flowPlayerFound = false;
+ function setUpPlayerReadyEvents()
+ {
+ if (!jwPlayerFound && hasJwPlayer()) {
+ jwPlayerFound = true;
+ // jw player might be ready only later and eg video element might be there only later. Works only if jwPlayer is loaded
+ // before piwik
+ var jwPlayerInstance = jwplayer();
+ if ('object' === typeof jwPlayerInstance && 'function' === typeof jwPlayerInstance.on) {
+ jwPlayerInstance.on('ready', function (event) {
+ players.scanForMedia(document);
+ });
+ }
+ }
+
+ if (!flowPlayerFound && hasFlowPlayer()) {
+ flowPlayerFound = true;
+ // flowplayer might be ready only later and eg video element might be there only later. Works only if flowplayer is loaded
+ // before flowplayer
+ flowplayer(function (api, root) {
+ if (api) {
+ api.on('ready', function () {
+ players.scanForMedia(document);
+ });
+ api.on('load', function () {
+ players.scanForMedia(document);
+ });
+ }
+ });
+
+ var flowplayerApi = flowplayer();
+ if ('object' === typeof flowplayerApi && 'function' === typeof flowplayerApi.on) {
+ flowplayerApi.on('ready', function () {
+ players.scanForMedia(document);
+ });
+ flowplayerApi.on('load', function () {
+ players.scanForMedia(document);
+ });
+ }
+ }
+ }
+
+ function startScanningForMedia()
+ {
+ // we test for tracker instance only in onReady and onLoad in case tracker instances were created between
+ // init Matomo and the onReady or onLoad event
+
+ Piwik.DOM.onReady(function () {
+ var trackers = getPiwikTrackers();
+
+ if (!trackers || !isArray(trackers) || !trackers.length) {
+ // no single tracker has been created yet. We do not automatically scan for media as a user might only
+ // later create a tracker
+ return;
+ }
+
+ players.scanForMedia(document);
+ setUpPlayerReadyEvents();
+ });
+ Piwik.DOM.onLoad(function () {
+ var trackers = getPiwikTrackers();
+
+ if (!trackers || !isArray(trackers) || !trackers.length) {
+ // no single tracker has been created yet. We do not automatically scan for media as a user might only
+ // later create a tracker
+ return;
+ }
+
+ players.scanForMedia(document);
+ setUpPlayerReadyEvents();
+ });
+ }
+
+ function init() {
+ if ('object' === typeof windowAlias && 'object' === typeof windowAlias.Piwik && 'object' === typeof windowAlias.Piwik.MediaAnalytics) {
+ // do not initialize media analytics twice
+ return;
+ }
+
+ if ('object' === typeof windowAlias && !windowAlias.Piwik) {
+ // piwik is not defined yet
+ return;
+ }
+
+ Piwik.MediaAnalytics = {
+ utils: utils,
+ url: urlHelper,
+ element: element,
+ players: players,
+ MediaTracker: MediaTracker,
+ mediaType: mediaType,
+ scanForMedia: function (node) {
+ players.scanForMedia(node || document);
+ },
+ setPingInterval: function (globalMediaPingIntervalInSeconds) {
+ if (1 > globalMediaPingIntervalInSeconds) {
+ throw new Error('Ping interval needs to be at least one second');
+ }
+ usesCustomInterval = true;
+ pingIntervalInSeconds = parseInt(globalMediaPingIntervalInSeconds, 10);
+ },
+ removePlayer: function (playerName) {
+ players.removePlayer(playerName);
+ },
+ addPlayer: function (playerName, player) {
+ players.registerPlayer(playerName, player);
+ },
+ disableMediaAnalytics: function () {
+ isMediaTrackingEnabled = false;
+ },
+ enableMediaAnalytics: function () {
+ isMediaTrackingEnabled = true;
+ },
+ setPiwikTrackers: function (trackers) {
+ if (trackers === null) {
+ customPiwikTrackers = null;
+ return;
+ }
+
+ if (!isArray(trackers)) {
+ trackers = [trackers];
+ }
+
+ customPiwikTrackers = trackers;
+ },
+ setMediaTitleFallback: function (fallbackCallback) {
+ if ('function' !== typeof fallbackCallback) {
+ throw new Error('The mediaTitleFallback needs to be callback function');
+ }
+ mediaTitleFallback = fallbackCallback;
+ },
+ getPiwikTrackers: function () {
+ return getPiwikTrackers();
+ },
+ isMediaAnalyticsEnabled: function () {
+ return isMediaTrackingEnabled;
+ },
+ setMaxTrackingTime: function (stopAfterSeconds) {
+ stopTrackingAfterXMs = parseInt(stopAfterSeconds, 10) * 1000;
+ },
+ enableDebugMode: function () {
+ debugMode = true
+ }
+ };
+
+ Piwik.addPlugin('MediaAnalytics', {
+ unload: function () {
+ var tracker;
+ logConsoleMessage('tracker intances mediaTrackerInstances');
+ for (var i = 0; i < mediaTrackerInstances.length; i++) {
+ tracker = mediaTrackerInstances[i];
+
+ if (tracker && tracker.timeout) {
+ logConsoleMessage('before unload');
+ tracker.trackUpdate();
+ }
+ }
+ }
+ });
+
+ if (windowAlias.Piwik.initialized) {
+ // tracker was separately loaded via separate include. we need to enrich already created trackers
+ var asyncTrackers = Piwik.getAsyncTrackers();
+ var i = 0;
+ for (i; i < asyncTrackers.length; i++) {
+ enrichTracker(asyncTrackers[i]);
+ }
+
+ Piwik.on('TrackerSetup', enrichTracker);
+
+ // now that the methods are set on the tracker instance we check if there were calls that couldn't be executed
+ // the first time because the media analytics plugin was not loaded yet (but it is now)
+ Piwik.retryMissedPluginCalls();
+
+ callAsyncReadyMethod();
+ startScanningForMedia();
+
+ } else {
+
+ Piwik.on('TrackerSetup', enrichTracker);
+
+ Piwik.on('PiwikInitialized', function () {
+ callAsyncReadyMethod();
+
+ // at this point the first tracker was created, and all methods called by a user on _paq applied.
+ // this means now we can start looking for media because if someone has disabled eg tracking events
+ // or tracking progress or enabled debug etc we can be sure the media tracker has been configured
+ startScanningForMedia();
+ });
+ }
+ }
+
+ if ('object' === typeof windowAlias.Piwik) {
+ init();
+ } else {
+ // tracker is loaded separately for sure
+ if ('object' !== typeof windowAlias.piwikPluginAsyncInit) {
+ windowAlias.piwikPluginAsyncInit = [];
+ }
+
+ windowAlias.piwikPluginAsyncInit.push(init);
+ }
+
+})();
+
+/* END GENERATED: tracker.js */
+
+
+/* GENERATED: tracker.js */
+
+/**
+ * To minify this version call
+ * cat tracker.js | java -jar ../../js/yuicompressor-2.4.7/build/yuicompressor-2.4.7.jar --type js --line-break 1000 | sed 's/^[/][*]/\/*!/' > tracker.min.js
+ */
+
+(function () {
+ // some libraries overwrite the builtin Node class so we have to define them ourselves. If we update libraries below
+ // we will need to update the constants and replace eg Node.ELEMENT_NODE with Node_ELEMENT_NODE
+ var Node_ELEMENT_NODE = 1;
+ var Node_DOCUMENT_NODE = 9;
+ var Node_DOCUMENT_TYPE_NODE = 10;
+ var Node_COMMENT_NODE = 8;
+ var Node_TEXT_NODE = 3;
+
+ // from https://gist.github.com/asfaltboy/8aea7435b888164e8563
+ // manual fixes:
+ // * isCSSIdentifier() && isCSSIdentChar() escaped dash to \-
+ // * fixed looping over prefixedOwnClassNamesArray
+ // * fixed / improved ownClassNameCount
+ // * if (siblings.children)
+
+ /*!
+ * Copyright (C) 2015 Pavel Savshenko
+ * Copyright (C) 2011 Google Inc. All rights reserved.
+ * Copyright (C) 2007, 2008 Apple Inc. All rights reserved.
+ * Copyright (C) 2008 Matt Lilek <webkit@mattlilek.com>
+ * Copyright (C) 2009 Joseph Pecoraro
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ * 3. Neither the name of Apple Computer, Inc. ("Apple") nor the names of
+ * its contributors may be used to endorse or promote products derived
+ * from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY
+ * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY
+ * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
+ * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+ var UTILS = {};
+ UTILS.cssPath = function(node, optimized)
+ {
+ if (node.nodeType !== Node_ELEMENT_NODE)
+ return "";
+ var steps = [];
+ var contextNode = node;
+ while (contextNode) {
+ var step = UTILS._cssPathStep(contextNode, !!optimized, contextNode === node);
+ if (!step)
+ break; // Error - bail out early.
+ steps.push(step);
+ if (step.optimized)
+ break;
+ contextNode = contextNode.parentNode;
+ }
+ steps.reverse();
+ return steps.join(" > ");
+ }
+ UTILS._cssPathStep = function(node, optimized, isTargetNode)
+ {
+ if (node.nodeType !== Node_ELEMENT_NODE)
+ return null;
+
+ var id = node.getAttribute("id");
+ if (optimized) {
+ if (id)
+ return new UTILS.DOMNodePathStep(idSelector(id), true);
+ var nodeNameLower = node.nodeName.toLowerCase();
+ if (nodeNameLower === "body" || nodeNameLower === "head" || nodeNameLower === "html")
+ return new UTILS.DOMNodePathStep(node.nodeName.toLowerCase(), true);
+ }
+ var nodeName = node.nodeName.toLowerCase();
+
+ if (id)
+ return new UTILS.DOMNodePathStep(nodeName.toLowerCase() + idSelector(id), true);
+ var parent = node.parentNode;
+ if (!parent || parent.nodeType === Node_DOCUMENT_NODE)
+ return new UTILS.DOMNodePathStep(nodeName.toLowerCase(), true);
+
+ /**
+ * @param {UTILS.DOMNode} node
+ * @return {Array.<string>}
+ */
+ function prefixedElementClassNames(node)
+ {
+ var classAttribute = node.getAttribute("class");
+ if (!classAttribute)
+ return [];
+
+
+
+ return classAttribute.split(/\s+/g).filter(Boolean).map(function(name) {
+ // The prefix is required to store "__proto__" in a object-based map.
+ return "$" + name;
+ });
+ }
+
+ /**
+ * @param {string} id
+ * @return {string}
+ */
+ function idSelector(id)
+ {
+ return "#" + escapeIdentifierIfNeeded(id);
+ }
+
+ /**
+ * @param {string} ident
+ * @return {string}
+ */
+ function escapeIdentifierIfNeeded(ident)
+ {
+ if (isCSSIdentifier(ident))
+ return ident;
+ var shouldEscapeFirst = /^(?:[0-9]|-[0-9-]?)/.test(ident);
+ var lastIndex = ident.length - 1;
+ return ident.replace(/./g, function(c, i) {
+ return ((shouldEscapeFirst && i === 0) || !isCSSIdentChar(c)) ? escapeAsciiChar(c, i === lastIndex) : c;
+ });
+ }
+
+ /**
+ * @param {string} c
+ * @param {boolean} isLast
+ * @return {string}
+ */
+ function escapeAsciiChar(c, isLast)
+ {
+ return "\\" + toHexByte(c) + (isLast ? "" : " ");
+ }
+
+ /**
+ * @param {string} c
+ */
+ function toHexByte(c)
+ {
+ var hexByte = c.charCodeAt(0).toString(16);
+ if (hexByte.length === 1)
+ hexByte = "0" + hexByte;
+ return hexByte;
+ }
+
+ /**
+ * @param {string} c
+ * @return {boolean}
+ */
+ function isCSSIdentChar(c)
+ {
+ if (/[a-zA-Z0-9_\-]/.test(c))
+ return true;
+ return c.charCodeAt(0) >= 0xA0;
+ }
+
+ /**
+ * @param {string} value
+ * @return {boolean}
+ */
+ function isCSSIdentifier(value)
+ {
+ return /^-?[a-zA-Z_][a-zA-Z0-9_\-]*$/.test(value);
+ }
+
+ function arrayFlip(theArray) {
+ var flipped = {}, index;
+
+ for (index = 0; index < theArray.length; index++) {
+ flipped[theArray[index]] = true
+ }
+
+ return flipped;
+ }
+
+ var prefixedOwnClassNamesArray = prefixedElementClassNames(node);
+ var needsClassNames = false;
+ var needsNthChild = false;
+ var ownIndex = -1;
+ var siblings = parent.children;
+ if (siblings && siblings.length) {
+ for (var i = 0; (ownIndex === -1 || !needsNthChild) && i < siblings.length; ++i) {
+ var sibling = siblings[i];
+ if (sibling === node) {
+ ownIndex = i;
+ continue;
+ }
+ if (needsNthChild) {
+ continue;
+ }
+ if (sibling.nodeName.toLowerCase() !== nodeName.toLowerCase()) {
+ continue;
+ }
+
+ needsClassNames = true;
+ // fixed by innocraft, otherwise when counting it would include .length when counting entries
+
+ var ownClassNames = arrayFlip(prefixedOwnClassNamesArray);
+ var ownClassNameCount = prefixedOwnClassNamesArray.length;
+
+ if (ownClassNameCount === 0) {
+ needsNthChild = true;
+ continue;
+ }
+ var siblingClassNamesArray = prefixedElementClassNames(sibling);
+ for (var j = 0; j < siblingClassNamesArray.length; ++j) {
+ var siblingClass = siblingClassNamesArray[j];
+ // FIXED BY INNOCRAFT, it may return 0 which is a valid value and considered found
+ if (!ownClassNames.hasOwnProperty(siblingClass)) {
+ continue;
+ }
+
+ delete ownClassNames[siblingClass];
+ ownClassNameCount--;
+
+ if (!ownClassNameCount) {
+ needsNthChild = true;
+ break;
+ }
+ }
+ }
+ }
+
+ var result = nodeName.toLowerCase();
+ if (isTargetNode && nodeName.toLowerCase() === "input" && node.getAttribute("type") && !node.getAttribute("id") && !node.getAttribute("class"))
+ result += "[type=\"" + node.getAttribute("type") + "\"]";
+ if (needsNthChild) {
+ result += ":nth-child(" + (ownIndex + 1) + ")";
+ } else if (needsClassNames) {
+ // FIXED THIS LOOP BY INNOCRAFT otherwise would include .length when iterating
+ for (var prefixedName = 0; prefixedName < prefixedOwnClassNamesArray.length; prefixedName++)
+ // for (var prefixedName in prefixedOwnClassNamesArray.keySet())
+ result += "." + escapeIdentifierIfNeeded(prefixedOwnClassNamesArray[prefixedName].substr(1));
+ }
+
+ return new UTILS.DOMNodePathStep(result, false);
+ }
+
+ UTILS.DOMNodePathStep = function(value, optimized)
+ {
+ this.value = value;
+ this.optimized = optimized || false;
+ }
+
+ UTILS.DOMNodePathStep.prototype = {
+ toString: function()
+ {
+ return this.value;
+ }
+ }
+
+/*!
+ * Copyright 2011 Google Inc.
+ *
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+var __extends = function (d, b) {
+ for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p];
+ function __() { this.constructor = d; }
+ __.prototype = b.prototype;
+ d.prototype = new __();
+ };
+var MutationObserverCtor;
+if (typeof WebKitMutationObserver !== 'undefined') {
+ MutationObserverCtor = WebKitMutationObserver;
+} else if (typeof MutationObserver !== 'undefined') {
+ MutationObserverCtor = MutationObserver;
+}
+
+if (typeof MutationObserverCtor !== 'undefined' && MutationObserverCtor) {
+
+ var NodeMap = (function () {
+ function NodeMap() {
+ this.nodes = [];
+ this.values = [];
+ }
+ NodeMap.prototype.isIndex = function (s) {
+ return +s === s >>> 0;
+ };
+ NodeMap.prototype.nodeId = function (node) {
+ var id = node[NodeMap.ID_PROP];
+ if (!id) {
+ // note: we may falsely assign an id that has already been used... and we cannot fix it really.
+ // it is the case when for debugging purposes we "delete window.Piwik/Matomo" and load the tracker
+ // again. The problem is that we cannot know the currently highest ID in use by any element.
+ // this.values and this.nodes will be empty, Nodemap.nextId_ will be set to 0, but there will be
+ // already elements having IDs like 826... when we then add new elements randomly, they will get an ID
+ // like 1 which points actually to a different node... it is not an issue only when debugging
+
+ id = node[NodeMap.ID_PROP] = NodeMap.nextId_++;
+ }
+ return id;
+ };
+ NodeMap.prototype.set = function (node, value) {
+ var id = this.nodeId(node);
+ this.nodes[id] = node;
+ this.values[id] = value;
+ };
+ NodeMap.prototype.get = function (node) {
+ var id = this.nodeId(node);
+ return this.values[id];
+ };
+ NodeMap.prototype.has = function (node) {
+ return this.nodeId(node) in this.nodes;
+ };
+ NodeMap.prototype['delete'] = function (node) {
+ var id = this.nodeId(node);
+ delete this.nodes[id];
+ this.values[id] = undefined;
+ };
+ NodeMap.prototype.keys = function () {
+ var nodes = [];
+ for (var id in this.nodes) {
+ if (!this.isIndex(id))
+ continue;
+ nodes.push(this.nodes[id]);
+ }
+ return nodes;
+ };
+ NodeMap.ID_PROP = '__mutation_summary_node_map_id__';
+ NodeMap.nextId_ = 1;
+ return NodeMap;
+ })();
+ /**
+ * var reachableMatchableProduct = [
+ * // STAYED_OUT, ENTERED, STAYED_IN, EXITED
+ * [ STAYED_OUT, STAYED_OUT, STAYED_OUT, STAYED_OUT ], // STAYED_OUT
+ * [ STAYED_OUT, ENTERED, ENTERED, STAYED_OUT ], // ENTERED
+ * [ STAYED_OUT, ENTERED, STAYED_IN, EXITED ], // STAYED_IN
+ * [ STAYED_OUT, STAYED_OUT, EXITED, EXITED ] // EXITED
+ * ];
+ */
+ var Movement;
+ (function (Movement) {
+ Movement[Movement["STAYED_OUT"] = 0] = "STAYED_OUT";
+ Movement[Movement["ENTERED"] = 1] = "ENTERED";
+ Movement[Movement["STAYED_IN"] = 2] = "STAYED_IN";
+ Movement[Movement["REPARENTED"] = 3] = "REPARENTED";
+ Movement[Movement["REORDERED"] = 4] = "REORDERED";
+ Movement[Movement["EXITED"] = 5] = "EXITED";
+ })(Movement || (Movement = {}));
+ function enteredOrExited(changeType) {
+ return changeType === Movement.ENTERED || changeType === Movement.EXITED;
+ }
+ var NodeChange = (function () {
+ function NodeChange(node, childList, attributes, characterData, oldParentNode, added, attributeOldValues, characterDataOldValue) {
+ if (childList === void 0) { childList = false; }
+ if (attributes === void 0) { attributes = false; }
+ if (characterData === void 0) { characterData = false; }
+ if (oldParentNode === void 0) { oldParentNode = null; }
+ if (added === void 0) { added = false; }
+ if (attributeOldValues === void 0) { attributeOldValues = null; }
+ if (characterDataOldValue === void 0) { characterDataOldValue = null; }
+ this.node = node;
+ this.childList = childList;
+ this.attributes = attributes;
+ this.characterData = characterData;
+ this.oldParentNode = oldParentNode;
+ this.added = added;
+ this.attributeOldValues = attributeOldValues;
+ this.characterDataOldValue = characterDataOldValue;
+ this.isCaseInsensitive =
+ this.node.nodeType === Node_ELEMENT_NODE &&
+ this.node instanceof HTMLElement &&
+ this.node.ownerDocument instanceof HTMLDocument;
+ }
+ NodeChange.prototype.getAttributeOldValue = function (name) {
+ if (!this.attributeOldValues)
+ return undefined;
+ if (this.isCaseInsensitive)
+ name = name.toLowerCase();
+ return this.attributeOldValues[name];
+ };
+ NodeChange.prototype.getAttributeNamesMutated = function () {
+ var names = [];
+ if (!this.attributeOldValues)
+ return names;
+ for (var name in this.attributeOldValues) {
+ names.push(name);
+ }
+ return names;
+ };
+ NodeChange.prototype.attributeMutated = function (name, oldValue) {
+ this.attributes = true;
+ this.attributeOldValues = this.attributeOldValues || {};
+ if (name in this.attributeOldValues)
+ return;
+ this.attributeOldValues[name] = oldValue;
+ };
+ NodeChange.prototype.characterDataMutated = function (oldValue) {
+ if (this.characterData)
+ return;
+ this.characterData = true;
+ this.characterDataOldValue = oldValue;
+ };
+ // Note: is it possible to receive a removal followed by a removal. This
+ // can occur if the removed node is added to an non-observed node, that
+ // node is added to the observed area, and then the node removed from
+ // it.
+ NodeChange.prototype.removedFromParent = function (parent) {
+ this.childList = true;
+ if (this.added || this.oldParentNode)
+ this.added = false;
+ else
+ this.oldParentNode = parent;
+ };
+ NodeChange.prototype.insertedIntoParent = function () {
+ this.childList = true;
+ this.added = true;
+ };
+ // An node's oldParent is
+ // -its present parent, if its parentNode was not changed.
+ // -null if the first thing that happened to it was an add.
+ // -the node it was removed from if the first thing that happened to it
+ // was a remove.
+ NodeChange.prototype.getOldParent = function () {
+ if (this.childList) {
+ if (this.oldParentNode)
+ return this.oldParentNode;
+ if (this.added)
+ return null;
+ }
+ return this.node.parentNode;
+ };
+ return NodeChange;
+ })();
+ var ChildListChange = (function () {
+ function ChildListChange() {
+ this.added = new NodeMap();
+ this.removed = new NodeMap();
+ this.maybeMoved = new NodeMap();
+ this.oldPrevious = new NodeMap();
+ this.moved = undefined;
+ }
+ return ChildListChange;
+ })();
+ var TreeChanges = (function (_super) {
+ __extends(TreeChanges, _super);
+ function TreeChanges(rootNode, mutations) {
+ _super.call(this);
+ this.rootNode = rootNode;
+ this.reachableCache = undefined;
+ this.wasReachableCache = undefined;
+ this.anyParentsChanged = false;
+ this.anyAttributesChanged = false;
+ this.anyCharacterDataChanged = false;
+ for (var m = 0; m < mutations.length; m++) {
+ var mutation = mutations[m];
+ switch (mutation.type) {
+ case 'childList':
+ this.anyParentsChanged = true;
+ for (var i = 0; i < mutation.removedNodes.length; i++) {
+ var node = mutation.removedNodes[i];
+ this.getChange(node).removedFromParent(mutation.target);
+ }
+ for (var i = 0; i < mutation.addedNodes.length; i++) {
+ var node = mutation.addedNodes[i];
+ this.getChange(node).insertedIntoParent();
+ }
+ break;
+ case 'attributes':
+ this.anyAttributesChanged = true;
+ var change = this.getChange(mutation.target);
+ change.attributeMutated(mutation.attributeName, mutation.oldValue);
+ break;
+ case 'characterData':
+ this.anyCharacterDataChanged = true;
+ var change = this.getChange(mutation.target);
+ change.characterDataMutated(mutation.oldValue);
+ break;
+ }
+ }
+ }
+ TreeChanges.prototype.getChange = function (node) {
+ var change = this.get(node);
+ if (!change) {
+ change = new NodeChange(node);
+ this.set(node, change);
+ }
+ return change;
+ };
+ TreeChanges.prototype.getOldParent = function (node) {
+ var change = this.get(node);
+ return change ? change.getOldParent() : node.parentNode;
+ };
+ TreeChanges.prototype.getIsReachable = function (node) {
+ if (node === this.rootNode)
+ return true;
+ if (!node)
+ return false;
+ this.reachableCache = this.reachableCache || new NodeMap();
+ var isReachable = this.reachableCache.get(node);
+ if (isReachable === undefined) {
+ isReachable = this.getIsReachable(node.parentNode);
+ this.reachableCache.set(node, isReachable);
+ }
+ return isReachable;
+ };
+ // A node wasReachable if its oldParent wasReachable.
+ TreeChanges.prototype.getWasReachable = function (node) {
+ if (node === this.rootNode)
+ return true;
+ if (!node)
+ return false;
+ this.wasReachableCache = this.wasReachableCache || new NodeMap();
+ var wasReachable = this.wasReachableCache.get(node);
+ if (wasReachable === undefined) {
+ wasReachable = this.getWasReachable(this.getOldParent(node));
+ this.wasReachableCache.set(node, wasReachable);
+ }
+ return wasReachable;
+ };
+ TreeChanges.prototype.reachabilityChange = function (node) {
+ if (this.getIsReachable(node)) {
+ return this.getWasReachable(node) ?
+ Movement.STAYED_IN : Movement.ENTERED;
+ }
+ return this.getWasReachable(node) ?
+ Movement.EXITED : Movement.STAYED_OUT;
+ };
+ return TreeChanges;
+ })(NodeMap);
+ var MutationProjection = (function () {
+ // TOOD(any)
+ function MutationProjection(rootNode, mutations, selectors, calcReordered, calcOldPreviousSibling) {
+ this.rootNode = rootNode;
+ this.mutations = mutations;
+ this.selectors = selectors;
+ this.calcReordered = calcReordered;
+ this.calcOldPreviousSibling = calcOldPreviousSibling;
+ this.treeChanges = new TreeChanges(rootNode, mutations);
+ this.entered = [];
+ this.exited = [];
+ this.stayedIn = new NodeMap();
+ this.visited = new NodeMap();
+ this.childListChangeMap = undefined;
+ this.characterDataOnly = undefined;
+ this.matchCache = undefined;
+ this.processMutations();
+ }
+ MutationProjection.prototype.processMutations = function () {
+ if (!this.treeChanges.anyParentsChanged &&
+ !this.treeChanges.anyAttributesChanged)
+ return;
+ var changedNodes = this.treeChanges.keys();
+ for (var i = 0; i < changedNodes.length; i++) {
+ this.visitNode(changedNodes[i], undefined);
+ }
+ };
+ MutationProjection.prototype.visitNode = function (node, parentReachable) {
+ if (this.visited.has(node))
+ return;
+ this.visited.set(node, true);
+ var change = this.treeChanges.get(node);
+ var reachable = parentReachable;
+ // node inherits its parent's reachability change unless
+ // its parentNode was mutated.
+ if ((change && change.childList) || reachable == undefined)
+ reachable = this.treeChanges.reachabilityChange(node);
+ if (reachable === Movement.STAYED_OUT)
+ return;
+ // Cache match results for sub-patterns.
+ this.matchabilityChange(node);
+ if (reachable === Movement.ENTERED) {
+ this.entered.push(node);
+ }
+ else if (reachable === Movement.EXITED) {
+ this.exited.push(node);
+ this.ensureHasOldPreviousSiblingIfNeeded(node);
+ }
+ else if (reachable === Movement.STAYED_IN) {
+ var movement = Movement.STAYED_IN;
+ if (change && change.childList) {
+ if (change.oldParentNode !== node.parentNode) {
+ movement = Movement.REPARENTED;
+ this.ensureHasOldPreviousSiblingIfNeeded(node);
+ }
+ else if (this.calcReordered && this.wasReordered(node)) {
+ movement = Movement.REORDERED;
+ }
+ }
+ this.stayedIn.set(node, movement);
+ }
+ if (reachable === Movement.STAYED_IN)
+ return;
+ // reachable === ENTERED || reachable === EXITED.
+ for (var child = node.firstChild; child; child = child.nextSibling) {
+ this.visitNode(child, reachable);
+ }
+ };
+ MutationProjection.prototype.ensureHasOldPreviousSiblingIfNeeded = function (node) {
+ if (!this.calcOldPreviousSibling)
+ return;
+ this.processChildlistChanges();
+ var parentNode = node.parentNode;
+ var nodeChange = this.treeChanges.get(node);
+ if (nodeChange && nodeChange.oldParentNode)
+ parentNode = nodeChange.oldParentNode;
+ var change = this.childListChangeMap.get(parentNode);
+ if (!change) {
+ change = new ChildListChange();
+ this.childListChangeMap.set(parentNode, change);
+ }
+ if (!change.oldPrevious.has(node)) {
+ change.oldPrevious.set(node, node.previousSibling);
+ }
+ };
+ MutationProjection.prototype.getChanged = function (summary, selectors, characterDataOnly) {
+ this.selectors = selectors;
+ this.characterDataOnly = characterDataOnly;
+ for (var i = 0; i < this.entered.length; i++) {
+ var node = this.entered[i];
+ var matchable = this.matchabilityChange(node);
+ if (matchable === Movement.ENTERED || matchable === Movement.STAYED_IN)
+ summary.added.push(node);
+ }
+ var stayedInNodes = this.stayedIn.keys();
+ for (var i = 0; i < stayedInNodes.length; i++) {
+ var node = stayedInNodes[i];
+ var matchable = this.matchabilityChange(node);
+ if (matchable === Movement.ENTERED) {
+ summary.added.push(node);
+ }
+ else if (matchable === Movement.EXITED) {
+ summary.removed.push(node);
+ }
+ else if (matchable === Movement.STAYED_IN && (summary.reparented || summary.reordered)) {
+ var movement = this.stayedIn.get(node);
+ if (summary.reparented && movement === Movement.REPARENTED)
+ summary.reparented.push(node);
+ else if (summary.reordered && movement === Movement.REORDERED)
+ summary.reordered.push(node);
+ }
+ }
+ for (var i = 0; i < this.exited.length; i++) {
+ var node = this.exited[i];
+ var matchable = this.matchabilityChange(node);
+ if (matchable === Movement.EXITED || matchable === Movement.STAYED_IN)
+ summary.removed.push(node);
+ }
+ };
+ MutationProjection.prototype.getOldParentNode = function (node) {
+ var change = this.treeChanges.get(node);
+ if (change && change.childList)
+ return change.oldParentNode ? change.oldParentNode : null;
+ var reachabilityChange = this.treeChanges.reachabilityChange(node);
+ if (reachabilityChange === Movement.STAYED_OUT || reachabilityChange === Movement.ENTERED)
+ throw Error('getOldParentNode requested on invalid node.');
+ return node.parentNode;
+ };
+ MutationProjection.prototype.getOldPreviousSibling = function (node) {
+ var parentNode = node.parentNode;
+ var nodeChange = this.treeChanges.get(node);
+ if (nodeChange && nodeChange.oldParentNode)
+ parentNode = nodeChange.oldParentNode;
+ var change = this.childListChangeMap.get(parentNode);
+ if (!change)
+ throw Error('getOldPreviousSibling requested on invalid node.');
+ return change.oldPrevious.get(node);
+ };
+ MutationProjection.prototype.getOldAttribute = function (element, attrName) {
+ var change = this.treeChanges.get(element);
+ if (!change || !change.attributes)
+ throw Error('getOldAttribute requested on invalid node.');
+ var value = change.getAttributeOldValue(attrName);
+ if (value === undefined)
+ throw Error('getOldAttribute requested for unchanged attribute name.');
+ return value;
+ };
+ MutationProjection.prototype.attributeChangedNodes = function (includeAttributes) {
+ if (!this.treeChanges.anyAttributesChanged)
+ return {}; // No attributes mutations occurred.
+ var attributeFilter;
+ var caseInsensitiveFilter;
+ if (includeAttributes) {
+ attributeFilter = {};
+ caseInsensitiveFilter = {};
+ for (var i = 0; i < includeAttributes.length; i++) {
+ var attrName = includeAttributes[i];
+ attributeFilter[attrName] = true;
+ caseInsensitiveFilter[attrName.toLowerCase()] = attrName;
+ }
+ }
+ var result = {};
+ var nodes = this.treeChanges.keys();
+ for (var i = 0; i < nodes.length; i++) {
+ var node = nodes[i];
+ var change = this.treeChanges.get(node);
+ if (!change.attributes)
+ continue;
+ if (Movement.STAYED_IN !== this.treeChanges.reachabilityChange(node) ||
+ Movement.STAYED_IN !== this.matchabilityChange(node)) {
+ continue;
+ }
+ var element = node;
+ var changedAttrNames = change.getAttributeNamesMutated();
+ for (var j = 0; j < changedAttrNames.length; j++) {
+ var attrName = changedAttrNames[j];
+ if (attributeFilter &&
+ !attributeFilter[attrName] &&
+ !(change.isCaseInsensitive && caseInsensitiveFilter[attrName])) {
+ continue;
+ }
+ var oldValue = change.getAttributeOldValue(attrName);
+ if (oldValue === element.getAttribute(attrName))
+ continue;
+ if (caseInsensitiveFilter && change.isCaseInsensitive)
+ attrName = caseInsensitiveFilter[attrName];
+ result[attrName] = result[attrName] || [];
+ result[attrName].push(element);
+ }
+ }
+ return result;
+ };
+ MutationProjection.prototype.getOldCharacterData = function (node) {
+ var change = this.treeChanges.get(node);
+ if (!change || !change.characterData)
+ throw Error('getOldCharacterData requested on invalid node.');
+ return change.characterDataOldValue;
+ };
+ MutationProjection.prototype.getCharacterDataChanged = function () {
+ if (!this.treeChanges.anyCharacterDataChanged)
+ return []; // No characterData mutations occurred.
+ var nodes = this.treeChanges.keys();
+ var result = [];
+ for (var i = 0; i < nodes.length; i++) {
+ var target = nodes[i];
+ if (Movement.STAYED_IN !== this.treeChanges.reachabilityChange(target))
+ continue;
+ var change = this.treeChanges.get(target);
+ if (!change.characterData ||
+ target.textContent == change.characterDataOldValue)
+ continue;
+ result.push(target);
+ }
+ return result;
+ };
+ MutationProjection.prototype.computeMatchabilityChange = function (selector, el) {
+ if (!this.matchCache)
+ this.matchCache = [];
+ if (!this.matchCache[selector.uid])
+ this.matchCache[selector.uid] = new NodeMap();
+ var cache = this.matchCache[selector.uid];
+ var result = cache.get(el);
+ if (result === undefined) {
+ result = selector.matchabilityChange(el, this.treeChanges.get(el));
+ cache.set(el, result);
+ }
+ return result;
+ };
+ MutationProjection.prototype.matchabilityChange = function (node) {
+ var _this = this;
+ // TODO(rafaelw): Include PI, CDATA?
+ // Only include text nodes.
+ if (this.characterDataOnly) {
+ switch (node.nodeType) {
+ case Node_COMMENT_NODE:
+ case Node_TEXT_NODE:
+ return Movement.STAYED_IN;
+ default:
+ return Movement.STAYED_OUT;
+ }
+ }
+ // No element filter. Include all nodes.
+ if (!this.selectors)
+ return Movement.STAYED_IN;
+ // Element filter. Exclude non-elements.
+ if (node.nodeType !== Node_ELEMENT_NODE)
+ return Movement.STAYED_OUT;
+ var el = node;
+ var matchChanges = this.selectors.map(function (selector) {
+ return _this.computeMatchabilityChange(selector, el);
+ });
+ var accum = Movement.STAYED_OUT;
+ var i = 0;
+ while (accum !== Movement.STAYED_IN && i < matchChanges.length) {
+ switch (matchChanges[i]) {
+ case Movement.STAYED_IN:
+ accum = Movement.STAYED_IN;
+ break;
+ case Movement.ENTERED:
+ if (accum === Movement.EXITED)
+ accum = Movement.STAYED_IN;
+ else
+ accum = Movement.ENTERED;
+ break;
+ case Movement.EXITED:
+ if (accum === Movement.ENTERED)
+ accum = Movement.STAYED_IN;
+ else
+ accum = Movement.EXITED;
+ break;
+ }
+ i++;
+ }
+ return accum;
+ };
+ MutationProjection.prototype.getChildlistChange = function (el) {
+ var change = this.childListChangeMap.get(el);
+ if (!change) {
+ change = new ChildListChange();
+ this.childListChangeMap.set(el, change);
+ }
+ return change;
+ };
+ MutationProjection.prototype.processChildlistChanges = function () {
+ if (this.childListChangeMap)
+ return;
+ this.childListChangeMap = new NodeMap();
+ for (var i = 0; i < this.mutations.length; i++) {
+ var mutation = this.mutations[i];
+ if (mutation.type != 'childList')
+ continue;
+ if (this.treeChanges.reachabilityChange(mutation.target) !== Movement.STAYED_IN &&
+ !this.calcOldPreviousSibling)
+ continue;
+ var change = this.getChildlistChange(mutation.target);
+ var oldPrevious = mutation.previousSibling;
+ function recordOldPrevious(node, previous) {
+ if (!node ||
+ change.oldPrevious.has(node) ||
+ change.added.has(node) ||
+ change.maybeMoved.has(node))
+ return;
+ if (previous &&
+ (change.added.has(previous) ||
+ change.maybeMoved.has(previous)))
+ return;
+ change.oldPrevious.set(node, previous);
+ }
+ for (var j = 0; j < mutation.removedNodes.length; j++) {
+ var node = mutation.removedNodes[j];
+ recordOldPrevious(node, oldPrevious);
+ if (change.added.has(node)) {
+ change.added['delete'](node);
+ }
+ else {
+ change.removed.set(node, true);
+ change.maybeMoved['delete'](node);
+ }
+ oldPrevious = node;
+ }
+ recordOldPrevious(mutation.nextSibling, oldPrevious);
+ for (var j = 0; j < mutation.addedNodes.length; j++) {
+ var node = mutation.addedNodes[j];
+ if (change.removed.has(node)) {
+ change.removed['delete'](node);
+ change.maybeMoved.set(node, true);
+ }
+ else {
+ change.added.set(node, true);
+ }
+ }
+ }
+ };
+ MutationProjection.prototype.wasReordered = function (node) {
+ if (!this.treeChanges.anyParentsChanged)
+ return false;
+ this.processChildlistChanges();
+ var parentNode = node.parentNode;
+ var nodeChange = this.treeChanges.get(node);
+ if (nodeChange && nodeChange.oldParentNode)
+ parentNode = nodeChange.oldParentNode;
+ var change = this.childListChangeMap.get(parentNode);
+ if (!change)
+ return false;
+ if (change.moved)
+ return change.moved.get(node);
+ change.moved = new NodeMap();
+ var pendingMoveDecision = new NodeMap();
+ function isMoved(node) {
+ if (!node)
+ return false;
+ if (!change.maybeMoved.has(node))
+ return false;
+ var didMove = change.moved.get(node);
+ if (didMove !== undefined)
+ return didMove;
+ if (pendingMoveDecision.has(node)) {
+ didMove = true;
+ }
+ else {
+ pendingMoveDecision.set(node, true);
+ didMove = getPrevious(node) !== getOldPrevious(node);
+ }
+ if (pendingMoveDecision.has(node)) {
+ pendingMoveDecision['delete'](node);
+ change.moved.set(node, didMove);
+ }
+ else {
+ didMove = change.moved.get(node);
+ }
+ return didMove;
+ }
+ var oldPreviousCache = new NodeMap();
+ function getOldPrevious(node) {
+ var oldPrevious = oldPreviousCache.get(node);
+ if (oldPrevious !== undefined)
+ return oldPrevious;
+ oldPrevious = change.oldPrevious.get(node);
+ while (oldPrevious &&
+ (change.removed.has(oldPrevious) || isMoved(oldPrevious))) {
+ oldPrevious = getOldPrevious(oldPrevious);
+ }
+ if (oldPrevious === undefined)
+ oldPrevious = node.previousSibling;
+ oldPreviousCache.set(node, oldPrevious);
+ return oldPrevious;
+ }
+ var previousCache = new NodeMap();
+ function getPrevious(node) {
+ if (previousCache.has(node))
+ return previousCache.get(node);
+ var previous = node.previousSibling;
+ while (previous && (change.added.has(previous) || isMoved(previous)))
+ previous = previous.previousSibling;
+ previousCache.set(node, previous);
+ return previous;
+ }
+ change.maybeMoved.keys().forEach(isMoved);
+ return change.moved.get(node);
+ };
+ return MutationProjection;
+ })();
+ var Summary = (function () {
+ function Summary(projection, query) {
+ var _this = this;
+ this.projection = projection;
+ this.added = [];
+ this.removed = [];
+ this.reparented = query.all || query.element || query.characterData ? [] : undefined;
+ this.reordered = query.all ? [] : undefined;
+ projection.getChanged(this, query.elementFilter, query.characterData);
+ if (query.all || query.attribute || query.attributeList) {
+ var filter = query.attribute ? [query.attribute] : query.attributeList;
+ var attributeChanged = projection.attributeChangedNodes(filter);
+ if (query.attribute) {
+ this.valueChanged = attributeChanged[query.attribute] || [];
+ }
+ else {
+ this.attributeChanged = attributeChanged;
+ if (query.attributeList) {
+ query.attributeList.forEach(function (attrName) {
+ if (!_this.attributeChanged.hasOwnProperty(attrName))
+ _this.attributeChanged[attrName] = [];
+ });
+ }
+ }
+ }
+ if (query.all || query.characterData) {
+ var characterDataChanged = projection.getCharacterDataChanged();
+ if (query.characterData)
+ this.valueChanged = characterDataChanged;
+ else
+ this.characterDataChanged = characterDataChanged;
+ }
+ if (this.reordered)
+ this.getOldPreviousSibling = projection.getOldPreviousSibling.bind(projection);
+ }
+ Summary.prototype.getOldParentNode = function (node) {
+ return this.projection.getOldParentNode(node);
+ };
+ Summary.prototype.getOldAttribute = function (node, name) {
+ return this.projection.getOldAttribute(node, name);
+ };
+ Summary.prototype.getOldCharacterData = function (node) {
+ return this.projection.getOldCharacterData(node);
+ };
+ Summary.prototype.getOldPreviousSibling = function (node) {
+ return this.projection.getOldPreviousSibling(node);
+ };
+ return Summary;
+ })();
+// TODO(rafaelw): Allow ':' and '.' as valid name characters.
+ var validNameInitialChar = /[a-zA-Z_]+/;
+ var validNameNonInitialChar = /[a-zA-Z0-9_\-]+/;
+// TODO(rafaelw): Consider allowing backslash in the attrValue.
+// TODO(rafaelw): There's got a to be way to represent this state machine
+// more compactly???
+ function escapeQuotes(value) {
+ return '"' + value.replace(/"/, '\\\"') + '"';
+ }
+ var Qualifier = (function () {
+ function Qualifier() {
+ }
+ Qualifier.prototype.matches = function (oldValue) {
+ if (oldValue === null)
+ return false;
+ if (this.attrValue === undefined)
+ return true;
+ if (!this.contains)
+ return this.attrValue == oldValue;
+ var tokens = oldValue.split(' ');
+ for (var i = 0; i < tokens.length; i++) {
+ if (this.attrValue === tokens[i])
+ return true;
+ }
+ return false;
+ };
+ Qualifier.prototype.toString = function () {
+ if (this.attrName === 'class' && this.contains)
+ return '.' + this.attrValue;
+ if (this.attrName === 'id' && !this.contains)
+ return '#' + this.attrValue;
+ if (this.contains)
+ return '[' + this.attrName + '~=' + escapeQuotes(this.attrValue) + ']';
+ if ('attrValue' in this)
+ return '[' + this.attrName + '=' + escapeQuotes(this.attrValue) + ']';
+ return '[' + this.attrName + ']';
+ };
+ return Qualifier;
+ })();
+ var Selector = (function () {
+ function Selector() {
+ this.uid = Selector.nextUid++;
+ this.qualifiers = [];
+ }
+ Object.defineProperty(Selector.prototype, "caseInsensitiveTagName", {
+ get: function () {
+ return this.tagName.toUpperCase();
+ },
+ enumerable: true,
+ configurable: true
+ });
+ Object.defineProperty(Selector.prototype, "selectorString", {
+ get: function () {
+ return this.tagName + this.qualifiers.join('');
+ },
+ enumerable: true,
+ configurable: true
+ });
+ Selector.prototype.isMatching = function (el) {
+ return el[Selector.matchesSelector](this.selectorString);
+ };
+ Selector.prototype.wasMatching = function (el, change, isMatching) {
+ if (!change || !change.attributes)
+ return isMatching;
+ var tagName = change.isCaseInsensitive ? this.caseInsensitiveTagName : this.tagName;
+ if (tagName !== '*' && tagName !== el.tagName)
+ return false;
+ var attributeOldValues = [];
+ var anyChanged = false;
+ for (var i = 0; i < this.qualifiers.length; i++) {
+ var qualifier = this.qualifiers[i];
+ var oldValue = change.getAttributeOldValue(qualifier.attrName);
+ attributeOldValues.push(oldValue);
+ anyChanged = anyChanged || (oldValue !== undefined);
+ }
+ if (!anyChanged)
+ return isMatching;
+ for (var i = 0; i < this.qualifiers.length; i++) {
+ var qualifier = this.qualifiers[i];
+ var oldValue = attributeOldValues[i];
+ if (oldValue === undefined)
+ oldValue = el.getAttribute(qualifier.attrName);
+ if (!qualifier.matches(oldValue))
+ return false;
+ }
+ return true;
+ };
+ Selector.prototype.matchabilityChange = function (el, change) {
+ var isMatching = this.isMatching(el);
+ if (isMatching)
+ return this.wasMatching(el, change, isMatching) ? Movement.STAYED_IN : Movement.ENTERED;
+ else
+ return this.wasMatching(el, change, isMatching) ? Movement.EXITED : Movement.STAYED_OUT;
+ };
+ Selector.parseSelectors = function (input) {
+ var selectors = [];
+ var currentSelector;
+ var currentQualifier;
+ function newSelector() {
+ if (currentSelector) {
+ if (currentQualifier) {
+ currentSelector.qualifiers.push(currentQualifier);
+ currentQualifier = undefined;
+ }
+ selectors.push(currentSelector);
+ }
+ currentSelector = new Selector();
+ }
+ function newQualifier() {
+ if (currentQualifier)
+ currentSelector.qualifiers.push(currentQualifier);
+ currentQualifier = new Qualifier();
+ }
+ var WHITESPACE = /\s/;
+ var valueQuoteChar;
+ var SYNTAX_ERROR = 'Invalid or unsupported selector syntax.';
+ var SELECTOR = 1;
+ var TAG_NAME = 2;
+ var QUALIFIER = 3;
+ var QUALIFIER_NAME_FIRST_CHAR = 4;
+ var QUALIFIER_NAME = 5;
+ var ATTR_NAME_FIRST_CHAR = 6;
+ var ATTR_NAME = 7;
+ var EQUIV_OR_ATTR_QUAL_END = 8;
+ var EQUAL = 9;
+ var ATTR_QUAL_END = 10;
+ var VALUE_FIRST_CHAR = 11;
+ var VALUE = 12;
+ var QUOTED_VALUE = 13;
+ var SELECTOR_SEPARATOR = 14;
+ var state = SELECTOR;
+ var i = 0;
+ while (i < input.length) {
+ var c = input[i++];
+ switch (state) {
+ case SELECTOR:
+ if (c.match(validNameInitialChar)) {
+ newSelector();
+ currentSelector.tagName = c;
+ state = TAG_NAME;
+ break;
+ }
+ if (c == '*') {
+ newSelector();
+ currentSelector.tagName = '*';
+ state = QUALIFIER;
+ break;
+ }
+ if (c == '.') {
+ newSelector();
+ newQualifier();
+ currentSelector.tagName = '*';
+ currentQualifier.attrName = 'class';
+ currentQualifier.contains = true;
+ state = QUALIFIER_NAME_FIRST_CHAR;
+ break;
+ }
+ if (c == '#') {
+ newSelector();
+ newQualifier();
+ currentSelector.tagName = '*';
+ currentQualifier.attrName = 'id';
+ state = QUALIFIER_NAME_FIRST_CHAR;
+ break;
+ }
+ if (c == '[') {
+ newSelector();
+ newQualifier();
+ currentSelector.tagName = '*';
+ currentQualifier.attrName = '';
+ state = ATTR_NAME_FIRST_CHAR;
+ break;
+ }
+ if (c.match(WHITESPACE))
+ break;
+ throw Error(SYNTAX_ERROR);
+ case TAG_NAME:
+ if (c.match(validNameNonInitialChar)) {
+ currentSelector.tagName += c;
+ break;
+ }
+ if (c == '.') {
+ newQualifier();
+ currentQualifier.attrName = 'class';
+ currentQualifier.contains = true;
+ state = QUALIFIER_NAME_FIRST_CHAR;
+ break;
+ }
+ if (c == '#') {
+ newQualifier();
+ currentQualifier.attrName = 'id';
+ state = QUALIFIER_NAME_FIRST_CHAR;
+ break;
+ }
+ if (c == '[') {
+ newQualifier();
+ currentQualifier.attrName = '';
+ state = ATTR_NAME_FIRST_CHAR;
+ break;
+ }
+ if (c.match(WHITESPACE)) {
+ state = SELECTOR_SEPARATOR;
+ break;
+ }
+ if (c == ',') {
+ state = SELECTOR;
+ break;
+ }
+ throw Error(SYNTAX_ERROR);
+ case QUALIFIER:
+ if (c == '.') {
+ newQualifier();
+ currentQualifier.attrName = 'class';
+ currentQualifier.contains = true;
+ state = QUALIFIER_NAME_FIRST_CHAR;
+ break;
+ }
+ if (c == '#') {
+ newQualifier();
+ currentQualifier.attrName = 'id';
+ state = QUALIFIER_NAME_FIRST_CHAR;
+ break;
+ }
+ if (c == '[') {
+ newQualifier();
+ currentQualifier.attrName = '';
+ state = ATTR_NAME_FIRST_CHAR;
+ break;
+ }
+ if (c.match(WHITESPACE)) {
+ state = SELECTOR_SEPARATOR;
+ break;
+ }
+ if (c == ',') {
+ state = SELECTOR;
+ break;
+ }
+ throw Error(SYNTAX_ERROR);
+ case QUALIFIER_NAME_FIRST_CHAR:
+ if (c.match(validNameInitialChar)) {
+ currentQualifier.attrValue = c;
+ state = QUALIFIER_NAME;
+ break;
+ }
+ throw Error(SYNTAX_ERROR);
+ case QUALIFIER_NAME:
+ if (c.match(validNameNonInitialChar)) {
+ currentQualifier.attrValue += c;
+ break;
+ }
+ if (c == '.') {
+ newQualifier();
+ currentQualifier.attrName = 'class';
+ currentQualifier.contains = true;
+ state = QUALIFIER_NAME_FIRST_CHAR;
+ break;
+ }
+ if (c == '#') {
+ newQualifier();
+ currentQualifier.attrName = 'id';
+ state = QUALIFIER_NAME_FIRST_CHAR;
+ break;
+ }
+ if (c == '[') {
+ newQualifier();
+ state = ATTR_NAME_FIRST_CHAR;
+ break;
+ }
+ if (c.match(WHITESPACE)) {
+ state = SELECTOR_SEPARATOR;
+ break;
+ }
+ if (c == ',') {
+ state = SELECTOR;
+ break;
+ }
+ throw Error(SYNTAX_ERROR);
+ case ATTR_NAME_FIRST_CHAR:
+ if (c.match(validNameInitialChar)) {
+ currentQualifier.attrName = c;
+ state = ATTR_NAME;
+ break;
+ }
+ if (c.match(WHITESPACE))
+ break;
+ throw Error(SYNTAX_ERROR);
+ case ATTR_NAME:
+ if (c.match(validNameNonInitialChar)) {
+ currentQualifier.attrName += c;
+ break;
+ }
+ if (c.match(WHITESPACE)) {
+ state = EQUIV_OR_ATTR_QUAL_END;
+ break;
+ }
+ if (c == '~') {
+ currentQualifier.contains = true;
+ state = EQUAL;
+ break;
+ }
+ if (c == '=') {
+ currentQualifier.attrValue = '';
+ state = VALUE_FIRST_CHAR;
+ break;
+ }
+ if (c == ']') {
+ state = QUALIFIER;
+ break;
+ }
+ throw Error(SYNTAX_ERROR);
+ case EQUIV_OR_ATTR_QUAL_END:
+ if (c == '~') {
+ currentQualifier.contains = true;
+ state = EQUAL;
+ break;
+ }
+ if (c == '=') {
+ currentQualifier.attrValue = '';
+ state = VALUE_FIRST_CHAR;
+ break;
+ }
+ if (c == ']') {
+ state = QUALIFIER;
+ break;
+ }
+ if (c.match(WHITESPACE))
+ break;
+ throw Error(SYNTAX_ERROR);
+ case EQUAL:
+ if (c == '=') {
+ currentQualifier.attrValue = '';
+ state = VALUE_FIRST_CHAR;
+ break;
+ }
+ throw Error(SYNTAX_ERROR);
+ case ATTR_QUAL_END:
+ if (c == ']') {
+ state = QUALIFIER;
+ break;
+ }
+ if (c.match(WHITESPACE))
+ break;
+ throw Error(SYNTAX_ERROR);
+ case VALUE_FIRST_CHAR:
+ if (c.match(WHITESPACE))
+ break;
+ if (c == '"' || c == "'") {
+ valueQuoteChar = c;
+ state = QUOTED_VALUE;
+ break;
+ }
+ currentQualifier.attrValue += c;
+ state = VALUE;
+ break;
+ case VALUE:
+ if (c.match(WHITESPACE)) {
+ state = ATTR_QUAL_END;
+ break;
+ }
+ if (c == ']') {
+ state = QUALIFIER;
+ break;
+ }
+ if (c == "'" || c == '"')
+ throw Error(SYNTAX_ERROR);
+ currentQualifier.attrValue += c;
+ break;
+ case QUOTED_VALUE:
+ if (c == valueQuoteChar) {
+ state = ATTR_QUAL_END;
+ break;
+ }
+ currentQualifier.attrValue += c;
+ break;
+ case SELECTOR_SEPARATOR:
+ if (c.match(WHITESPACE))
+ break;
+ if (c == ',') {
+ state = SELECTOR;
+ break;
+ }
+ throw Error(SYNTAX_ERROR);
+ }
+ }
+ switch (state) {
+ case SELECTOR:
+ case TAG_NAME:
+ case QUALIFIER:
+ case QUALIFIER_NAME:
+ case SELECTOR_SEPARATOR:
+ // Valid end states.
+ newSelector();
+ break;
+ default:
+ throw Error(SYNTAX_ERROR);
+ }
+ if (!selectors.length)
+ throw Error(SYNTAX_ERROR);
+ return selectors;
+ };
+ Selector.nextUid = 1;
+ Selector.matchesSelector = (function () {
+ var element = document.createElement('div');
+ if (typeof element['webkitMatchesSelector'] === 'function')
+ return 'webkitMatchesSelector';
+ if (typeof element['mozMatchesSelector'] === 'function')
+ return 'mozMatchesSelector';
+ if (typeof element['msMatchesSelector'] === 'function')
+ return 'msMatchesSelector';
+ return 'matchesSelector';
+ })();
+ return Selector;
+ })();
+ var attributeFilterPattern = /^([a-zA-Z:_]+[a-zA-Z0-9_\-:\.]*)$/;
+ function validateAttribute(attribute) {
+ if (typeof attribute != 'string')
+ throw Error('Invalid request opion. attribute must be a non-zero length string.');
+ attribute = attribute.trim();
+ if (!attribute)
+ throw Error('Invalid request opion. attribute must be a non-zero length string.');
+ if (!attribute.match(attributeFilterPattern))
+ throw Error('Invalid request option. invalid attribute name: ' + attribute);
+ return attribute;
+ }
+ function validateElementAttributes(attribs) {
+ if (!attribs.trim().length)
+ throw Error('Invalid request option: elementAttributes must contain at least one attribute.');
+ var lowerAttributes = {};
+ var attributes = {};
+ var tokens = attribs.split(/\s+/);
+ for (var i = 0; i < tokens.length; i++) {
+ var name = tokens[i];
+ if (!name)
+ continue;
+ var name = validateAttribute(name);
+ var nameLower = name.toLowerCase();
+ if (lowerAttributes[nameLower])
+ throw Error('Invalid request option: observing multiple case variations of the same attribute is not supported.');
+ attributes[name] = true;
+ lowerAttributes[nameLower] = true;
+ }
+ return Object.keys(attributes);
+ }
+ function elementFilterAttributes(selectors) {
+ var attributes = {};
+ selectors.forEach(function (selector) {
+ selector.qualifiers.forEach(function (qualifier) {
+ attributes[qualifier.attrName] = true;
+ });
+ });
+ return Object.keys(attributes);
+ }
+ var MutationSummary = (function () {
+ function MutationSummary(opts) {
+ var _this = this;
+ this.connected = false;
+ this.options = MutationSummary.validateOptions(opts);
+ this.observerOptions = MutationSummary.createObserverOptions(this.options.queries);
+ this.root = this.options.rootNode;
+ this.callback = this.options.callback;
+ this.elementFilter = Array.prototype.concat.apply([], this.options.queries.map(function (query) {
+ return query.elementFilter ? query.elementFilter : [];
+ }));
+ if (!this.elementFilter.length)
+ this.elementFilter = undefined;
+ this.calcReordered = this.options.queries.some(function (query) {
+ return query.all;
+ });
+ this.queryValidators = []; // TODO(rafaelw): Shouldn't always define this.
+ if (MutationSummary.createQueryValidator) {
+ this.queryValidators = this.options.queries.map(function (query) {
+ return MutationSummary.createQueryValidator(_this.root, query);
+ });
+ }
+ this.observer = new MutationObserverCtor(function (mutations) {
+ _this.observerCallback(mutations);
+ });
+ this.reconnect();
+ }
+ MutationSummary.createObserverOptions = function (queries) {
+ var observerOptions = {
+ childList: true,
+ subtree: true
+ };
+ var attributeFilter;
+ function observeAttributes(attributes) {
+ if (observerOptions.attributes && !attributeFilter)
+ return; // already observing all.
+ observerOptions.attributes = true;
+ observerOptions.attributeOldValue = true;
+ if (!attributes) {
+ // observe all.
+ attributeFilter = undefined;
+ return;
+ }
+ // add to observed.
+ attributeFilter = attributeFilter || {};
+ attributes.forEach(function (attribute) {
+ attributeFilter[attribute] = true;
+ attributeFilter[attribute.toLowerCase()] = true;
+ });
+ }
+ queries.forEach(function (query) {
+ if (query.characterData) {
+ observerOptions.characterData = true;
+ observerOptions.characterDataOldValue = true;
+ return;
+ }
+ if (query.all) {
+ observeAttributes();
+ observerOptions.characterData = true;
+ observerOptions.characterDataOldValue = true;
+ return;
+ }
+ if (query.attribute) {
+ observeAttributes([query.attribute.trim()]);
+ return;
+ }
+ var attributes = elementFilterAttributes(query.elementFilter).concat(query.attributeList || []);
+ if (attributes.length)
+ observeAttributes(attributes);
+ });
+ if (attributeFilter)
+ observerOptions.attributeFilter = Object.keys(attributeFilter);
+ return observerOptions;
+ };
+ MutationSummary.validateOptions = function (options) {
+ for (var prop in options) {
+ if (!(prop in MutationSummary.optionKeys))
+ throw Error('Invalid option: ' + prop);
+ }
+ if (typeof options.callback !== 'function')
+ throw Error('Invalid options: callback is required and must be a function');
+ if (!options.queries || !options.queries.length)
+ throw Error('Invalid options: queries must contain at least one query request object.');
+ var opts = {
+ callback: options.callback,
+ rootNode: options.rootNode || document,
+ observeOwnChanges: !!options.observeOwnChanges,
+ oldPreviousSibling: !!options.oldPreviousSibling,
+ queries: []
+ };
+ for (var i = 0; i < options.queries.length; i++) {
+ var request = options.queries[i];
+ // all
+ if (request.all) {
+ if (Object.keys(request).length > 1)
+ throw Error('Invalid request option. all has no options.');
+ opts.queries.push({ all: true });
+ continue;
+ }
+ // attribute
+ if ('attribute' in request) {
+ var query = {
+ attribute: validateAttribute(request.attribute)
+ };
+ query.elementFilter = Selector.parseSelectors('*[' + query.attribute + ']');
+ if (Object.keys(request).length > 1)
+ throw Error('Invalid request option. attribute has no options.');
+ opts.queries.push(query);
+ continue;
+ }
+ // element
+ if ('element' in request) {
+ var requestOptionCount = Object.keys(request).length;
+ var query = {
+ element: request.element,
+ elementFilter: Selector.parseSelectors(request.element)
+ };
+ if (request.hasOwnProperty('elementAttributes')) {
+ query.attributeList = validateElementAttributes(request.elementAttributes);
+ requestOptionCount--;
+ }
+ if (requestOptionCount > 1)
+ throw Error('Invalid request option. element only allows elementAttributes option.');
+ opts.queries.push(query);
+ continue;
+ }
+ // characterData
+ if (request.characterData) {
+ if (Object.keys(request).length > 1)
+ throw Error('Invalid request option. characterData has no options.');
+ opts.queries.push({ characterData: true });
+ continue;
+ }
+ throw Error('Invalid request option. Unknown query request.');
+ }
+ return opts;
+ };
+ MutationSummary.prototype.createSummaries = function (mutations) {
+ if (!mutations || !mutations.length)
+ return [];
+ var projection = new MutationProjection(this.root, mutations, this.elementFilter, this.calcReordered, this.options.oldPreviousSibling);
+ var summaries = [];
+ for (var i = 0; i < this.options.queries.length; i++) {
+ summaries.push(new Summary(projection, this.options.queries[i]));
+ }
+ return summaries;
+ };
+ MutationSummary.prototype.checkpointQueryValidators = function () {
+ this.queryValidators.forEach(function (validator) {
+ if (validator)
+ validator.recordPreviousState();
+ });
+ };
+ MutationSummary.prototype.runQueryValidators = function (summaries) {
+ this.queryValidators.forEach(function (validator, index) {
+ if (validator)
+ validator.validate(summaries[index]);
+ });
+ };
+ MutationSummary.prototype.changesToReport = function (summaries) {
+ return summaries.some(function (summary) {
+ var summaryProps = ['added', 'removed', 'reordered', 'reparented',
+ 'valueChanged', 'characterDataChanged'];
+ if (summaryProps.some(function (prop) { return summary[prop] && summary[prop].length; }))
+ return true;
+ if (summary.attributeChanged) {
+ var attrNames = Object.keys(summary.attributeChanged);
+ var attrsChanged = attrNames.some(function (attrName) {
+ return !!summary.attributeChanged[attrName].length;
+ });
+ if (attrsChanged)
+ return true;
+ }
+ return false;
+ });
+ };
+ MutationSummary.prototype.observerCallback = function (mutations) {
+ if (!this.options.observeOwnChanges)
+ this.observer.disconnect();
+ var summaries = this.createSummaries(mutations);
+ this.runQueryValidators(summaries);
+ if (this.options.observeOwnChanges)
+ this.checkpointQueryValidators();
+ if (this.changesToReport(summaries))
+ this.callback(summaries);
+ // disconnect() may have been called during the callback.
+ if (!this.options.observeOwnChanges && this.connected) {
+ this.checkpointQueryValidators();
+ this.observer.observe(this.root, this.observerOptions);
+ }
+ };
+ MutationSummary.prototype.reconnect = function () {
+ if (this.connected)
+ throw Error('Already connected');
+ this.observer.observe(this.root, this.observerOptions);
+ this.connected = true;
+ this.checkpointQueryValidators();
+ };
+ MutationSummary.prototype.takeSummaries = function () {
+ if (!this.connected)
+ throw Error('Not connected');
+ var summaries = this.createSummaries(this.observer.takeRecords());
+ return this.changesToReport(summaries) ? summaries : undefined;
+ };
+ MutationSummary.prototype.disconnect = function () {
+ var summaries = this.takeSummaries();
+ this.observer.disconnect();
+ this.connected = false;
+ return summaries;
+ };
+ MutationSummary.NodeMap = NodeMap; // exposed for use in TreeMirror.
+ MutationSummary.parseElementFilter = Selector.parseSelectors; // exposed for testing.
+ MutationSummary.optionKeys = {
+ 'callback': true,
+ 'queries': true,
+ 'rootNode': true,
+ 'oldPreviousSibling': true,
+ 'observeOwnChanges': true
+ };
+ return MutationSummary;
+ })();
+
+ /**
+ * TREEMIRROR
+ * SEE https://github.com/rafaelw/mutation-summary/blob/master/util/tree-mirror.js
+ * Custom modifications from InnoCraft especially in serializeNode()
+ */
+ var TreeMirrorClient = (function () {
+ function TreeMirrorClient(target, mirror, testingQueries) {
+ var _this = this;
+ this.target = target;
+ this.mirror = mirror;
+ this.nextId = 1;
+ this.knownNodes = new MutationSummary.NodeMap();
+ var rootId = this.serializeNode(target).id;
+ var children = [];
+ for (var child = target.firstChild; child; child = child.nextSibling)
+ children.push(this.serializeNode(child, true));
+ this.mirror.initialize(rootId, children);
+ var self = this;
+ var queries = [{ all: true }];
+ if (testingQueries)
+ queries = queries.concat(testingQueries);
+ this.mutationSummary = new MutationSummary({
+ rootNode: target,
+ callback: function (summaries) {
+ _this.applyChanged(summaries);
+ },
+ queries: queries
+ });
+ }
+ TreeMirrorClient.prototype.disconnect = function () {
+ if (this.mutationSummary) {
+ this.mutationSummary.disconnect();
+ this.mutationSummary = undefined;
+ }
+ };
+ TreeMirrorClient.prototype.rememberNode = function (node) {
+ var id = this.nextId++;
+ this.knownNodes.set(node, id);
+ return id;
+ };
+ TreeMirrorClient.prototype.forgetNode = function (node) {
+ this.knownNodes['delete'](node);
+ };
+ TreeMirrorClient.prototype.serializeNode = function (node, recursive, isIgnoredField, isIgnoredContent) {
+ if (node === null)
+ return null;
+
+ var id = this.knownNodes.get(node);
+ if (id !== undefined) {
+ return { id: id };
+ }
+ var parent = (id && id.parentNode) ? id.parentNode : null;
+ var data = {
+ nodeType: node.nodeType,
+ id: this.rememberNode(node)
+ };
+
+ if (!isIgnoredField && element.shouldMaskField(node, false)) {
+ isIgnoredField = true;
+ }
+ if (!isIgnoredContent && element.shouldMaskContent(node, false)) {
+ isIgnoredContent = true;
+ }
+
+ while (parent && !isIgnoredField && !isIgnoredContent) {
+ if (!isIgnoredField && element.shouldMaskField(parent, false)) {
+ isIgnoredField = true;
+ }
+ if (!isIgnoredContent && element.shouldMaskContent(parent, false)) {
+ isIgnoredContent = true;
+ }
+
+ parent = parent.parentNode ? parent.parentNode : null;
+ }
+
+ switch (data.nodeType) {
+ case Node_DOCUMENT_TYPE_NODE:
+ var docType = node;
+ data.name = docType.name;
+ data.publicId = docType.publicId;
+ data.systemId = docType.systemId;
+ break;
+ case Node_COMMENT_NODE:
+ // to save data etc we do not track comment nodes. they are not needed to render page
+ data.textContent = ' ';
+ break;
+ case Node_TEXT_NODE:
+
+ if ('undefined' !== typeof node.parentNode && node.parentNode && node.parentNode.tagName === 'TEXTAREA' && (!recordKeystrokes || isIgnoredField || element.shouldMaskField(node, false))) {
+ data.textContent = element.maskFormField(trackerUtils.trim(node.textContent));
+ } else if (isIgnoredContent || element.shouldMaskContent(node, false)) {
+ data.textContent = element.maskFormField(trackerUtils.trim(node.textContent));
+ } else {
+ data.textContent = node.textContent;
+ }
+
+ break;
+ case Node_ELEMENT_NODE:
+
+ data.tagName = node.tagName;
+ data.attributes = {};
+
+ if ('SCRIPT' === data.tagName || 'NOSCRIPT' === data.tagName) {
+ // we ignore any details about script elements. We would replace them anyway when rendering the page
+ // and they often contain random parameters which would prevent re-using existing tracked DOM
+ // mutations in blob tables
+ break;
+ }
+
+ if ('STYLE' === data.tagName
+ && (('string' === typeof node.innerText && node.innerText.trim() === '') ||
+ ('string' === typeof node.innerHTML && node.innerHTML.trim() === ''))
+ && documentAlias.styleSheets
+ && documentAlias.styleSheets.length) {
+ var styleContent;
+ for (var k = 0; k < documentAlias.styleSheets.length; k++) {
+ if (documentAlias.styleSheets[k]) {
+ var sheet = documentAlias.styleSheets[k];
+ if (sheet && sheet.ownerNode && !sheet.href && sheet.ownerNode === node && sheet.cssRules && sheet.cssRules.length) {
+ var content = '';
+ for (var i = 0; i < sheet.cssRules.length; i++) {
+ if (sheet.cssRules[i].cssText) {
+ content += sheet.cssRules[i].cssText + " ";
+ }
+ }
+ styleContent = documentAlias.createTextNode(content);
+ data.childNodes = [
+ this.serializeNode(styleContent, false, isIgnoredField, isIgnoredContent)
+ ];
+ break;
+ }
+ }
+
+ }
+ if (styleContent) {
+ break;
+ }
+ }
+
+ var attrValue;
+ for (var i = 0; i < node.attributes.length; i++) {
+ var attr = node.attributes[i];
+ if (attr && 'value' in attr) {
+ attrValue = attr.value;
+ } else {
+ attrValue = '';
+ }
+
+ var randomLength = false;
+
+ if (attr.name === 'value'
+ && data.tagName === 'INPUT'
+ && node.value
+ && (!node.type || String(node.type).toLowerCase() === 'text' || String(node.type).toLowerCase() === 'number')) {
+ attrValue = node.value;
+ }
+
+ if (attr.name === 'src'
+ && data.tagName === 'IMG'
+ && element.shouldMaskContent(node, false)) {
+ attrValue = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg==';
+ }
+
+ if (attr.name === 'value'
+ && data.tagName === 'INPUT'
+ && node.value
+ && String(node.type).toLowerCase() === 'password') {
+ randomLength = true;
+ }
+
+ if ('INPUT' === data.tagName && node.type && node.type === 'hidden' && 'value' === attr.name) {
+ data.attributes[attr.name] = ''; // we make sure to not track any hidden tokens etc
+ } else if ('INPUT' === data.tagName && 'value' === attr.name && (!recordKeystrokes || isIgnoredField || element.shouldMaskField(node, false))) {
+ data.attributes[attr.name] = element.maskFormField(attrValue, randomLength);
+ } else if (('title' === attr.name || 'alt' === attr.name || 'label' === attr.name || 'placeholder' === attr.name) && (isIgnoredContent || element.shouldMaskContent(node, false))) {
+ data.attributes[attr.name] = element.maskFormField(attrValue);
+ } else {
+ data.attributes[attr.name] = attrValue;
+ }
+ }
+
+ if ('IFRAME' === data.tagName && (node.scrollWidth <= 1 || node.scrollHeight <= 1)) {
+ // they would not be displayed, we want to prevent recording such iframes as they may contain
+ // random parameters or other content that should not be recorded
+ data.attributes.src = 'about:blank';
+ } else if ('META' === data.tagName) {
+ if (data.attributes.property && String(data.attributes.property).indexOf('og:') >= 0) {
+ data.attributes = {};
+ } else if (data.attributes.name) {
+ var metaName = String(data.attributes.name).toLowerCase();
+ if (metaName.indexOf('twitter:') >= 0 || metaName.indexOf('description') >= 0 || metaName.indexOf('keywords') >= 0) {
+ data.attributes = {};
+ }
+ }
+
+ } else if ('LINK' === data.tagName) {
+ if (data.attributes.rel) {
+ var link = String(data.attributes.rel).toLowerCase();
+ var blockedLinks = ['icon', 'preload', 'preconnect', 'dns-prefetch', 'next', 'prev', 'alternate', 'search']
+ if (blockedLinks.indexOf(link) >= 0) {
+ data.attributes = {};
+ }
+ }
+ if (data.attributes.href) {
+ // this is a local url that cannot be resolved when viewing the recording
+ var linkPos = String(data.attributes.href).toLowerCase().indexOf('.scr.kaspersky-labs.com');
+ if (linkPos > 5 && linkPos <= 20) {
+ data.attributes = {};
+ }
+ }
+ }
+
+ if (recursive && node.childNodes.length) {
+ data.childNodes = [];
+ for (var child = node.firstChild; child; child = child.nextSibling) {
+ data.childNodes.push(this.serializeNode(child, true, isIgnoredField, isIgnoredContent));
+ }
+ }
+ break;
+ }
+ return data;
+ };
+ TreeMirrorClient.prototype.serializeAddedAndMoved = function (added, reparented, reordered) {
+ var _this = this;
+ var all = added.concat(reparented).concat(reordered);
+ var parentMap = new MutationSummary.NodeMap();
+ all.forEach(function (node) {
+ var parent = node.parentNode;
+ var children = parentMap.get(parent);
+ if (!children) {
+ children = new MutationSummary.NodeMap();
+ parentMap.set(parent, children);
+ }
+ children.set(node, true);
+ });
+ var moved = [];
+ parentMap.keys().forEach(function (parent) {
+ var children = parentMap.get(parent);
+ var keys = children.keys();
+ while (keys.length) {
+ var node = keys[0];
+ while (node.previousSibling && children.has(node.previousSibling))
+ node = node.previousSibling;
+ while (node && children.has(node)) {
+ var data = _this.serializeNode(node);
+ data.previousSibling = _this.serializeNode(node.previousSibling);
+ data.parentNode = _this.serializeNode(node.parentNode);
+ moved.push(data);
+ children['delete'](node);
+ node = node.nextSibling;
+ }
+ var keys = children.keys();
+ }
+ });
+ return moved;
+ };
+ TreeMirrorClient.prototype.serializeAttributeChanges = function (attributeChanged) {
+ var _this = this;
+ var map = new MutationSummary.NodeMap();
+ Object.keys(attributeChanged).forEach(function (attrName) {
+ attributeChanged[attrName].forEach(function (element) {
+ var record = map.get(element);
+ if (!record) {
+ record = _this.serializeNode(element);
+ record.attributes = {};
+ map.set(element, record);
+ }
+ record.attributes[attrName] = element.getAttribute(attrName);
+ });
+ });
+ return map.keys().map(function (node) {
+ return map.get(node);
+ });
+ };
+ TreeMirrorClient.prototype.applyChanged = function (summaries) {
+ var _this = this;
+ var summary = summaries[0];
+ var removed = summary.removed.map(function (node) {
+ return _this.serializeNode(node);
+ });
+ var moved = this.serializeAddedAndMoved(summary.added, summary.reparented, summary.reordered);
+ var attributes = this.serializeAttributeChanges(summary.attributeChanged);
+ var text = summary.characterDataChanged.map(function (node) {
+ var data = _this.serializeNode(node);
+ data.textContent = node.textContent;
+ return data;
+ });
+ this.mirror.applyChanged(removed, moved, attributes, text);
+ summary.removed.forEach(function (node) {
+ _this.forgetNode(node);
+ });
+ };
+ return TreeMirrorClient;
+ })();
+}
+ /*!
+ * Copyright (C) InnoCraft Ltd - All rights reserved.
+ *
+ * All information contained herein is, and remains the property of InnoCraft Ltd.
+ *
+ * @link https://www.innocraft.com/
+ * @license For license details see https://www.innocraft.com/license
+ */
+
+ var documentAlias = document;
+ var windowAlias = window;
+
+ // we initialize it with zero by default but overwrite it as soon as page is ready. It is not really being used before that
+ var timeWhenPageReady = 0;
+ var debugMode = false;
+ var isHsrEnabled = !isExcluded();
+ var enableRecordMovements = true;
+ var customPiwikTrackers = null;
+ var isDOMloaded = false;
+ var hsrIdView = '';
+ var matchTrackerUrl = false;
+
+ var maxCapturingTimeStart = 15 * 60 * 1000; // 15 minutes
+ var maxCapturingTimeEnd = 30 * 60 * 1000; // 30 minutes we stop recording after 30 minutes in any case. This is to prevent cases were eg DOM is manipulated constantly on a page and session recording would keep sending requests.
+
+ var increaseMaxCaptureTimeWhenLessThanXRequests = 10; // when less than 10 requests so far, we record for another 5min
+ var maxCaptureTimeIncreaseWhenLessThanXRequests = (5 * 60 * 1000); // we give it another 5 minutes when there are less than 10 requests
+ var pixelOffsetAccuracy = 2000; // HAS TO MATCH SERVER SIDE VALUE AS WE CALCULATE PERCENTAGE OFFSET BASED ON THIS
+ var maxScrollAccuracy = 1000; // HAS TO MATCH SERVER SIDE VALUE AS WE CALCULATE SCROLL PERCENTAGE BASED ON THIS
+ var maxSampleRate = 100; // HAS TO MATCH SERVER SIDE VALUE MAX SAMPLE RATE
+ var maxLenTextInput = 500; // we limit for now to max 500 characters
+
+ // when a page is loaded, we cannot detect the mouse position until a user actually moves the mouse. This is a problem
+ // because in the replay the first mouse move is basically not shown. therefore, the first time we receive a mouse move
+ // event we will use this as initial mouse position. So instead of waiting for 100 or 200ms for the first mouse move
+ // we capture it directly without any delay. This way we can draw the first mouse move.
+ var isFirstMouseMoveEvent = false;
+
+ function isExcluded()
+ {
+ // we check whether it is a supported browser and not a bot, in most methods we can use more "modern" features that wouldn't work eg on IE6 or IE9
+ // some inital methods still need to work on all browsers though!
+ if ('object' !== typeof JSON) {
+ // not supported browser
+ return true;
+ }
+
+ if ('function' !== typeof Array.prototype.map || 'function' !== typeof Array.prototype.filter || 'function' !== typeof Array.prototype.indexOf) {
+ // needed by CSS selector and mutation summary
+ return true;
+ }
+
+ if ('function' !== typeof Element.prototype.getBoundingClientRect) {
+ // needed by our tracker
+ return true;
+ }
+
+ var blockedSites = ['cc.bingj.com'];
+ if (blockedSites.indexOf(documentAlias.domain) !== -1
+ || String(documentAlias.domain).indexOf('.googleusercontent.com') !== -1) {
+ return true;
+ }
+
+ var bot = /alexa|baidu|bing|bot|crawler|curl|crawling|duckduckgo|facebookexternalhit|feedburner|googlebot|google web preview|linkdex|nagios|postrank|pingdom|robot|slurp|spider|yahoo!|yandex|wget/i.test(navigator.userAgent);
+ if (bot) {
+ return true;
+ }
+
+ var topUrl = String(documentAlias.referrer);
+ if (topUrl && topUrl.indexOf('module=Overlay&action=startOverlaySession') >= 0) {
+ // do not record when shown in Piwik overlay
+ return true;
+ }
+
+ return false;
+ }
+
+ function logConsoleMessage() {
+ if (debugMode && 'object' === typeof console) {
+ if (typeof console.debug === 'function') {
+ console.debug.apply(console, arguments);
+ } else if (typeof console.log === 'function') {
+ console.log.apply(console, arguments);
+ }
+ }
+ }
+
+ var shouldTriggerRecording = function () {
+ // allows user to customize whether a user should be included in tracking or not
+ return true;
+ };
+
+ var EVENT_TYPE_MOVEMENT = 1;
+ var EVENT_TYPE_CLICK = 2;
+ var EVENT_TYPE_SCROLL = 3;
+ var EVENT_TYPE_RESIZE = 4;
+ var EVENT_TYPE_INITIAL_DOM = 5;
+ var EVENT_TYPE_MUTATION = 6;
+ var EVENT_TYPE_LINK_HSR = 7;
+ var EVENT_TYPE_PAGE_TREEMIRROR = 8;
+ var EVENT_TYPE_FORM_TEXT = 9;
+ var EVENT_TYPE_FORM_VALUE = 10;
+ var EVENT_TYPE_STOP_RECORDING = 11;
+ var EVENT_TYPE_SCROLL_ELEMENT = 12;
+
+ var RECORD_TYPE_BOTH = 0;
+ var RECORD_TYPE_HEATMAP = 1;
+ var RECORD_TYPE_SESSION = 2;
+
+ var recordKeystrokes = true;
+ var hasClicked = false;
+ var hasScrolled = false;
+ var autoDetectNewPageviews = true;
+ var timeLastEvent = null;
+
+ var pJson = {};
+ if ('object' === typeof JSON) {
+ // we need to initialize this by default because we need to use JSON before Matomo is loaded
+ pJson = JSON;
+ }
+
+ var isPluginInitialized = false;
+ var recordedDataBeforeTrackerSetup = [];
+
+ var mutation = {
+ hasObserver: function () {
+ if (typeof WebKitMutationObserver !== 'undefined') {
+ return true;
+ } else if (typeof MutationObserver !== 'undefined') {
+ return true;
+ }
+ // no need to check for MozMutationObserver as outdated
+
+ return false;
+ }
+ };
+
+ var canTrackSessionRecording = mutation.hasObserver();
+
+ var dom = {
+ getScrollLeft: function () {
+ return windowAlias.document.body.scrollLeft || windowAlias.document.documentElement.scrollLeft;
+ },
+ getScrollTop: function () {
+ return windowAlias.document.body.scrollTop || windowAlias.document.documentElement.scrollTop;
+ },
+ getDocumentHeight: function () {
+ // we use at least one px to prevent divisions by zero etc
+ return Math.max(documentAlias.body.offsetHeight, documentAlias.body.scrollHeight, documentAlias.documentElement.offsetHeight, documentAlias.documentElement.clientHeight, documentAlias.documentElement.scrollHeight, 1);
+ },
+ getDocumentWidth: function () {
+ // we use at least one px to prevent divisions by zero etc
+ return Math.max(documentAlias.body.offsetWidth, documentAlias.body.scrollWidth, documentAlias.documentElement.offsetWidth, documentAlias.documentElement.clientWidth, documentAlias.documentElement.scrollWidth, 1);
+ },
+ getWindowSize: function () {
+ var height = windowAlias.innerHeight || documentAlias.documentElement.clientHeight || documentAlias.body.clientHeight;
+ var width = windowAlias.innerWidth || documentAlias.documentElement.clientWidth || documentAlias.body.clientWidth;
+
+ return {width: width, height: height};
+ }
+ };
+
+ var storage = {
+ namespace: 'hsr', // has to match server side!
+ set: function (tracker, configId, value) {
+ configId = parseInt(configId, 10);
+ value = parseInt(value, 10);
+
+ var cookieValue = '';
+ var keys = storage.getHsrConfigs(tracker);
+ var found = false;
+ for (var i = 0; i < keys.length; i++) {
+ if (keys[i] && keys[i].id === configId) {
+ found = true;
+ keys[i].value = value;
+ }
+ cookieValue += keys[i].id + '.' + keys[i].value + '_';
+ }
+ if (!found) {
+ cookieValue += configId + '.' + value;
+ }
+ tracker.setSessionCookie(this.namespace, cookieValue);
+ },
+ get: function (tracker, configId) {
+ configId = parseInt(configId, 10);
+
+ var keys = storage.getHsrConfigs(tracker);
+ for (var i = 0; i < keys.length; i++) {
+ if (keys[i] && keys[i].id === configId) {
+ return keys[i].value;
+ }
+ }
+ return null;
+ },
+ getHsrConfigs: function (tracker) {
+ var value = tracker.getCookie(this.namespace);
+ if (!value) {
+ return [];
+ }
+
+ var keys = [];
+ var parts = String(value).split('_'), innerParts;
+ for (var i = 0; i < parts.length; i++) {
+ innerParts = parts[i].split('.');
+ if (innerParts && innerParts.length === 2) {
+ keys.push({id: parseInt(innerParts[0], 10), value: parseInt(innerParts[1], 10)});
+ }
+ }
+ return keys;
+ }
+ };
+
+ var element = {
+ getAttribute: function (node, attributeName) {
+ if (node && node.getAttribute && attributeName) {
+ return node.getAttribute(attributeName);
+ }
+
+ return null;
+ },
+ hasAttribute: function (node, attributeName) {
+ if (node && node.hasAttribute) {
+ return node.hasAttribute(attributeName);
+ }
+
+ if (node && node.attributes) {
+ var theType = (typeof node.attributes[attributeName]);
+ return theType !== 'undefined';
+ }
+
+ return false;
+ },
+ getTagName: function (node) {
+ if (node && node.tagName) {
+ return ('' + node.tagName).toLowerCase();
+ }
+
+ return null;
+ },
+ getCssClasses: function (node)
+ {
+ if (node && node.className) {
+ var classes = typeof node.className === "string" ? trackerUtils.trim(node.className).split(/\s+/) : [];
+ return classes;
+ }
+
+ return [];
+ },
+ getHeight: function(node) {
+ if (node && (node.nodeType === 9 || node.tagName === 'HTML')) {
+ return dom.getDocumentHeight();
+ }
+
+ if (node === window) {
+ return documentAlias.documentElement.clientHeight;
+ }
+
+ return Math.max(node.scrollHeight, node.offsetHeight, 0);
+ },
+ getWidth: function(node) {
+ if (node && (node.nodeType === 9 || node.tagName === 'HTML')) {
+ // In some browsers when the viewport is larger than the html element we need to use doc width / height.
+ // Eg in firefox, when viewport is larger than HTML element, we may run into this issue
+ return dom.getDocumentWidth();
+ }
+
+ if (node === window) {
+ return documentAlias.documentElement.clientWidth;
+ }
+
+ return Math.max(node.scrollWidth, node.offsetWidth, 0);
+ },
+ getOffset: function (node) {
+ if (!node.getBoundingClientRect) {
+ return {top: 0, left: 0, width: 0, height: 0};
+ }
+
+ var theDoc = (node && node.ownerDocument).documentElement;
+ var clientRect = node.getBoundingClientRect();
+
+ // we use Math.floor so when calculating like event.pageX - x.top then we more likely don't get a negative value
+ return {
+ top: Math.floor(clientRect.top) + (windowAlias.pageYOffset || documentAlias.scrollTop || 0) - (theDoc.clientTop || 0),
+ left: Math.floor(clientRect.left) + (windowAlias.pageXOffset || documentAlias.scrollLeft || 0) - (theDoc.clientLeft || 0),
+ width: Math.max(clientRect.width, element.getWidth(node)),
+ height: Math.max(clientRect.height, element.getHeight(node))
+ };
+ },
+ getSelector: function (node, selector) {
+ return UTILS.cssPath(node, false);
+ },
+ maskFormField: function (text, variableLength) {
+ if (!text) {
+ return text;
+ }
+ text = String(text).replace(/./g, '*');
+ if (variableLength) {
+ // useful to eg better anonymize password length
+ var rand = Math.floor(Math.random() * 10) + 1;
+ text = text + (new Array(rand + 1).join('*'));
+ }
+ return text;
+ },
+ shouldMaskField: function (node, shouldCheckParents) {
+ if (!node) {
+ return false;
+ }
+
+ var type = element.getAttribute(node, 'type');
+ if (!type) {
+ type = 'text';
+ } else {
+ type = String(type).toLowerCase();
+ }
+ var isSelectField = type === 'radio' || type === 'checkbox' || (node.nodeName && node.nodeName === 'SELECT');
+
+ if (!recordKeystrokes) {
+ if (isSelectField) {
+ return false; // still record select fields
+ }
+ return true;
+ }
+
+ var attrName = element.getAttribute(node, 'name');
+ var attrId = element.getAttribute(node, 'id');
+ var attrAutoCo = element.getAttribute(node, 'autocomplete');
+
+ attrName = trackerUtils.trim(String(attrName)).toLowerCase().replace(/[\s_-]+/g, '');
+ attrId = trackerUtils.trim(String(attrId)).toLowerCase().replace(/[\s_-]+/g, '');
+ attrAutoCo = trackerUtils.trim(String(attrAutoCo)).toLowerCase().replace(/[\s_-]+/g, '');
+
+ var blockedFields = ['creditcardnumber', 'off', 'kreditkarte', 'debitcard', 'kreditkort', 'kredietkaart', ' kartakredytowa', 'cvv', 'cc', 'ccc', 'cccsc', 'cccvc', 'ccexpiry', 'ccexpyear', 'ccexpmonth', 'cccvv', 'cctype', 'cvc', 'exp', 'ccname', 'cardnumber', 'ccnumber', 'username', 'creditcard', 'name', 'fullname', 'familyname', 'firstname', 'vorname', 'nachname', 'lastname', 'nickname', 'surname', 'login', 'formlogin', 'konto', 'user', 'website', 'domain', 'gender', 'company', 'firma', 'geschlecht', 'email', 'emailaddress', 'emailadresse', 'mail', 'epos', 'ebost', 'epost', 'eposta', 'authpw', 'token_auth', 'tokenauth', 'token', 'pin', 'ibanaccountnum', 'ibanaccountnumber', 'account', 'accountnum', 'auth', 'age', 'alter', 'tel', 'city', 'cell', 'cellphone', 'bic', 'iban', 'swift', 'kontonummer', 'konto', 'kontonr', 'phone', 'mobile', 'mobiili', 'mobilne', 'handynummer', 'téléphone', 'telefono', 'ssn', 'socialsecuritynumber', 'socialsec', 'socsec', 'address', 'addressline1', 'addressline2','billingaddress', 'billingaddress1', 'billingaddress2','shippingaddress', 'shippingaddress1', 'shippingaddress2', 'vat', 'vatnumber', 'gst', 'gstnumber', 'tax', 'taxnumber', 'steuernummer', 'adresse', 'indirizzo', 'adres', 'dirección', 'osoite', 'address1', 'address2', 'address3', 'street', 'strasse', 'rue', 'via', 'ulica', 'calle', 'sokak', 'zip', 'zipcode', 'plz', 'postleitzahl', 'postalcode', 'postcode', 'dateofbirth', 'dob', 'telephone', 'telefon', 'telefonnr', 'telefonnummer', 'password', 'passwort', 'kennwort', 'wachtwoord', 'contraseña', 'passord', 'hasło', 'heslo', 'wagwoord', 'parole', 'contrasenya', 'heslo', 'clientid', 'identifier', 'id', 'consumersecret', 'webhooksecret', 'consumerkey', 'keyconsumersecret', 'keyconsumerkey', 'clientsecret', 'secret', 'secretq', 'secretquestion', 'privatekey', 'publickey', 'pw', 'pwd', 'pwrd', 'pword', 'paword', 'pasword', 'paswort', 'pass'];
+
+ if (type === 'password'
+ || type === 'email'
+ || type === 'tel'
+ || type === 'hidden'
+ || blockedFields.indexOf(attrName) !== -1
+ || blockedFields.indexOf(attrId) !== -1
+ || blockedFields.indexOf(attrAutoCo) !== -1
+ || element.hasAttribute(node, 'data-piwik-mask')
+ || element.hasAttribute(node, 'data-matomo-mask')) {
+ return true;
+ }
+
+ if (!isSelectField && node && node.value) {
+ if (!type || type === 'text' || type === 'number' || (node && node.nodeName === 'TEXTAREA')) {
+ if (/^\d{7,24}$/.test(String(node.value))) {
+ // when entering 12-21 digits, we assume it is credit card. Longest credit card has 19 characters
+ // but users may enter too many characters. we also want to avoid some phone numbers etc
+ return true;
+ }
+
+ if (String(node.value).indexOf('@') !== -1 && String(node.value).length > 2) {
+ // user might be entering an email address, we force the masking of it
+ return true;
+ }
+ }
+ }
+
+ if (shouldCheckParents) {
+ var parent = node.parentNode ? node.parentNode : null;
+ var hasUnmask = false;
+
+ // check if any parent node has data-piwik-mask. We only do this when requested for performance reasons
+ while (parent) {
+ if (element.hasAttribute(parent, 'data-piwik-mask')
+ || element.hasAttribute(parent, 'data-matomo-mask')) {
+ return true;
+ } else {
+ if (!hasUnmask && parent && element.hasAttribute(parent, 'data-matomo-unmask')) {
+ // if any mask is set on any parent, this unmask is supposed to be ignored!
+ hasUnmask = true;
+ }
+ parent = parent.parentNode ? parent.parentNode : null;
+ }
+ }
+
+ if (hasUnmask) {
+ return false;
+ }
+ }
+
+ if (element.hasAttribute(node, 'data-matomo-unmask')) {
+ return false;
+ }
+
+ if (isSelectField) {
+ return false; // it is fine to record this select field
+ }
+
+ return true;
+ },
+ shouldMaskContent: function (node, shouldCheckParents) {
+ if (!node) {
+ return false;
+ }
+
+ if (node.tagName && node.tagName !== 'FORM' && element.hasAttribute(node, 'data-matomo-mask')) {
+ return true;
+ }
+
+ if (shouldCheckParents) {
+ var parent = node.parentNode ? node.parentNode : null;
+
+ // check if any parent node has data-matomo-mask. We only do this when requested for performance reasons
+ // when defined on a form element, it will apply to all form elements within that container
+ while (parent) {
+ if (parent.tagName !== 'FORM' && element.hasAttribute(parent, 'data-matomo-mask')) {
+ return true;
+ } else {
+ parent = parent.parentNode ? parent.parentNode : null;
+ }
+ }
+ }
+
+ return false;
+ }
+ };
+
+ var trackerUtils = {
+ isArray: function (variable) {
+ return typeof variable === 'object' && variable !== null && typeof variable.length === 'number';
+ },
+ getCurrentTime: function () {
+ return new Date().getTime();
+ },
+ getTimeSincePageReady: function () {
+ if (!timeWhenPageReady) {
+ return 0;
+ }
+ return (new Date().getTime()) - timeWhenPageReady;
+ },
+ roundTimeToSeconds: function (timeInMs) {
+ return Math.round(timeInMs / 1000);
+ },
+ getRandomInt: function (min, max) {
+ return Math.round(Math.random() * (max - min) + min);
+ },
+ isNumber: function (text) {
+ return !isNaN(text);
+ },
+ trim: function (text)
+ {
+ if (text && String(text) === text) {
+ return text.replace(/^\s+|\s+$/g, '');
+ }
+
+ return text;
+ },
+ generateUniqueId: function () {
+ var id = '';
+ var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+ var charLen = chars.length;
+
+ for (var i = 0; i < 6; i++) {
+ id += chars.charAt(Math.floor(Math.random() * charLen));
+ }
+
+ return id;
+ }
+ };
+
+ function hasParameterInUrl(parameter)
+ {
+ return location.href && location.href.indexOf(parameter) > 0;
+ }
+
+ function shouldForceBeInSample()
+ {
+ return hasParameterInUrl('pk_hsr_forcesample=1') || hasParameterInUrl('pk_hsr_capturescreen=1');
+ }
+
+ function shouldForceNotBeInSample()
+ {
+ return hasParameterInUrl('pk_hsr_forcesample=0');
+ }
+
+ function isInTestGroup(sampleRate)
+ {
+ if (shouldForceBeInSample()) {
+ // when we take a screenshot, the user also needs to be included otherwise it won't be possible to capture
+ // the screen.
+ return true;
+ }
+
+ if (shouldForceNotBeInSample()) {
+ return false;
+ }
+
+ if (sampleRate >= 100) {
+ return true;
+ }
+
+ if (sampleRate <= 0) {
+ return false;
+ }
+
+ if (sampleRate >= 1) {
+ return sampleRate >= trackerUtils.getRandomInt(1, maxSampleRate);
+ }
+
+ // eg when 0.1
+ return (sampleRate * 10) >= trackerUtils.getRandomInt(1, maxSampleRate * 10);
+ }
+
+ function enrichTracker(tracker)
+ {
+ if ('undefined' !== typeof tracker.HeatmapSessionRecording) {
+ return;
+ }
+
+ tracker.HeatmapSessionRecording = {
+ myId: trackerUtils.generateUniqueId(),
+ hasReceivedConfig: false,
+ hasTrackedData: false,
+ hasSentStopTrackingEvent: false,
+ enabled: true,
+ hsrIdsToGetDOM: [],
+ disable: function () {
+ this.enabled = false;
+ },
+ enable: function () {
+ this.enabled = true;
+ },
+ isEnabled: function () {
+ return isHsrEnabled && this.enabled;
+ },
+ numSentTrackingRequests: 0,
+ Heatmap: {
+ data: [], // holds all data that needs to be tracked
+ hsrids: [], // as soon as one of those configuration is "active / condition met", the hsrid of the config is added here
+ configs: [], // here we hold all configurations
+ addConfig: function (config) {
+ if ('object' !== typeof config || !config.id) {
+ return;
+ }
+
+ config.id = parseInt(config.id, 10);
+
+ this.configs.push(config);
+
+ if ('undefined' === typeof config.sample_rate) {
+ config.sample_rate = maxSampleRate;
+ } else {
+ // we need to make sure to limit to max value
+ config.sample_rate = Math.min(parseFloat(config.sample_rate), maxSampleRate);
+ }
+
+ // a heatmap is so far always immediately enabled
+ if (config.id && isInTestGroup(config.sample_rate) && shouldTriggerRecording(config)) {
+ this.addHsrId(config.id);
+
+ if (config.getdom || hasParameterInUrl('pk_hsr_capturescreen=1')) {
+ tracker.HeatmapSessionRecording.hsrIdsToGetDOM.push(config.id);
+ }
+ }
+ },
+ addHsrId: function (idSiteHsr) {
+ this.hsrids.push(idSiteHsr);
+
+ if (tracker.HeatmapSessionRecording.hasTrackedData) {
+ // we record it as an event so it makes sure to be only tracked if we also send other data
+ tracking.recordData(RECORD_TYPE_HEATMAP, {ty: EVENT_TYPE_LINK_HSR, id: idSiteHsr});
+ }
+ }
+ },
+ Both: {
+ data: []
+ },
+ Session: {
+ data: [], // holds all data that needs to be tracked
+ hsrids: [], // as soon as one of those configuration is "active / condition met", the hsrid of the config is added here
+ configs: [], // here we hold all configurations
+ addConfig: function (config) {
+ if ('object' !== typeof config || !config.id) {
+ return;
+ }
+
+ config.id = parseInt(config.id, 10);
+
+ if ('undefined' === typeof config.sample_rate) {
+ config.sample_rate = maxSampleRate;
+ } else {
+ // we need to make sure to limit to max value
+ config.sample_rate = Math.min(parseFloat(config.sample_rate), maxSampleRate);
+ }
+
+ config.conditionsMet = false;
+
+ this.configs.push(config);
+
+ var idSite = parseInt(tracker.getSiteId(), 10);
+ var storageValue = storage.get(tracker, config.id);
+
+ if (1 === storageValue && !shouldForceNotBeInSample()) {
+ // we make sure user will be directly recorded, but only if user did not force to be excluded manually via url
+ config.sample_rate = maxSampleRate;
+ config.activity = false;
+ config.min_time = 0;
+ } else if (shouldForceBeInSample()) {
+ // user forced to be recorded, we ignore any stored value or test group
+ } else if (0 === storageValue || !isInTestGroup(config.sample_rate)) {
+ // visitor is not in sample group, we always set cookie again to extend value
+ // unless user forced to be in test group
+ storage.set(tracker, config.id, 0);
+ return;
+ }
+
+ this.checkConditionsMet();
+
+ if (config.min_time) {
+ // there might be a little race conditions if this is called when page is not ready yet,
+ // and later timeSincePageReady is "changed / resetted" then this won't become "true".
+
+ var self = this;
+ Piwik.DOM.onReady(function () {
+ // we add 120ms extra just to make sure we are over the session time
+ var timeoutMs = (config.min_time * 1000) - trackerUtils.getTimeSincePageReady() + 120;
+ if (timeoutMs >= 0) {
+ setTimeout(function () {
+ self.checkConditionsMet();
+ }, timeoutMs);
+ } else {
+ self.checkConditionsMet();
+ }
+ });
+ }
+ },
+ checkConditionsMet: function () {
+ var config;
+ for (var i = 0; i < this.configs.length; i++) {
+ config = this.configs[i];
+ if (config && !config.conditionsMet) {
+ var met = true;
+
+ if (config.min_time && config.min_time >= trackerUtils.roundTimeToSeconds(trackerUtils.getTimeSincePageReady())) {
+ met = false;
+ }
+
+ if (config.activity && !hasScrolled) {
+ // check if scrollbars are shown at all, if not we set it as scrolled
+ hasScrolled = dom.getDocumentHeight() <= dom.getWindowSize().height;
+ }
+
+ if (config.activity && (!hasClicked || !hasScrolled)) {
+ met = false;
+ }
+
+ if (met) {
+ config.conditionsMet = true;
+
+ if (shouldTriggerRecording(config)) {
+ if ('undefined' === typeof config.keystrokes || !config.keystrokes || config.keystrokes === '0') {
+ // as soon as one disables it, we need to disable them all, not possible to do it differently
+ // as they are all linked with each other and also affect already recorded / tracked mutations
+ // etc.
+ recordKeystrokes = false;
+ }
+
+ this.addHsrId(config.id);
+ }
+ }
+ }
+ }
+ },
+ addHsrId: function (idSiteHsr) {
+ this.hsrids.push(idSiteHsr);
+
+ if (tracker.HeatmapSessionRecording.hasTrackedData) {
+ // we record it as an event so it makes sure to be only tracked if we also send other data
+ // if we sent this as a separate request, we risk to link this ID even though we never sent
+ // any initial dom
+ tracking.recordData(RECORD_TYPE_SESSION, {ty: EVENT_TYPE_LINK_HSR, id: idSiteHsr});
+ }
+
+ var idSite = parseInt(tracker.getSiteId(), 10);
+
+ // remember recording of this visitor to also record next page view
+ storage.set(tracker, idSiteHsr, 1);
+ }
+ },
+ addConfig: function (config) {
+ this.hasReceivedConfig = true;
+
+ if ('undefined' === typeof config || !config) {
+ configuration.checkAllConfigsReceived();
+ return;
+ }
+
+ if ('object' === typeof config.heatmap) {
+ this.Heatmap.addConfig(config.heatmap);
+ }
+
+ var i;
+
+ if (config.heatmaps && trackerUtils.isArray(config.heatmaps) && config.heatmaps.length) {
+ for (i = 0; i < config.heatmaps.length; i++) {
+ this.Heatmap.addConfig(config.heatmaps[i]);
+ }
+ }
+ if (canTrackSessionRecording) {
+ if (config.sessions && trackerUtils.isArray(config.sessions) && config.sessions.length) {
+ for (i = 0; i < config.sessions.length; i++) {
+ this.Session.addConfig(config.sessions[i]);
+ }
+ }
+
+ if ('object' === typeof config.session) {
+ this.Session.addConfig(config.session);
+ }
+ }
+
+ configuration.checkAllConfigsReceived();
+ }
+ };
+ }
+
+ var initialWindowSize = dom.getWindowSize();
+
+ var tracking = {
+ getPiwikTrackers: function ()
+ {
+ if (null === customPiwikTrackers) {
+ if ('object' === typeof Piwik && Piwik.getAsyncTrackers) {
+ var trackers = Piwik.getAsyncTrackers();
+
+ if (!trackers || !trackers.length) {
+ return [];
+ }
+
+ return trackers;
+ }
+ }
+
+ if (trackerUtils.isArray(customPiwikTrackers)) {
+ return customPiwikTrackers;
+ }
+
+ return [];
+ },
+ sendQueuedData: function (tracker, shouldEndRecording) {
+ if (!isDOMloaded || !hsrIdView) {
+ // we start tracking only after onload event so we know everything is loaded and rendered and we
+ // know full document size and can calculate scroll max percentage, fold percentage etc correctly.
+ return;
+ }
+
+ if (!tracker || !tracker.HeatmapSessionRecording) {
+ return;
+ }
+
+ var hsr = tracker.HeatmapSessionRecording;
+
+ if (!hsr.isEnabled()) {
+ return;
+ }
+
+ var hsrIds = [];
+ var queuedData = [];
+
+ if (hsr.Heatmap.hsrids && hsr.Heatmap.hsrids.length) {
+ // we always need to add all active hsrids, otherwise, on first request, there might not be any heatmap
+ // data yet and then we would never assign these hsrids so we always send those ids no matter if there
+ // is any data or not
+ hsrIds = hsr.Heatmap.hsrids;
+
+ if (hsr.Heatmap.data.length) {
+ queuedData = hsr.Heatmap.data;
+ hsr.Heatmap.data = [];
+ }
+ }
+
+ var isUsingSessionRecording = hsr.Session.hsrids && hsr.Session.hsrids.length && recording.initialDOM;
+ if (isUsingSessionRecording) {
+ // we always need to add all active hsrids, otherwise, on first request, there might not be any session
+ // data yet and then we would never assign these hsrids so we always send those ids no matter if there
+ // is any data or not
+
+ // we also need to make sure we have the initial DOM, otherwise, if this is not tracked, replaying
+ // the session won't work. even better would be to have a separate flag whether we had it at least
+ // once in session.data.length or so
+ hsrIds = hsrIds.concat(hsr.Session.hsrids);
+
+ if (hsr.Session.data.length) {
+ queuedData = queuedData.concat(hsr.Session.data);
+ hsr.Session.data = [];
+
+ if (!recordKeystrokes) {
+ // we again make sure to not send any keystrokes if disabled. This happens when eg
+ // we record some keystrokes before any of the tracker is initialized or has received a config
+ for (var i = (queuedData.length - 1); i >= 0; i--) {
+ if (queuedData[i] && queuedData[i].ty && queuedData[i].ty === EVENT_TYPE_FORM_TEXT) {
+ queuedData.splice(i, 1);
+ }
+ }
+ }
+ }
+ }
+
+ if (hsrIds.length && hsr.Both.data.length) {
+ // we track Both only if there is any heatmap or session recording to be tracked
+ queuedData = queuedData.concat(hsr.Both.data);
+ hsr.Both.data = [];
+ }
+
+ if ('undefined' === typeof shouldEndRecording) {
+ shouldEndRecording = this.shouldEndRecording(tracker);
+ }
+
+ if (shouldEndRecording && hsr.hasTrackedData && !hsr.hasSentStopTrackingEvent && isUsingSessionRecording) {
+ // we do not send a stop event when user was not active before that (no tracking request)
+ queuedData.push({ty: EVENT_TYPE_STOP_RECORDING});
+
+ // will be set to true if the tracking of a session or heatmap finishes "early". Set to make sure we do not track this event twice on unload again
+ hsr.hasSentStopTrackingEvent = true;
+ }
+
+ if (!hsrIds || !hsrIds.length || !queuedData || !queuedData.length) {
+ return;
+ }
+
+ // we only add initial dom once when needing to get dom, but only if we send data anyway
+ if (tracker.HeatmapSessionRecording.hsrIdsToGetDOM && tracker.HeatmapSessionRecording.hsrIdsToGetDOM.length) {
+ if (!recording.initialDOM && canTrackSessionRecording) {
+ var mirror = new TreeMirrorClient(documentAlias, {
+ initialize: function(rootId, children) {
+ // happens when no session recording is active at the same time
+ recording.initialDOM = pJson.stringify({
+ rootId: rootId,
+ children: children
+ });
+ }
+ });
+ mirror.disconnect();
+ }
+
+ if (recording.initialDOM && canTrackSessionRecording) {
+ for (var index = 0; index < tracker.HeatmapSessionRecording.hsrIdsToGetDOM.length; index++) {
+ queuedData.push({
+ ty: EVENT_TYPE_PAGE_TREEMIRROR,
+ dom: recording.initialDOM,
+ id: tracker.HeatmapSessionRecording.hsrIdsToGetDOM[index]
+ });
+ }
+
+ tracker.HeatmapSessionRecording.hsrIdsToGetDOM = [];
+ }
+ }
+
+ hsr.hasTrackedData = true;
+
+ this.sendQueuedDataRequestNow(tracker, hsrIds, queuedData);
+
+ if (shouldEndRecording) {
+ // we stop the recording at the end to make sure all other queued events will be still tracked
+ // this is useful if there were eg a few events just before the maxCaptureTime. In this case we make
+ // sure to still send them
+
+ // this is not 100% multi tracker safe and will also directly disable it for multiple trackers meaning
+ // for them likely the recording end date won't be sent
+ Piwik.HeatmapSessionRecording.disable();
+ }
+ },
+ shouldEndRecording: function (tracker) {
+ var timeSinceLoad = trackerUtils.getTimeSincePageReady();
+
+ if (maxCapturingTimeEnd < timeSinceLoad) {
+ // we force stop recording eg after 30 minutes
+ return true;
+ }
+
+ if (maxCapturingTimeStart < timeSinceLoad) {
+ var hsr = tracker.HeatmapSessionRecording;
+ var hasSentFewRequestsSoFar = !hsr.numSentTrackingRequests || hsr.numSentTrackingRequests <= increaseMaxCaptureTimeWhenLessThanXRequests;
+ var msOneMinute = 60 * 1000;
+ var wasActiveInLastMinute = timeLastEvent && (timeSinceLoad < (msOneMinute + timeLastEvent));
+
+ if (hasSentFewRequestsSoFar || wasActiveInLastMinute) {
+ // we increase max capture time eg by another 5 minutes
+ maxCapturingTimeStart = timeSinceLoad + maxCaptureTimeIncreaseWhenLessThanXRequests;
+ } else {
+ return true;
+ }
+ }
+
+ return false;
+ },
+ sendQueuedDataRequestNow: function (tracker, hsrIds, events) {
+
+ var data = '';
+ for (var i = 0; i < events.length; i++) {
+ for (var name in events[i]) {
+ if (Object.prototype.hasOwnProperty.call(events[i], name)) {
+ data += '&hsr_ev[' + i + '][' + name + ']=' + encodeURIComponent(events[i][name]);
+ }
+ }
+ }
+
+ for (var index = 0; index < hsrIds.length; index++) {
+ data += '&hsr_ids[]=' + encodeURIComponent(hsrIds[index]);
+ }
+
+ var requestUrl = 'hsr_vid=' + hsrIdView + data;
+ var windowSize = dom.getWindowSize();
+ var docHeight = dom.getDocumentHeight();
+
+ if (!recording.scrollMaxPercentage) {
+ // recording.scrollMaxPercentage is only set after first scroll so before we have to detect ourselves
+ var lastScrollTop = dom.getScrollTop();
+ recording.scrollMaxPercentage = parseInt(((windowSize.height + lastScrollTop) / docHeight) * maxScrollAccuracy, 10);
+ }
+
+ requestUrl += '&hsr_vw=' + encodeURIComponent(windowSize.width);
+ requestUrl += '&hsr_vh=' + encodeURIComponent(windowSize.height);
+ requestUrl += '&hsr_ti=' + trackerUtils.getTimeSincePageReady();
+ requestUrl += '&hsr_smp=' + recording.scrollMaxPercentage;
+ requestUrl += '&hsr_fyp=' + parseInt((initialWindowSize.height / docHeight) * maxScrollAccuracy, 10);
+
+ tracker.HeatmapSessionRecording.numSentTrackingRequests++;
+ tracker.trackRequest(requestUrl, null, null, 'HeatmapSessionRecording');
+
+ logConsoleMessage('track: ' + requestUrl);
+ },
+ recordData: function (dataType, data) {
+ if (!isPluginInitialized) {
+ recordedDataBeforeTrackerSetup.push({type: dataType, data: data});
+ return;
+ }
+
+ var trackers = tracking.getPiwikTrackers();
+ trackers.forEach(function (tracker) {
+ if (tracker.HeatmapSessionRecording && tracker.HeatmapSessionRecording.isEnabled()) {
+ if ('object' === typeof data && 'undefined' !== typeof data.ti
+ && data.ti && (!timeLastEvent || data.ti > timeLastEvent)
+ && data.ty && data.ty !== EVENT_TYPE_MUTATION) {
+ // we ignore mutation events, these are not active events where the user did something
+ // prevents extending the maxCapturingTime in case the website keeps updating the page
+ timeLastEvent = data.ti;
+ }
+
+ if (RECORD_TYPE_BOTH === dataType) {
+ tracker.HeatmapSessionRecording.Both.data.push(data);
+ } else if (RECORD_TYPE_HEATMAP === dataType) {
+ tracker.HeatmapSessionRecording.Heatmap.data.push(data);
+ } else if (RECORD_TYPE_SESSION === dataType) {
+ tracker.HeatmapSessionRecording.Session.data.push(data);
+ }
+ }
+ });
+
+ if (debugMode) {
+ logConsoleMessage('recorddata', pJson.stringify(data));
+ }
+ },
+ stopSendingData: function () {
+ var trackers = tracking.getPiwikTrackers();
+ trackers.forEach(function (tracker) {
+ if (tracker.HeatmapSessionRecording) {
+ var hsr = tracker.HeatmapSessionRecording;
+ if ('undefined' !== typeof hsr.trackingInterval) {
+ // cleanup resources to not run interval every second forever
+ clearInterval(hsr.trackingInterval);
+ delete hsr.trackingInterval;
+ }
+ }
+ });
+ },
+ startSendingData: function () {
+ var trackers = tracking.getPiwikTrackers();
+ trackers.forEach(function (tracker) {
+ if (tracker.HeatmapSessionRecording
+ && 'undefined' === typeof tracker.HeatmapSessionRecording.trackingInterval) {
+ // we make sure it has not been set up yet and is not currently sending data already
+
+ // if there are multiple trackers, we do not want to fire them at the same time
+ var intervalMs = trackerUtils.getRandomInt(1250, 1450);
+ tracker.HeatmapSessionRecording.trackingInterval = setInterval(function () {
+ tracking.sendQueuedData(tracker);
+ }, intervalMs);
+
+ // don't wait for another second to send first data, start sending possibly queued data now
+ tracking.sendQueuedData(tracker);
+ }
+ });
+ }
+ };
+
+ function callAsyncReadyMethod()
+ {
+ if (typeof window === 'object' && 'function' === typeof windowAlias.piwikHeatmapSessionRecordingAsyncInit) {
+ windowAlias.piwikHeatmapSessionRecordingAsyncInit();
+ }
+
+ var eventsQueued = recordedDataBeforeTrackerSetup;
+ recordedDataBeforeTrackerSetup = [];
+ isPluginInitialized = true;
+
+ // replay previously recorded events
+ for (var i = 0; i < eventsQueued.length; i++) {
+ tracking.recordData(eventsQueued[i].type, eventsQueued[i].data);
+ }
+
+ // we can only add this code after async init to make sure we recognize all parameters and configurations
+ // and to make sure piwik trackers have been configured
+ Piwik.DOM.onLoad(function () {
+ isDOMloaded = true;
+
+ if (isHsrEnabled) {
+ var trackers = tracking.getPiwikTrackers();
+ if (trackers && trackers.length) {
+ // only if it has not been disabled meanwhile by a user and if there is actually a tracker configured
+ // we call the method only to start the recording, not to actually enable it
+ Piwik.HeatmapSessionRecording.enable();
+ }
+ }
+ });
+ }
+
+ var recording = {
+ moveEvents: ['mousemove', 'touchmove'],
+ clickEvents: ['mousedown'],
+ scrollEvents: ['scroll', 'resize'],
+ lastScroll: null,
+ lastElementScroll: null,
+ lastMove: null,
+ lastResize: null,
+ scrollMaxPercentage: 0,
+ lastResizeInterval: null,
+ lastScrollInterval: null,
+ lastMoveInterval: null,
+ isRecording: false,
+ isRecordingMutations: false,
+ startRecording: function()
+ {
+ if (!isHsrEnabled || this.isRecording) {
+ return;
+ }
+
+ this.isRecording = true;
+
+ // append each recorded data individually to each tracker so they can individually start sending data
+ // once data has arrived for a tracker. also depending on which tracker heatmap or session is enabled
+ // we need to track different data per tracker
+
+ this.lastScrollInterval = setInterval(function () {
+ if (recording.lastScroll) {
+ var scroll = recording.lastScroll;
+ recording.lastScroll = null;
+
+ var data = {
+ ti: scroll.time,
+ ty: EVENT_TYPE_SCROLL,
+ x: scroll.scrollX,
+ y: scroll.scrollY
+ };
+
+ tracking.recordData(RECORD_TYPE_SESSION, data);
+ }
+ if (recording.lastElementScroll) {
+ var scroll = recording.lastElementScroll;
+ recording.lastElementScroll = null;
+
+ var data = {
+ ti: scroll.time,
+ ty: EVENT_TYPE_SCROLL_ELEMENT,
+ s: scroll.selector,
+ x: scroll.scrollX,
+ y: scroll.scrollY
+ };
+
+ tracking.recordData(RECORD_TYPE_SESSION, data);
+ }
+ }, 200);
+
+ this.lastResizeInterval = setInterval(function () {
+ if (recording.lastResize) {
+ var resize = recording.lastResize;
+ recording.lastResize = null;
+ var data = {
+ ti: resize.ti,
+ ty: EVENT_TYPE_RESIZE,
+ x: resize.width,
+ y: resize.height
+ };
+
+ tracking.recordData(RECORD_TYPE_SESSION, data);
+ }
+ }, 200);
+
+ // also listen to double click?
+
+ this.lastMoveInterval = setInterval(function () {
+ if (recording.lastMove) {
+ var move = recording.lastMove;
+ recording.lastMove = null;
+ var data = {
+ ti: move.time,
+ ty: EVENT_TYPE_MOVEMENT,
+ s: move.selector,
+ x: move.offsetx,
+ y: move.offsety
+ };
+
+ tracking.recordData(RECORD_TYPE_BOTH, data);
+ }
+ }, 200);
+
+ this.scrollEvents.forEach(function (eventName) {
+ windowAlias.addEventListener(eventName, recording.onScroll, true);
+ });
+
+ this.clickEvents.forEach(function (eventName) {
+ windowAlias.addEventListener(eventName, recording.onClick, true);
+ });
+
+ this.moveEvents.forEach(function (eventName) {
+ windowAlias.addEventListener(eventName, recording.onMove, true);
+ });
+ },
+ mirror: null,
+ initialDOM: null,
+ startRecordingMutations: function () {
+ if (!isHsrEnabled || !canTrackSessionRecording || this.isRecordingMutations) {
+ // either already set up or does not support observer
+ return;
+ }
+
+ this.isRecordingMutations = true;
+
+ windowAlias.addEventListener('resize', recording.onResize, true);
+ windowAlias.addEventListener('change', recording.onFormChange, true);
+
+ try {
+ this.mirror = new TreeMirrorClient(document, {
+ initialize: function(rootId, children) {
+ // track initial dom mutations
+ var data = {
+ ty: EVENT_TYPE_INITIAL_DOM,
+ ti: 0,
+ te: pJson.stringify({rootId: rootId, children: children})
+ };
+
+ if (!recording.initialDOM) {
+ recording.initialDOM = data.te;
+ }
+
+ tracking.recordData(RECORD_TYPE_SESSION, data);
+ },
+ applyChanged: function(removed, addedOrMoved, attributes, text) {
+ if (removed.length || addedOrMoved.length || attributes.length || text.length) {
+ var data = {
+ ti: trackerUtils.getTimeSincePageReady(),
+ ty: EVENT_TYPE_MUTATION,
+ te: {}
+ };
+ if (removed.length) {
+ data.te.rem = removed;
+ }
+ if (addedOrMoved.length) {
+ data.te.adOrMo = addedOrMoved;
+ }
+ if (attributes.length) {
+ data.te.att = attributes;
+ }
+ if (text.length) {
+ data.te.text = text;
+ }
+
+ data.te = pJson.stringify(data.te);
+
+ tracking.recordData(RECORD_TYPE_SESSION, data);
+ }
+ }
+ });
+
+ } catch (e) {
+ logConsoleMessage(e);
+ }
+ },
+ onResize: function () {
+ // todo check if e.target === window?
+
+ var size = dom.getWindowSize();
+
+ recording.lastResize = {
+ ti: trackerUtils.getTimeSincePageReady(),
+ width: size.width,
+ height: size.height
+ };
+ },
+ onFormChange: function (e) {
+ if (!('target' in e) || !e.target) {
+ return;
+ }
+
+ var node = e.target;
+ var tagName = element.getTagName(node);
+
+ if (!tagName) {
+ return;
+ }
+
+ var time = trackerUtils.getTimeSincePageReady();
+ var eventType = EVENT_TYPE_FORM_VALUE;
+ var isCheckField = false;
+
+ if (tagName === 'input') {
+ var fieldType = element.getAttribute(node, 'type');
+
+ if (String(fieldType).toLowerCase() === 'radio' || String(fieldType).toLowerCase() === 'checkbox') {
+ isCheckField = true;
+ } else {
+ // we ignore this as we handle this as in onFormChange
+ eventType = EVENT_TYPE_FORM_TEXT;
+ }
+ } else if (tagName === 'textarea') {
+ eventType = EVENT_TYPE_FORM_TEXT;
+ } else if (tagName !== 'select') {
+ return;
+ }
+
+ if (!recordKeystrokes && eventType === EVENT_TYPE_FORM_TEXT) {
+ // recording of any text is disabled
+ return;
+ }
+
+ var selector = element.getSelector(node);
+
+ var text = '';
+ if (isCheckField) {
+ text = node.checked ? '1' : '0';
+ } else if (eventType === EVENT_TYPE_FORM_TEXT && 'undefined' !== typeof node.value) {
+ text = String(node.value);
+ if (text > maxLenTextInput) {
+ text = text.substr(0, maxLenTextInput);
+ }
+
+ if (element.shouldMaskField(node, true)) {
+ text = element.maskFormField(text, element.getAttribute(node, 'type') === 'password');
+ }
+ } else if (eventType === EVENT_TYPE_FORM_VALUE && 'undefined' !== typeof node.value) {
+ text = String(node.value);
+ }
+
+ var data = {
+ ti: time,
+ ty: eventType,
+ s: selector,
+ te: text
+ };
+
+ if (selector) {
+ tracking.recordData(RECORD_TYPE_SESSION, data);
+ } else {
+ logConsoleMessage('No selector found for text input ', e);
+ }
+ },
+ onScroll: function(event) {
+ if (!hasScrolled) {
+ // eg when a session requires a scroll and a click in order to actually track a recording
+ hasScrolled = true;
+ recording.checkTrackersIfConditionsMet();
+ }
+
+ var time = trackerUtils.getTimeSincePageReady();
+
+ if (event && event.type && event.type === 'scroll' && event.target && event.target !== documentAlias) {
+ // scroll on an element
+ var target = event.target;
+
+ if ('undefined' === typeof target.scrollTop) {
+ return;// not supported
+ }
+
+ var scrollTop = target.scrollTop;
+ var scrollLeft = target.scrollLeft;
+
+ var eleWidth = element.getWidth(target);
+ var eleHeight = element.getHeight(target);
+
+ if (eleWidth <= 0 || eleHeight <= 0 || !eleWidth || !eleHeight) {
+ return; // element is not visible and prevent division by zero.
+ }
+
+ var selector = element.getSelector(target);
+
+ recording.lastElementScroll = {
+ time: time,
+ selector: selector,
+ scrollY: parseInt((maxScrollAccuracy * scrollTop) / eleHeight, 10),
+ scrollX: parseInt((maxScrollAccuracy * scrollLeft) / eleWidth, 10)
+ };
+ return;
+ }
+
+ // scroll on the document itself
+ var lastScrollTop = parseInt(dom.getScrollTop(), 10);
+ var lastScrollLeft = parseInt(dom.getScrollLeft(), 10);
+ var docHeight = dom.getDocumentHeight();
+ var docWidth = dom.getDocumentWidth();
+
+ recording.lastScroll = {
+ time: time,
+ scrollY: parseInt((maxScrollAccuracy * lastScrollTop) / docHeight, 10),
+ scrollX: parseInt((maxScrollAccuracy * lastScrollLeft) / docWidth, 10)
+ };
+
+ var lastScrollPercentage = parseInt((maxScrollAccuracy * (lastScrollTop + dom.getWindowSize().height)) / docHeight, 10);
+
+ if (lastScrollPercentage > recording.scrollMaxPercentage) {
+ // track new scroll reach position and percentage
+ recording.scrollMaxPercentage = lastScrollPercentage;
+ }
+ },
+ checkTrackersIfConditionsMet: function () {
+ var trackers = tracking.getPiwikTrackers();
+ for (var i = 0; i < trackers.length; i++) {
+ if (trackers[i]
+ && trackers[i].HeatmapSessionRecording
+ && trackers[i].HeatmapSessionRecording.Session) {
+ trackers[i].HeatmapSessionRecording.Session.checkConditionsMet();
+ }
+ }
+ },
+ onClick: function(e) {
+ logConsoleMessage('click');
+
+ if (!hasClicked) {
+ hasClicked = true;
+ recording.checkTrackersIfConditionsMet();
+ }
+
+ if (!('target' in e) || !('pageY' in e) || !('pageX' in e) || !e.target) {
+ return;
+ }
+
+ var time = trackerUtils.getTimeSincePageReady();
+
+ // we unset a poosibly set mouse move for now as we can move the mouse there automatically
+ // saves a wee bit of data but makes maybe replaying more complicated? not sure..
+ recording.lastMove = null;
+
+ var offset = element.getOffset(e.target);
+ var offsetx = parseInt(((e.pageX - offset.left) / offset.width) * pixelOffsetAccuracy, 10);
+ var offsety = parseInt(((e.pageY - offset.top) / offset.height) * pixelOffsetAccuracy, 10);
+ var selector = element.getSelector(e.target);
+
+ if (offsetx % 2 === 1) {
+ offsetx++; // we only record "even" pixel number to slightly reduce the number of rows when grouping / archiving
+ }
+ if (offsety % 2 === 1) {
+ offsety++; // we only record "even" pixel number to slightly reduce the number of rows when grouping / archiving
+ }
+
+ if (debugMode && (isNaN(offsetx) || isNaN(offsety))) {
+ logConsoleMessage('could not detect x or y coordinate for selector ' + selector, e);
+ }
+
+ var data = {
+ ti: time,
+ ty: EVENT_TYPE_CLICK,
+ s: selector,
+ x: offsetx,
+ y: offsety
+ };
+
+ if (selector) {
+ tracking.recordData(RECORD_TYPE_BOTH, data);
+ } else {
+ logConsoleMessage('No selector found for click ', e);
+ }
+ },
+ onMove: function (e) {
+ if (!enableRecordMovements) {
+ return;
+ }
+ if (!('clientY' in e) || !('clientX' in e) || !('pageX' in e) || !('pageY' in e)) {
+ return;
+ }
+
+ var x = e.clientX;
+ var y = e.clientY;
+ var node = documentAlias.elementFromPoint(x, y);
+
+ if (node) {
+ var time = trackerUtils.getTimeSincePageReady();
+
+ var offset = element.getOffset(node);
+ var offsetx = parseInt(((e.pageX - offset.left) / offset.width) * pixelOffsetAccuracy, 10);
+ var offsety = parseInt(((e.pageY - offset.top) / offset.height) * pixelOffsetAccuracy, 10);
+ if (offsetx % 2 === 1) {
+ offsetx++; // we only record "even" pixel number to slightly reduce the number of rows when grouping / archiving
+ }
+ if (offsety % 2 === 1) {
+ offsety++; // we only record "even" pixel number to slightly reduce the number of rows when grouping / archiving
+ }
+
+ var selector = element.getSelector(node);
+
+ if (debugMode && (isNaN(offsetx) || isNaN(offsety))) {
+ logConsoleMessage('could not detect x or y coordinate for selector ' + selector, e);
+ }
+
+ if (selector) {
+ if (!isFirstMouseMoveEvent) {
+ // we record this one directly to "make sure" to detect initial mouse position as the initial
+ // mouse position can be only detected when this event occurs.
+ isFirstMouseMoveEvent = true;
+ var data = {ti: 0, ty: EVENT_TYPE_MOVEMENT, s: selector, x: offsetx, y: offsety};
+ tracking.recordData(RECORD_TYPE_BOTH, data);
+ } else {
+ recording.lastMove = {selector: selector, offsetx: offsetx, offsety: offsety, time: time};
+ }
+ } else {
+ logConsoleMessage('No selector found for click ', e);
+ }
+ }
+ },
+ stopRecording: function () {
+ this.isRecording = false;
+
+ if (this.lastResizeInterval !== null) {
+ clearInterval(this.lastResizeInterval);
+ this.lastResizeInterval = null;
+ }
+ if (this.lastScrollInterval !== null) {
+ clearInterval(this.lastScrollInterval);
+ this.lastScrollInterval = null;
+ }
+ if (this.lastMoveInterval !== null) {
+ clearInterval(this.lastMoveInterval);
+ this.lastMoveInterval = null;
+ }
+
+ this.scrollMaxPercentage = 0;
+
+ // make sure they won't be tracked if tracking is started again later
+ this.lastScroll = null;
+ this.lastElementScroll = null;
+ this.lastMove = null;
+ this.lastResize = null;
+
+ this.scrollEvents.forEach(function (eventName) {
+ windowAlias.removeEventListener(eventName, recording.onScroll, true);
+ });
+ this.moveEvents.forEach(function (eventName) {
+ windowAlias.removeEventListener(eventName, recording.onMove, true);
+ });
+ this.clickEvents.forEach(function (eventName) {
+ windowAlias.removeEventListener(eventName, recording.onClick, true);
+ });
+ },
+ stopRecordingMutations: function () {
+ this.isRecordingMutations = false;
+
+ windowAlias.removeEventListener('resize', recording.onResize, true);
+ windowAlias.removeEventListener('change', recording.onFormChange, true);
+
+ this.initialDOM = null;
+
+ if (this.mirror) {
+ this.mirror.disconnect();
+ this.mirror = null;
+ }
+ }
+ };
+
+ var configuration = {
+ fetch: function() {
+ // THIS FUNCTION NEEDS TO WORK IN ALL BROWSERS
+
+ var numRequestsSent = 0;
+
+ var trackers = tracking.getPiwikTrackers();
+
+ if (!trackers || !trackers.length) {
+ // no tracker configured yet or user won't take part because browser not supported
+ return;
+ }
+
+ for (var i = 0; i < trackers.length; i++) {
+ var tracker = trackers[i];
+ if (tracker
+ && tracker.HeatmapSessionRecording
+ && !tracker.HeatmapSessionRecording.hasReceivedConfig
+ && tracker.HeatmapSessionRecording.isEnabled()) {
+ // we check for !hasReceivedConfig so a user can set a config manually for a tracker and prevent
+ // sending this request to the Matomo instance for even faster performance
+ // if a user for sure has this plugin not available on a piwik instance, the user may also disable
+ // the feature for that tracker so we for sure do not send this request
+
+ var trackerUrl = tracker.getPiwikUrl();
+ var trackerIdSite = tracker.getSiteId();
+
+ if (!trackerUrl || !trackerIdSite) {
+ logConsoleMessage('cannot find piwik url for tracker or site, disabling heatmap & session recording');
+ tracker.HeatmapSessionRecording.disable();
+ tracker.HeatmapSessionRecording.hasReceivedConfig = true;
+ continue;
+ }
+
+ if (trackerUrl.substr(-1, 1) !== '/') {
+ trackerUrl += '/';
+ }
+
+ numRequestsSent++;
+
+ var url;
+
+ if (matchTrackerUrl) {
+ url = tracker.getCurrentUrl();
+ } else {
+ url = windowAlias.location.href;
+ try {
+ url = decodeURIComponent(url);
+ } catch (e) {
+ url = unescape(url);
+ }
+ }
+
+ trackerUrl += 'plugins/HeatmapSessionRecording/configs.php?idsite=' + encodeURIComponent(trackerIdSite) + '&trackerid=' + tracker.HeatmapSessionRecording.myId + '&url=' + encodeURIComponent(url);
+
+ var hsrIds = storage.getHsrConfigs(tracker);
+ for (var k = 0; k < hsrIds.length; k++) {
+ trackerUrl += '&hsr'+ encodeURIComponent(hsrIds[k].id) + '=' + encodeURIComponent(hsrIds[k].value);
+ }
+
+ (function (tracker) {
+ var script = documentAlias.createElement('script');
+ script.src = trackerUrl;
+ script.async = true;
+ script.defer = true;
+ script.onerror = function () {
+ // eg when using multiple trackers with different piwik instances but plugin is only installed on one piwik instance
+ tracker.HeatmapSessionRecording.disable();
+ tracker.HeatmapSessionRecording.hasReceivedConfig = true;
+ configuration.checkAllConfigsReceived();
+ };
+ // timeout may reduce the risk of delaying onload event
+ setTimeout(function () {
+ var head = documentAlias.getElementsByTagName('head');
+ if (head && head.length && head[0]) {
+ head[0].appendChild(script);
+ } else {
+ var body = documentAlias.getElementsByTagName('body');
+ if (body && body.length && body[0]) {
+ body[0].appendChild(script);
+ }
+ }
+ }, 10);
+ })(tracker);
+ }
+ }
+
+ if (numRequestsSent === 0) {
+ // no tracking request was sent, check if we can maybe disable the recording
+ this.checkAllConfigsReceived();
+ }
+ },
+ assign: function (config) {
+ var trackers = tracking.getPiwikTrackers();
+
+ // we need to find to which tracker the config belongs and assign it to that tracker
+ for (var i = 0; i < trackers.length; i++) {
+ var tracker = trackers[i];
+ if (tracker && tracker.HeatmapSessionRecording) {
+ if (tracker.getSiteId() == config.idsite
+ && tracker.HeatmapSessionRecording.myId === config.trackerid) {
+
+ tracker.HeatmapSessionRecording.addConfig(config);
+ break;
+ }
+ }
+ }
+
+ this.checkAllConfigsReceived();
+ },
+ checkAllConfigsReceived: function () {
+ // this is to detect if we can stop recording any actions as it will never start tracking initially
+ // this way we save resources and increase website performance etc.
+
+ var trackers = tracking.getPiwikTrackers();
+ // we know we are not waiting for any particular config anymore and for better performance can
+ // disable the recording
+ var anyHeatmapConfigured = false;
+ var anySessionConfigured = false;
+
+ var hsr;
+ for (var i = 0; i < trackers.length; i++) {
+ if (trackers[i].HeatmapSessionRecording) {
+ hsr = trackers[i].HeatmapSessionRecording;
+
+ if (!hsr.hasReceivedConfig) {
+ // not all trackers have received their config yet so cannot decide yet if we can stop recording or not
+ // returning here is not a mistake but essential to not execute check below
+ return;
+ }
+
+ if (hsr.Heatmap.configs && hsr.Heatmap.configs.length) {
+ anyHeatmapConfigured = true;
+ }
+ if (hsr.Session.configs && hsr.Session.configs.length) {
+ anySessionConfigured = true;
+ }
+ }
+ }
+
+ // there are none configured, so we stop the tracking so the website gets better performance and we need
+ // less resources etc. less cpu usage etc. it doesn't mean we can track anything yet because they are only
+ // configured but there might be a requirement like "requires_activity" or "min_session_time" that prevents
+ // them from tracking in the end but we still need to record as those requirements might be met later
+ if (!anyHeatmapConfigured && !anySessionConfigured) {
+ Piwik.HeatmapSessionRecording.disable();
+ } else if (!anySessionConfigured) {
+ Piwik.DOM.onLoad(function () {
+ // we need to make sure this is executed after mutations were started. Otherwise we might stop now,
+ // and then onLoad we start recording (as this is happening by default)
+ recording.stopRecordingMutations();
+ });
+ }
+ }
+ };
+
+ function init() {
+ if ('object' === typeof window && 'object' === typeof windowAlias.Piwik && 'object' === typeof windowAlias.Piwik.HeatmapSessionRecording) {
+ // do not initialize twice
+ return;
+ }
+
+ if ('object' === typeof window && !windowAlias.Piwik) {
+ // piwik is not defined yet
+ return;
+ }
+
+ pJson = Piwik.JSON;
+
+ Piwik.HeatmapSessionRecording = {
+ utils: trackerUtils,
+ element: element,
+ storage: storage,
+ dom: dom,
+ tracking: tracking,
+ recording: recording,
+ RECORD_TYPE_BOTH: RECORD_TYPE_BOTH,
+ RECORD_TYPE_HEATMAP: RECORD_TYPE_HEATMAP,
+ RECORD_TYPE_SESSION: RECORD_TYPE_SESSION,
+ configuration: configuration,
+ getIdView: function () {
+ return hsrIdView;
+ },
+ disableRecordMovements: function () {
+ enableRecordMovements = false;
+ },
+ enableRecordMovements: function () {
+ enableRecordMovements = true;
+ },
+ isRecordingMovements: function () {
+ return enableRecordMovements;
+ },
+ disableAutoDetectNewPageView: function () {
+ autoDetectNewPageviews = false;
+ },
+ enableAutoDetectNewPageView: function () {
+ autoDetectNewPageviews = true;
+ },
+ isAutoDetectingNewPageViews: function () {
+ return autoDetectNewPageviews;
+ },
+ matchTrackerUrl: function () {
+ return matchTrackerUrl = true;
+ },
+ setTrigger: function (triggerMethod) {
+ if (typeof triggerMethod === 'function') {
+ shouldTriggerRecording = triggerMethod;
+ } else {
+ throw Error('trigger needs to be a method');
+ }
+ },
+ setNewPageView: function (fetchConfig) {
+ if (isExcluded()) {
+ // those methods are not compatible with the browser so we only execute it when browser is supported
+ return;
+ }
+
+ logConsoleMessage('new pageview');
+
+ var isHsrEnabled = this.isEnabled();
+
+ // now we stop any potential recording. This method will make sure to track remaining queued data
+ if (isHsrEnabled) {
+ this.disable();
+ }
+
+ // we set a new view to make sure a new heatmap or recording will be logged.
+ // We need to make sure this method is called after disable so the new hsrIdView won't be used when
+ // sending the remaining tracking requests
+ hsrIdView = trackerUtils.generateUniqueId();
+ timeWhenPageReady = new Date().getTime();
+ recordedDataBeforeTrackerSetup = []; // need to reset / clear this
+ hasClicked = false;
+ hasScrolled = false;
+
+ // we need to re-initialize the tracker and unset all previously set configs, hsrids, data etc.
+ // problem: there may be timeouts waiting...
+ var trackers = tracking.getPiwikTrackers();
+ trackers.forEach(function (tracker) {
+ var isEnabled = true;
+
+ if ('undefined' !== typeof tracker.HeatmapSessionRecording) {
+ // we remember isEnabled state for each tracker. This way eg the config wont be fetched again
+ // for a not supported Matomo etc and user won't have to call it again.
+ // note: we cannot use isEnabled() because we disabled tracking before and it would always
+ // return false. so we need to remember the state itself directly
+ isEnabled = tracker.HeatmapSessionRecording.enabled;
+ delete tracker.HeatmapSessionRecording;
+ }
+
+ enrichTracker(tracker);
+
+ if (!isEnabled) {
+ tracker.HeatmapSessionRecording.disable();
+ }
+ });
+
+ if (isHsrEnabled) {
+ // we enable the tracking again if it was enabled before
+ this.enable();
+
+ if ('undefined' === typeof fetchConfig || fetchConfig === true) {
+ // user might want to disable this if the user wants to avoid these requests and instead wants
+ // to configure it manually on the tracker
+ configuration.fetch();
+ } else if ('object' === typeof fetchConfig) {
+ trackers.forEach(function (tracker) {
+ tracker.HeatmapSessionRecording.addConfig(fetchConfig);
+ });
+ } else {
+ logConsoleMessage('manual tracker config required');
+ }
+ }
+ },
+ disable: function () {
+ if (isExcluded()) {
+ // those methods are not compatible with the browser so we only execute it when browser is supported
+ return;
+ }
+
+ isHsrEnabled = false;
+ recording.stopRecording();
+ recording.stopRecordingMutations();
+ tracking.stopSendingData();
+ },
+ enable: function () {
+ if (isExcluded()) {
+ // those methods are not compatible with the browser so we only execute it when browser is supported
+ return;
+ }
+
+ isHsrEnabled = true;
+ recording.startRecording();
+ recording.startRecordingMutations();
+ // we always start sending data but that method will make sure to actually only start it once
+ // and it will only send data if a tracker is actually supposed to send data
+ // (there might be a gap between start sending data and actually tracking data)
+ tracking.startSendingData();
+ },
+ isEnabled: function () {
+ return isHsrEnabled;
+ },
+ setMaxCaptureTime: function (maxTimeInSeconds) {
+ maxCapturingTimeStart = parseInt(maxTimeInSeconds, 10) * 1000;
+
+ if (maxCapturingTimeStart > maxCapturingTimeEnd) {
+ // if user sets a custom value that is very long, we need to make sure to apply this also to the end max capture time
+ maxCapturingTimeStart = maxCapturingTimeEnd;
+ }
+ },
+ setMaxTextInputLength: function (maxLengthCharacters) {
+ maxLenTextInput = maxLengthCharacters;
+ },
+ disableCaptureKeystrokes: function () {
+ recordKeystrokes = false;
+ },
+ enableCaptureKeystrokes: function () {
+ recordKeystrokes = true;
+ },
+ setPiwikTrackers: function (trackers) {
+ if (trackers === null) {
+ customPiwikTrackers = null;
+ return;
+ }
+
+ if (!trackerUtils.isArray(trackers)) {
+ trackers = [trackers];
+ }
+
+ customPiwikTrackers = trackers;
+ // we make sure all trackers are enriched
+ customPiwikTrackers.forEach(enrichTracker);
+ },
+ enableDebugMode: function () {
+ debugMode = true;
+ }
+ };
+
+ Piwik.DOM.onReady(function () {
+ // we do not measure time since "script load" but since DOM ready, we should ignore any time past
+ // before dom ready as user is not actually doing anything on the website as it is still "loading the dom".
+ timeWhenPageReady = new Date().getTime();
+ });
+
+ Piwik.addPlugin('HeatmapSessionRecording', {
+ log: function (eventParams) {
+ if (autoDetectNewPageviews) {
+ if (eventParams.tracker && eventParams.tracker.getNumTrackedPageViews && eventParams.tracker.getNumTrackedPageViews() > 1) {
+ // we only recognize a pageview after the first tracking pageview as the first tracking pageview
+ // is already handled indirectly without needing to wait for this.
+ setTimeout(function () {
+ // we don't execute it directly and rather want the initial request to finish
+ Piwik.HeatmapSessionRecording.setNewPageView(true);
+ }, 10);
+ }
+ }
+
+ return '';
+ },
+ unload: function () {
+ if (!isExcluded()) {
+ // We make sure it is a supported browser and can use the needed methods
+
+ // sendQueuedData will make sure to only send data when actually enabled
+ var trackers = tracking.getPiwikTrackers();
+
+ // prevent possible race conditions of starting 2 queued data at same time
+ tracking.stopSendingData();
+
+ trackers.forEach(function (tracker) {
+ var shouldEndRecording = true;
+ tracking.sendQueuedData(tracker, shouldEndRecording);
+ });
+ }
+ }
+ });
+
+ if (windowAlias.Piwik.initialized) {
+ // tracker was separately loaded via separate include. we need to enrich already created trackers
+ var asyncTrackers = Piwik.getAsyncTrackers();
+ asyncTrackers.forEach(enrichTracker);
+
+ Piwik.on('TrackerSetup', enrichTracker);
+
+ // now that the methods are set on the tracker instance we check if there were calls that couldn't be executed
+ // the first time because the form analytics plugin was not loaded yet (but it is now)
+ Piwik.retryMissedPluginCalls();
+
+ callAsyncReadyMethod();
+ configuration.fetch();
+
+ } else {
+ Piwik.on('TrackerSetup', enrichTracker);
+
+ Piwik.on('PiwikInitialized', function () {
+ callAsyncReadyMethod();
+
+ // at this point the first tracker was created, and all methods called by a user on _paq applied.
+ // this means now we can start looking for form because if someone has disabled eg tracking events
+ // or tracking progress or enabled debug etc we can be sure the form tracker has been configured
+
+ configuration.fetch();
+ });
+ }
+ }
+
+ hsrIdView = trackerUtils.generateUniqueId();
+
+ if ('object' === typeof windowAlias.Piwik) {
+ init();
+ } else {
+ // tracker is loaded separately for sure
+ if ('object' !== typeof windowAlias.piwikPluginAsyncInit) {
+ windowAlias.piwikPluginAsyncInit = [];
+ }
+
+ windowAlias.piwikPluginAsyncInit.push(init);
+ }
+
+})();
+/* END GENERATED: tracker.js */
+
+
+/* GENERATED: tracker.js */
+/*!
+ * Copyright (C) InnoCraft Ltd - All rights reserved.
+ *
+ * All information contained herein is, and remains the property of InnoCraft Ltd.
+ *
+ * @link https://www.innocraft.com/
+ * @license For license details see https://www.innocraft.com/license
+ */
+
+/**
+ * To minify this version call
+ * cat tracker.js | java -jar ../../js/yuicompressor-2.4.7/build/yuicompressor-2.4.7.jar --type js --line-break 1000 | sed 's/^[/][*]/\/*!/' > tracker.min.js
+ */
+
+(function () {
+ var debugMode = false;
+ var isFormAnalyticsEnabled = true;
+ var customPiwikTrackers = null;
+
+ var FIELD_CATEGORY_CHECK = 'FIELD_CHECKABLE';
+ var FIELD_CATEGORY_SELECT = 'FIELD_SELECTABLE';
+ var FIELD_CATEGORY_TEXT = 'FIELD_TEXT';
+
+ var textFieldTypes = ['password', 'text', 'url', 'tel', 'email' , 'search', '', null];
+ var selectFieldTypes = ['color', 'date', 'datetime', 'datetime-local', 'month' , 'number', 'range', 'time', 'week'];
+ var checkFieldTypes = ['radio', 'checkbox'];
+ var ignoreFieldTypes = ['button', 'submit', 'hidden', 'reset'];
+
+ // when sending a tracking request, we wait that many ms before actually sending the tracking request so we can send several requests at once
+ var trackRequestAfterMs = 2250;
+
+ var formTrackerInstances = [];
+
+ function logConsoleMessage() {
+ if (debugMode && 'undefined' !== typeof console && console && console.debug) {
+ console.debug.apply(console, arguments);
+ }
+ }
+
+ var element = {
+ getAttribute: function (node, attributeName) {
+ if (node && node.getAttribute && attributeName) {
+ return node.getAttribute(attributeName);
+ }
+
+ return null;
+ },
+ hasClass: function (node, className) {
+ if (!node || !node.className) {
+ return false;
+ }
+
+ return (' ' + node.className + ' ').indexOf(' ' + className + ' ') > -1;
+ },
+ hasNodeAttribute: function (node, attributeName) {
+ if (node && node.hasAttribute) {
+ return node.hasAttribute(attributeName);
+ }
+
+ if (node && node.attributes) {
+ var theType = (typeof node.attributes[attributeName]);
+ return theType !== 'undefined';
+ }
+
+ return false;
+ },
+ isIgnored: function (node) {
+ if (this.hasNodeAttribute(node, 'data-matomo-ignore')) {
+ return true;
+ }
+ if (this.hasNodeAttribute(node, 'data-piwik-ignore')) {
+ return true;
+ }
+ return false;
+ },
+ getTagName: function (node) {
+ if (node && node.tagName) {
+ return ('' + node.tagName).toLowerCase();
+ }
+
+ return null;
+ },
+ findAllFormElements: function (element) {
+ if (element && element.querySelectorAll) {
+ return element.querySelectorAll('form, [data-piwik-form], [data-matomo-form]');
+ }
+ return [];
+ },
+ findAllFieldElements: function (element) {
+ if (element && element.querySelectorAll) {
+ return element.querySelectorAll('input,select,textarea,button,textarea');
+ }
+ return [];
+ },
+ findFormTrackerInstance: function (node, maxLevels) {
+ if ('undefined' === typeof maxLevels) {
+ maxLevels = 100;
+ }
+
+ if (maxLevels <= 0 || !node) {
+ return null;
+ }
+
+ if (node.formTrackerInstance) {
+ return node.formTrackerInstance;
+ }
+
+ if (node.parentNode) {
+ return this.findFormTrackerInstance(node.parentNode, --maxLevels);
+ }
+ }
+ };
+
+ var utils = {
+ isArray: function (variable) {
+ return typeof variable === 'object' && variable !== null && typeof variable.length === 'number';
+ },
+ indexOfArray: function (anArray, element) {
+ if (!anArray) {
+ return -1;
+ }
+
+ if (anArray.indexOf) {
+ return anArray.indexOf(element);
+ }
+
+ if (!this.isArray(anArray)) {
+ return -1;
+ }
+
+ for (var i = 0; i < anArray.length; i++) {
+ if (anArray[i] === element) {
+ return i;
+ }
+ }
+
+ return -1;
+ },
+ getCurrentTime: function () {
+ return new Date().getTime();
+ },
+ isNumber: function (text) {
+ return !isNaN(text);
+ },
+ generateUniqueId: function () {
+ var id = '';
+ var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+ var charLen = chars.length;
+
+ for (var i = 0; i < 6; i++) {
+ id += chars.charAt(Math.floor(Math.random() * charLen));
+ }
+
+ return id;
+ },
+ paramsToQueryString: function (params) {
+ if (!params) {
+ params = {};
+ }
+
+ var requestUrl = '';
+ for (var index in params) {
+ if (Object.prototype.hasOwnProperty.call(params, index)) {
+ if (params[index] === null) {
+ continue;
+ }
+
+ requestUrl += index + '=' + encodeURIComponent(params[index]) + '&';
+ }
+ }
+
+ return requestUrl;
+ }
+ };
+
+ var tracking = {
+ getPiwikTrackers: function ()
+ {
+ if (null === customPiwikTrackers) {
+ if ('object' === typeof Piwik && Piwik.getAsyncTrackers) {
+ return Piwik.getAsyncTrackers();
+ }
+ }
+
+ if (utils.isArray(customPiwikTrackers)) {
+ return customPiwikTrackers;
+ }
+
+ return [];
+ },
+ trackParams: function (params, increaseTimeout) {
+ if (!isFormAnalyticsEnabled) {
+ return;
+ }
+
+ var requestUrl = utils.paramsToQueryString(params);
+
+ if (!requestUrl || requestUrl === '') {
+ return;
+ }
+
+ var asyncTrackers = this.getPiwikTrackers();
+
+ if (asyncTrackers && asyncTrackers.length) {
+ var i = 0, tracker;
+
+ for (i; i < asyncTrackers.length; i++) {
+ tracker = asyncTrackers[i];
+ if (increaseTimeout && 500 === tracker.getLinkTrackingTimer() && tracker.setLinkTrackingTimer) {
+ // if it still has the default value, slightly increase timeout to make sure we can handle eg form submit
+ // request
+ tracker.setLinkTrackingTimer(650);
+ }
+
+ if (tracker && (!tracker.FormAnalytics || tracker.FormAnalytics.isEnabled())) {
+ tracker.trackRequest(requestUrl, null, null, 'FormAnalytics');
+ }
+ }
+ }
+
+ if (debugMode) {
+ // check for debug mode is not needed here but we do want to perform the stringify only when needed
+ logConsoleMessage('trackProgress: ' + Piwik.JSON.stringify(params));
+ }
+ }
+ };
+
+ function callAsyncReadyMethod()
+ {
+ if (typeof window === 'object' && 'function' === typeof window.piwikFormAnalyticsAsyncInit) {
+ window.piwikFormAnalyticsAsyncInit();
+ }
+ }
+
+ function FormTracker(node) {
+ this.reset();
+
+ this.fields = [];
+ this.firstFieldEngagementDate = null;
+ this.lastFieldEngagementDate = null;
+ this.hesitationTimeTracked = false;
+ this.formStartTracked = false;
+ this.node = node;
+ this.formId = element.getAttribute(node, 'id');
+ this.formName = element.getAttribute(node, 'data-matomo-name'); // name can be overwritten by data-matomo-name
+ if (!this.formName) {
+ this.formName = element.getAttribute(node, 'data-piwik-name'); // name can be overwritten by data-piwik-name
+ }
+ if (!this.formName) {
+ this.formName = element.getAttribute(node, 'name');
+ }
+
+ this.entryFieldName = '';
+ this.exitFieldName = '';
+ this.lastFocusedFieldName = ''; // we update this one always on focus
+ this.fieldsWithUpdates = [];
+ this.fieldNodes = []; // we use this to know which form fields have been added to this form already
+ this.initialFormViewLoggedWithTrackers = [];
+ this.trackingTimeout = null;
+ this.timeLastTrackingRequest = 0; // used to calculate time spent on form, we are ignoring any time while form was active
+ this.timeOffWindowBeforeEngagement = 0; // we do not count the time into account when window wasn't active
+ // we never send the total time spent, we only send the amount of ms that was spent on the form since the last
+ // tracking event. So we calculate always the total time spent, and remove the amount of already tracked spent time.
+ this.timeOffWindowSinceEngagement = 0; // we do not count the time into account when window wasn't active
+
+ Piwik.DOM.addEventListener(window, 'focus', (function (that) {
+ return function () {
+ if (!that.timeWindowBlur) {
+ return;
+ }
+
+ var timeWindowOff = utils.getCurrentTime() - that.timeWindowBlur;
+ that.timeWindowBlur = null;
+ // we unset it again to make sure next time it works only when we also captured blur
+
+ if (timeWindowOff < 0) {
+ timeWindowOff = 0;
+ }
+
+ if (that.timeLastTrackingRequest) {
+ // we need to make sure to ignore if window was off after last tracking request
+ that.timeLastTrackingRequest = that.timeLastTrackingRequest + timeWindowOff;
+ }
+
+ if (that.firstFieldEngagementDate) {
+ that.timeOffWindowSinceEngagement += timeWindowOff;
+ logConsoleMessage('time off engaged ' + that.timeOffWindowSinceEngagement);
+ } else {
+ that.timeOffWindowBeforeEngagement += timeWindowOff;
+ logConsoleMessage('time off not engaged ' + that.timeOffWindowBeforeEngagement);
+ }
+ };
+ })(this));
+ Piwik.DOM.addEventListener(window, 'blur', (function (that) {
+ return function () {
+ that.timeWindowBlur = utils.getCurrentTime();
+ logConsoleMessage('window blur');
+ };
+ })(this));
+
+ Piwik.DOM.addEventListener(node, 'submit', (function (that) {
+ return function () {
+ logConsoleMessage('form submit');
+ that.trackFormSubmit();
+ };
+ })(this));
+ }
+
+ FormTracker.prototype.reset = function () {
+ this.detectionDate = utils.getCurrentTime();
+ this.formViewId = utils.generateUniqueId();
+ this.fieldsWithUpdates = [];
+ this.firstFieldEngagementDate = null;
+ this.lastFieldEngagementDate = null;
+ this.timeOffWindowSinceEngagement = 0;
+ this.timeOffWindowBeforeEngagement = 0;
+ this.formStartTracked = false; // if user engages again, we will count a new form start...
+
+ if (this.fields && this.fields.length) {
+ for (var i = 0; i < this.fields.length; i++) {
+ this.fields[i].resetOnFormSubmit();
+ }
+ }
+ };
+
+ FormTracker.prototype.trackFormSubmit = function () {
+
+ this.setEngagedWithForm();
+
+ var timeToSubmit = this.lastFieldEngagementDate - this.firstFieldEngagementDate - this.timeOffWindowSinceEngagement;
+ if (timeToSubmit < 0) {
+ timeToSubmit = 0;
+ }
+
+ var params = {
+ fa_su: 1, // marks it as form submission
+ fa_tts: timeToSubmit // time to submit form
+ };
+
+ this.sendUpdate(this.fields, params, true);
+
+ this.reset();
+ };
+
+ FormTracker.prototype.trackFormConversion = function () {
+ if (!this.timeLastTrackingRequest) {
+ // no timeout needed, we can send the request directly as nothing else has been tracked yet
+ this.sendUpdate([], {fa_co: 1});
+ return;
+ }
+
+ var secondsSinceLastTrackingRequest = (utils.getCurrentTime() - this.timeLastTrackingRequest) / 1000;
+
+ if (secondsSinceLastTrackingRequest < 2) {
+ // we need to give the previous request a bit of time to process in case the submit contains information
+ // about form_start and time_spent on form. otherwise there could be a race condition when filling out
+ // the form very quick and then calling submit and conversion at the same time. the conversion would not
+ // find a form with time spent on because the other request that contains this information is only just
+ // being processed at the same time
+ var self = this;
+ setTimeout(function () {
+ self.sendUpdate([], {fa_co: 1});
+ }, 800);
+ } else {
+ // if there was no tracking request in the last 2 seconds we want to send this request as quick as possible
+ // in case the user leaves the page again
+ this.sendUpdate([], {fa_co: 1});
+ }
+ };
+
+ FormTracker.prototype.shouldBeTracked = function () {
+ return !!this.fields && !!this.fields.length;
+ };
+
+ FormTracker.prototype.trackInitialFormView = function () {
+ if (!this.initialFormViewLoggedWithTrackers || !this.initialFormViewLoggedWithTrackers.length) {
+ this.initialFormViewLoggedWithTrackers = tracking.getPiwikTrackers();
+ this.sendUpdate([], {fa_fv: '1'});
+ // fa_fv => 1 = form view. we do currently not track a new form view after a form submit
+ // (eg if page does not reload). Possibly we should though in the future as when someone does a page reload
+ // after a form submit we would track a form view and a new form start under circumstances and it would be
+ // good to track this consistent no matter if it is an ajax form or not. Something to see later.
+ }
+ };
+
+ FormTracker.prototype.setEngagedWithForm = function (hasChangedForm) {
+ this.lastFieldEngagementDate = utils.getCurrentTime();
+
+ if (!this.firstFieldEngagementDate) {
+ this.firstFieldEngagementDate = this.lastFieldEngagementDate;
+ }
+ };
+
+ FormTracker.prototype.trackFieldUpdate = function (field) {
+ if (utils.indexOfArray(this.fieldsWithUpdates, field) === -1) {
+ this.fieldsWithUpdates.push(field);
+ }
+
+ this.scheduleSendUpdate();
+ };
+
+ FormTracker.prototype.scheduleSendUpdate = function () {
+ if (this.trackingTimeout) {
+ clearTimeout(this.trackingTimeout);
+ this.trackingTimeout = null;
+ }
+
+ var self = this;
+ this.trackingTimeout = setTimeout(function () {
+ var fields = self.fieldsWithUpdates;
+ self.fieldsWithUpdates = [];
+ self.sendUpdate(fields);
+ }, trackRequestAfterMs);
+ };
+
+ FormTracker.prototype.sendUpdate = function (fieldsToTrack, extraParams, increaseTimeout)
+ {
+ if (!this.shouldBeTracked()) {
+ // we make sure to not track anything if there are no visible fields registered
+ return;
+ }
+
+ if (this.trackingTimeout) {
+ clearTimeout(this.trackingTimeout);
+ this.trackingTimeout = null;
+ }
+
+ if (!fieldsToTrack) {
+ fieldsToTrack = [];
+ }
+
+ var fields = [];
+ for (var i = 0; i < fieldsToTrack.length; i++) {
+ fields.push(fieldsToTrack[i].getTrackingParams());
+ }
+
+ var params = {
+ fa_vid: this.formViewId,
+ fa_id: this.formId,
+ fa_name: this.formName
+ };
+
+ if (this.entryFieldName) {
+ params.fa_ef = this.entryFieldName;
+ }
+
+ if (this.exitFieldName) {
+ params.fa_lf = this.exitFieldName;
+ }
+
+ if (fields.length) {
+ params.fa_fields = Piwik.JSON.stringify(fields);
+ }
+
+ if (this.firstFieldEngagementDate) {
+
+ // we only want to track any time spent or hesitation time as soon as a user has engaged with it
+ if (!this.formStartTracked) {
+ // we do currently not track a new form view and not a new form start after a form submit
+ // (eg if page does not reload). Possibly we should though in the future as when someone does a page reload
+ // after a form submit we would track a form view and a new form start under circumstances and it would be
+ // good to track this consistent no matter if it is an ajax form or not.
+ params.fa_st = '1';
+ this.formStartTracked = true;
+ }
+
+ if (!this.hesitationTimeTracked) {
+ // we only want to track this once as soon as
+ params.fa_ht = this.firstFieldEngagementDate - this.detectionDate - this.timeOffWindowBeforeEngagement;
+ this.hesitationTimeTracked = true;
+ }
+
+ if (this.lastFieldEngagementDate && this.timeLastTrackingRequest) {
+ // we only send time spent once user has actually engaged with it
+ params.fa_ts = this.lastFieldEngagementDate - this.timeLastTrackingRequest;
+ if (params.fa_ts < 0) {
+ params.fa_ts = 0; // eg if form was not engaged with since last tracking request
+ }
+ } else if (this.lastFieldEngagementDate && !this.timeLastTrackingRequest) {
+ // on first tracking request since engagement
+ params.fa_ts = this.lastFieldEngagementDate - this.firstFieldEngagementDate - this.timeOffWindowSinceEngagement;
+ if (params.fa_ts < 0) {
+ params.fa_ts = 0; // eg if window was off / blurred for a long time after the last engagement
+ }
+ }
+
+ // it is important we set this only once engaged so far
+ this.timeLastTrackingRequest = utils.getCurrentTime();
+ }
+
+ if (extraParams) {
+ for (var j in extraParams) {
+ if (Object.prototype.hasOwnProperty.call(extraParams, j)) {
+ params[j] = extraParams[j];
+ }
+ }
+ }
+
+ if ('undefined' === typeof increaseTimeout) {
+ increaseTimeout = false;
+ }
+
+ tracking.trackParams(params, increaseTimeout);
+ };
+
+ FormTracker.prototype.scanForFields = function() {
+ var i, j = 0, field, fields, node;
+
+ fields = element.findAllFieldElements(this.node);
+
+ for (i = 0; i < fields.length; i++) {
+ if (!fields[i]) {
+ continue;
+ }
+
+ node = fields[i];
+
+ if (element.isIgnored(node) || utils.indexOfArray(this.fieldNodes, node) > -1) {
+ continue;
+ }
+
+ var tagName = element.getTagName(node);
+ var fieldType = element.getAttribute(node, 'type');
+
+ if (utils.indexOfArray(ignoreFieldTypes, fieldType) !== -1) {
+ continue;
+ } else if ('button' === tagName) {
+ continue;
+ }
+
+ if (tagName === 'input' && !fieldType) {
+ fieldType = 'text';
+ }
+
+ var fieldName = element.getAttribute(node, 'data-matomo-name');
+ if (!fieldName) {
+ fieldName = element.getAttribute(node, 'data-piwik-name');
+ if (!fieldName) {
+ fieldName = element.getAttribute(node, 'name');
+
+ if (!fieldName) {
+ fieldName = element.getAttribute(node, 'id');
+
+ if (!fieldName) {
+ continue; // no name found, we ignore it
+ }
+ }
+ }
+ }
+
+ this.fieldNodes.push(node);
+
+ var found = false;
+ for (j = 0; j < this.fields.length; j++) {
+ if (this.fields[j] && this.fields[j].fieldName === fieldName) {
+ found = true;
+ // for radio and checkboxes we need to group some fields together
+ this.fields[j].addNode(node);
+ break;
+ }
+ }
+
+ if (!found) {
+ field = new FormField(this, fields[i], tagName, fieldType, fieldName);
+ this.addFormField(field);
+ }
+ }
+ };
+
+ FormTracker.prototype.addFormField = function (field)
+ {
+ this.fields.push(field);
+ };
+
+ function FormField(tracker, node, tagName, fieldType, fieldName) {
+ this.discoveredDate = utils.getCurrentTime();
+ this.tracker = tracker;
+ this.timespent = 0;
+ this.hesitationtime = 0;
+ this.nodes = [];
+ this.tagName = tagName;
+ this.fieldName = fieldName;
+ this.fieldType = fieldType;
+ this.startFocus = null; // timestamp last focused
+ this.timeLastChange = null; // timestamp last changed which is set when field has focus and it is being changed
+ this.numChanges = 0; // number of total changes after a different field had the focus
+ this.numFocus = 0; // number of total focuses after a different field had the focus
+ this.numDeletes = 0; // number of times a user pressed back or delete key
+ this.numCursor = 0; // number of times a user used arrow keys
+ this.canCountChange = true; // to make sure we can count a change only once per focus
+ this.isFocusedCausedAuto = element.hasNodeAttribute(node, 'autofocus');
+
+ if (this.tagName === 'select') {
+ this.category = FIELD_CATEGORY_SELECT;
+ } else if (this.tagName === 'textarea') {
+ this.category = FIELD_CATEGORY_TEXT;
+ } else if (utils.indexOfArray(checkFieldTypes, this.fieldType) !== -1) {
+ this.category = FIELD_CATEGORY_CHECK;
+ } else if (utils.indexOfArray(selectFieldTypes, this.fieldType) !== -1) {
+ this.category = FIELD_CATEGORY_SELECT;
+ } else {
+ this.category = FIELD_CATEGORY_TEXT;
+ }
+
+ this.addNode(node);
+
+ var isFocusedInitially = (node === document.activeElement);
+
+ if (isFocusedInitially) {
+ this.onFocus();
+ }
+ }
+
+ FormField.prototype.addNode = function (node) {
+ this.nodes.push(node);
+
+ function addEvent(node, eventName, callback) {
+ if (node
+ && 'object' === typeof tinymce
+ && 'function' === typeof tinymce.get
+ && element.getTagName(node) === 'textarea'
+ && element.getAttribute(node, 'id')) {
+ var id = element.getAttribute(node, 'id');
+ var editor = tinymce.get(id);
+ if (editor) {
+ editor.on(eventName, callback);
+ return;
+ }
+ } else if (node
+ && 'function' === typeof jQuery
+ && element.getTagName(node) === 'select'
+ && element.hasClass(node, 'select2-hidden-accessible')
+ && node.nextSibling
+ ) {
+ if (eventName === 'focus') {
+ eventName = 'select2:open';
+ } else if (eventName === 'blur') {
+ eventName = 'select2:close';
+ }
+ jQuery(node).on(eventName, callback);
+ return;
+ }
+
+ Piwik.DOM.addEventListener(node, eventName, callback);
+ }
+
+ addEvent(node, 'focus', (function (that) {
+ return function (event) {
+ // if focus happens after page was loaded, we can be quite sure it wasn't autofocused.
+ // when another field had the focus before, it cannot be an autofocus
+ if (that.isAutoFocus()) {
+ logConsoleMessage('field autofocus ' + that.fieldName);
+ } else {
+ logConsoleMessage('field focus ' + that.fieldName);
+ }
+
+ that.onFocus();
+ };
+ })(this));
+
+ addEvent(node, 'blur', (function (that) {
+ return function () {
+ logConsoleMessage('field blur ' + that.fieldName);
+ that.onBlur();
+ };
+ })(this));
+
+ if (this.category === FIELD_CATEGORY_TEXT) {
+ addEvent(node, 'keyup', (function (that) {
+ return function (event) {
+ var key = event.which || event.keyCode;
+ var metaKeysIgnore = [9, 16, 17, 18, 20, 27, 91];
+ if ((key && utils.indexOfArray(metaKeysIgnore, key) !== -1) || event.isCtrlKey) {
+ return;
+ }
+ if (key >= 37 && key <= 40) {
+ if (!that.isBlank()) {
+ // we count cursors only once there is actually a value in the field
+ that.numCursor++;
+ that.tracker.trackFieldUpdate(that);
+ }
+ return;
+ }
+ if (key == 8 || key == 46) {
+ // used backspace or delete key
+ if (!that.isBlank()) {
+ // we count deletes only once there is actually a value in the field
+ that.numDeletes++;
+ that.tracker.trackFieldUpdate(that);
+ }
+ return;
+ }
+
+ logConsoleMessage('field text keyup ' + that.fieldName);
+ that.onChange();
+ };
+ })(this));
+
+ addEvent(node, 'paste', (function (that) {
+ return function () {
+ logConsoleMessage('field text paste ' + that.fieldName);
+ that.onChange();
+ };
+ })(this));
+ } else {
+ addEvent(node, 'change', (function (that) {
+ // it would be great to listen to change events for text fields as well, however, the change event
+ // may be triggered on blur, which means we cannot differentiate between blur / change and eg you type
+ // something, then wait for 10 seconds to leave the field, we would not want to count those 10seconds
+ // into time spent but the change event would be triggered after those 10 seconds and we would count it
+ return function () {
+ logConsoleMessage('field change ' + that.fieldName);
+ that.onChange();
+ };
+ })(this));
+ }
+ };
+
+ FormField.prototype.resetOnFormSubmit = function () {
+ this.hesitationtime = 0;
+ this.timespent = 0;
+ this.numFocus = 0;
+ this.numDeletes = 0;
+ this.numCursor = 0;
+ this.numChanges = 0;
+ this.startFocus = null;
+ this.timeLastChange = null;
+ this.canCountChange = true;
+ this.hasChangedValueSinceFocus = false;
+ this.isFocusedCausedAuto = false; // after a submit it can be no longer caused by auto focus
+ };
+
+ FormField.prototype.isAutoFocus = function () {
+
+ if (!this.isFocusedCausedAuto) {
+ return false;
+ }
+
+ // as soon as another field that a focus, it cannot be caused by autofocus anymore
+ if (this.tracker.entryFieldName && this.tracker.entryFieldName !== this.fieldName) {
+ this.isFocusedCausedAuto = false;
+ }
+
+ // a different field had focus before, it cannot be caused by auto focus
+ if (this.tracker.exitFieldName && this.tracker.exitFieldName !== this.fieldName) {
+ this.isFocusedCausedAuto = false;
+ }
+
+ return this.isFocusedCausedAuto;
+ };
+
+ FormField.prototype.getTrackingParams = function () {
+ return {
+ fa_fts: this.getTimeSpent(),
+ fa_fht: this.getHesitationTime(),
+ fa_fb: this.isBlank(),
+ fa_fn: this.fieldName,
+ fa_fch: this.numChanges,
+ fa_ff: this.numFocus,
+ fa_fd: this.numDeletes,
+ fa_fcu: this.numCursor,
+ fa_ft: this.fieldType || this.tagName,
+ fa_fs: this.getFieldSize()
+ };
+ };
+
+ FormField.prototype.isBlank = function () {
+ if (this.category === FIELD_CATEGORY_CHECK) {
+ for (var i = 0; i < this.nodes.length; i++) {
+ if (this.nodes[i] && this.nodes[i].checked) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ if (!this.nodes[0]) {
+ return false;
+ }
+
+ var node = this.nodes[0];
+
+ if ('undefined' === typeof node.value) {
+ return true;
+ }
+
+ var value = node.value;
+
+ if (null === value || false === value || '' === value) {
+ return true;
+ }
+
+ return String(value).length === 0;
+ };
+
+ FormField.prototype.getFieldSize = function () {
+ if (this.category === FIELD_CATEGORY_TEXT) {
+ if (this.nodes[0] && this.nodes[0].value) {
+ return String(this.nodes[0].value).length;
+ } else {
+ return 0;
+ }
+ } else {
+ return -1;
+ }
+ };
+
+ FormField.prototype.getTimeSpent = function () {
+ if (this.numChanges && !this.timeSpent) {
+ // we make sure to log at least 1ms if there was a change but no actual time was spent
+ // as it is kind of required behaviour for logaggregator which assumes when there was a change,
+ // there was also time spent on it
+ this.timeSpent = 1;
+ }
+
+ if (!this.startFocus || this.isAutoFocus()) {
+ // the field does currently had not have focus, so we use that value
+ return this.timespent;
+ }
+
+ // field has currently focus and likely the time spent is not yet updated, we need to calculate it dynamically
+
+ if (this.timeLastChange) {
+ // the field currently has focus and it was changed at least once. When the field was changed we only want
+ // to calculate the time to the last change, not the time to the blur since the user might just not "unfocus"
+ // the field
+ var diff = this.timeLastChange - this.startFocus;
+ if (diff < 0) {
+ diff = 0;
+ }
+ return this.timespent + diff;
+ }
+
+ // the field has currently focus but not has been changed yet
+ return this.timespent + utils.getCurrentTime() - this.startFocus;
+ };
+
+ FormField.prototype.getHesitationTime = function () {
+
+ if (this.numChanges || !this.startFocus || this.isAutoFocus()) {
+ // only if there were no changes to it so far. Then user has already interacted with it
+ return this.hesitationtime;
+ }
+
+ // field has currently focus and likely the hesitation time is not yet updated, we need to calculate it dynamically
+ var now = utils.getCurrentTime();
+
+ return this.hesitationtime + (now - this.startFocus);
+ };
+
+ FormField.prototype.onFocus = function () {
+ this.startFocus = utils.getCurrentTime();
+
+ var hadDifferentFieldFocusBefore = this.fieldName !== this.tracker.lastFocusedFieldName;
+
+ if (hadDifferentFieldFocusBefore && this.tracker.lastFocusedFieldName) {
+ // cannot be caused by auto focus since different field was focused before
+ // (on first focus lastFocusedFieldName) may be empty so we need to make sure a value was set
+ this.isFocusedCausedAuto = false;
+ }
+
+ this.timeLastChange = null;
+ this.hasChangedValueSinceFocus = false;
+ this.tracker.lastFocusedFieldName = this.fieldName;
+
+ if (hadDifferentFieldFocusBefore) {
+ this.canCountChange = true;
+ }
+
+ if (hadDifferentFieldFocusBefore && !this.isAutoFocus()) {
+ this.numFocus++;
+
+ this.tracker.setEngagedWithForm();
+ this.tracker.trackFieldUpdate(this);
+
+ // we track it only as exit field if the user actually engaged with a field, but not if field gets
+ // autofocus
+ this.tracker.exitFieldName = this.fieldName;
+
+ // we only schedule an update if something actually changed
+ this.tracker.scheduleSendUpdate();
+ }
+ };
+
+ FormField.prototype.onBlur = function () {
+
+ if (!this.startFocus) {
+ return;
+ }
+
+ if (this.hasChangedValueSinceFocus) {
+ // timespent and hesitation time was already measured on change event. we do not want do track any time
+ // after a select change to the select blur event as it is not relevant here.
+ if (this.timeLastChange && this.startFocus) {
+ this.timespent += (this.timeLastChange - this.startFocus);
+ }
+
+ this.timeLastChange = null;
+ this.startFocus = null;
+ return;
+ }
+
+ if (!this.isAutoFocus()) {
+ var now = utils.getCurrentTime();
+ this.timespent += now - this.startFocus;
+
+ if (!this.numChanges) {
+ this.hesitationtime += now - this.startFocus;
+ }
+
+ // user has not updated a value, but kept focus for a while
+ this.tracker.setEngagedWithForm();
+ this.tracker.trackFieldUpdate(this);
+ }
+
+ this.startFocus = null;
+ };
+
+ FormField.prototype.onChange = function () {
+
+ this.timeLastChange = utils.getCurrentTime();
+
+ if (this.isAutoFocus()) {
+ // we need to update the start focus time since it was automatically focussed and time spent wouldn't be
+ // 100% accurate when using the auto focus start date. Instead we update it manually once on the first key
+ // press
+ this.startFocus = this.timeLastChange;
+ } else if (!this.startFocus) {
+ return; // currently not focussed!
+ }
+
+ // after a text change we ignore future focus events as being auto since they will be from then triggered by user
+ this.isFocusedCausedAuto = false;
+ this.hasChangedValueSinceFocus = true;
+
+ if (!this.numChanges) {
+ // when we go in here the first change has been made. we need to set the hestiation time and it will keep
+ // always having this hesitation time afterwards since it is time from first focus to first change
+ this.hesitationtime += this.timeLastChange - this.startFocus;
+ }
+
+ if (this.canCountChange) {
+ // we only count a change once during a focus
+ this.numChanges++;
+ this.canCountChange = false;
+ }
+
+ if (!this.tracker.entryFieldName) {
+ // we could an entry field only if a value was changed
+ this.tracker.entryFieldName = this.fieldName;
+ }
+
+ this.tracker.setEngagedWithForm();
+ this.tracker.trackFieldUpdate(this);
+ };
+
+ function addForm(node, sendInitialTrackingRequest)
+ {
+ if (!isFormAnalyticsEnabled) {
+ return;
+ }
+
+ if (!document.querySelectorAll) {
+ // this browser is not supported
+ return;
+ }
+
+ var tracker;
+ if (node && node.formTrackerInstance) {
+ tracker = node.formTrackerInstance;
+ tracker.scanForFields();
+ } else if (!element.isIgnored(node)) {
+ tracker = new FormTracker(node);
+ tracker.scanForFields();
+ formTrackerInstances.push(tracker); // we need to keep a list for unload event
+ node.formTrackerInstance = tracker;
+ }
+
+ if (sendInitialTrackingRequest && tracker && tracker.shouldBeTracked()) {
+ tracker.trackInitialFormView();
+ }
+
+ return tracker;
+ }
+
+ function scanForForms(elementOrDocument)
+ {
+ if ('undefined' === typeof elementOrDocument) {
+ elementOrDocument = document;
+ }
+
+ var forms = element.findAllFormElements(elementOrDocument);
+
+ for (var i = 0; i < forms.length; i++) {
+ addForm(forms[i], true);
+ }
+ }
+
+ function startScanningForForms()
+ {
+ var trackers = tracking.getPiwikTrackers();
+
+ if (!trackers || !utils.isArray(trackers) || !trackers.length) {
+ // no single tracker has been created yet. We do not automatically scan for forms as a user might only
+ // later create a tracker
+ return;
+ }
+
+ Piwik.DOM.onReady(function () {
+ scanForForms(document);
+ });
+ Piwik.DOM.onLoad(function () {
+ scanForForms(document);
+ });
+ }
+
+ function enrichTracker(tracker)
+ {
+ if ('undefined' !== typeof tracker.FormAnalytics) {
+ return;
+ }
+
+ tracker.FormAnalytics = {
+ enabled: true,
+ enable: function () {
+ this.enabled = true;
+ },
+ disable: function () {
+ this.enabled = false;
+ },
+ isEnabled: function () {
+ return isFormAnalyticsEnabled && this.enabled;
+ }
+ };
+ }
+
+ function init() {
+ if ('object' === typeof window && 'object' === typeof window.Piwik && 'object' === typeof window.Piwik.FormAnalytics) {
+ // do not initialize form analytics twice
+ return;
+ }
+
+ if ('object' === typeof window && !window.Piwik) {
+ // piwik is not defined yet
+ return;
+ }
+
+ Piwik.FormAnalytics = {
+ element: element,
+ utils: utils,
+ tracking: tracking,
+ FormField: FormField,
+ FormTracker: FormTracker,
+ disableFormAnalytics: function () {
+ isFormAnalyticsEnabled = false;
+ },
+ enableFormAnalytics: function () {
+ isFormAnalyticsEnabled = true;
+ },
+ isFormAnalyticsEnabled: function () {
+ return isFormAnalyticsEnabled;
+ },
+ setPiwikTrackers: function (trackers) {
+ if (trackers === null) {
+ customPiwikTrackers = null;
+ return;
+ }
+
+ if (!utils.isArray(trackers)) {
+ trackers = [trackers];
+ }
+
+ customPiwikTrackers = trackers;
+ },
+ setTrackingTimer: function (delay) {
+ if (delay < 0) {
+ throw new Error('Delay needs to be at least zero');
+ }
+ trackRequestAfterMs = parseInt(delay, 10);
+ },
+ enableDebugMode: function () {
+ debugMode = true;
+ },
+ scanForForms: scanForForms,
+ trackFormSubmit: function (node) {
+ var tracker = element.findFormTrackerInstance(node);
+
+ if (tracker) {
+ tracker.trackFormSubmit();
+ }
+ },
+ trackFormConversion: function (nodeOrFormName, formId) {
+ if ('string' === typeof nodeOrFormName || 'string' === typeof formId) {
+ // TODO: later we need to optionally also add a pageURL parameter as they might configure their
+ // form to only be matched by page URL
+ tracking.trackParams({fa_vid: utils.generateUniqueId(),
+ fa_id: formId,
+ fa_name: nodeOrFormName, fa_co: 1});
+
+ return;
+ }
+
+ var tracker = element.findFormTrackerInstance(nodeOrFormName);
+
+ if (tracker) {
+ tracker.trackFormConversion();
+ }
+ },
+ trackForm: function (node) {
+ return addForm(node, true);
+ }
+ };
+
+ Piwik.addPlugin('FormAnalytics', {
+ log: function (eventParams) {
+ if (!isFormAnalyticsEnabled || !eventParams || !eventParams.tracker) {
+ return '';
+ }
+
+ var trackerInstance = eventParams.tracker;
+
+ if (trackerInstance.FormAnalytics && !trackerInstance.FormAnalytics.isEnabled()) {
+ return '';
+ }
+
+ var forms = element.findAllFormElements(document);
+
+ var requestParams = '';
+ for (var i = 0; i < forms.length; i++) {
+ var formTracker = addForm(forms[i], false);
+
+ if (formTracker
+ && formTracker.shouldBeTracked()
+ && utils.indexOfArray(formTracker.initialFormViewLoggedWithTrackers, trackerInstance) === -1) {
+ formTracker.initialFormViewLoggedWithTrackers.push(trackerInstance);
+
+ if (formTracker.formViewId !== null) {
+ requestParams += '&fa_fp[' + i + '][fa_vid]=' + encodeURIComponent(formTracker.formViewId);
+ }
+ if (formTracker.formId !== null) {
+ requestParams += '&fa_fp[' + i + '][fa_id]=' + encodeURIComponent(formTracker.formId);
+ }
+ if (formTracker.formName !== null) {
+ requestParams += '&fa_fp[' + i + '][fa_name]=' + encodeURIComponent(formTracker.formName);
+ }
+ requestParams += '&fa_fp[' + i + '][fa_fv]=1';
+ }
+ }
+
+ if (requestParams) {
+ logConsoleMessage('sending request with pageview' + requestParams);
+ return '&fa_pv=1' + requestParams;
+ }
+
+ return '';
+ },
+ unload: function () {
+ var tracker;
+ for (var i = 0; i < formTrackerInstances.length; i++) {
+ tracker = formTrackerInstances[i];
+
+ if (tracker && tracker.trackingTimeout) {
+ logConsoleMessage('before unload');
+ clearTimeout(tracker.trackingTimeout);
+ tracker.sendUpdate(tracker.fieldsWithUpdates, {}, true);
+ }
+ }
+ }
+ });
+
+ if (window.Piwik.initialized) {
+ Piwik.on('TrackerSetup', enrichTracker);
+
+ // now that the methods are set on the tracker instance we check if there were calls that couldn't be executed
+ // the first time because the form analytics plugin was not loaded yet (but it is now)
+ Piwik.retryMissedPluginCalls();
+
+ callAsyncReadyMethod();
+ startScanningForForms();
+
+ } else {
+ Piwik.on('TrackerSetup', enrichTracker);
+
+ Piwik.on('PiwikInitialized', function () {
+ callAsyncReadyMethod();
+
+ // at this point the first tracker was created, and all methods called by a user on _paq applied.
+ // this means now we can start looking for form because if someone has disabled eg tracking events
+ // or tracking progress or enabled debug etc we can be sure the form tracker has been configured
+ startScanningForForms();
+ });
+ }
+ }
+
+ if ('object' === typeof window.Piwik) {
+ init();
+ } else {
+ // tracker is loaded separately for sure
+ if ('object' !== typeof window.piwikPluginAsyncInit) {
+ window.piwikPluginAsyncInit = [];
+ }
+
+ window.piwikPluginAsyncInit.push(init);
+ }
+
+})();
+/* END GENERATED: tracker.js */
+
+
+/* GENERATED: tracker.js */
+/*!
+ * Copyright (C) InnoCraft Ltd - All rights reserved.
+ *
+ * All information contained herein is, and remains the property of InnoCraft Ltd.
+ *
+ * @link https://www.innocraft.com/
+ * @license For license details see https://www.innocraft.com/license
+ */
+
+/**
+ * To minify this version call
+ * cat tracker.js | java -jar ../../js/yuicompressor-2.4.7/build/yuicompressor-2.4.7.jar --type js --line-break 1000 | sed 's/^[/][*]/\/*!/' > tracker.min.js
+ */
+
+(function () {
+
+ var NAME_ORIGINAL_VARIATION = 'original';
+ var debugMode = false;
+
+ var storageNamespace = 'PiwikAbTesting';
+
+ function logConsoleMessage() {
+ if (debugMode && 'undefined' !== typeof console && console && console.debug) {
+ console.debug.apply(console, arguments);
+ }
+ }
+
+ function throwError(message) {
+ logConsoleMessage(message);
+
+ if (typeof Experiment !== 'undefined' && Experiment && Experiment.THROW_ERRORS) {
+ throw new Error(message);
+ }
+ }
+
+ var utils = {
+ getRandomNumber: function (min, max) {
+ return parseInt(Math.round(Math.random() * (max - min) + min, 10));
+ },
+ hasLocalStorage: function() {
+ if (typeof localStorage === 'undefined') {
+ return false;
+ }
+
+ var uid = new Date();
+ var result;
+ try {
+ localStorage.setItem(uid, uid);
+ result = localStorage.getItem(uid) == uid;
+ localStorage.removeItem(uid);
+ return result && localStorage && typeof JSON === 'object' && typeof JSON.parse === 'function';
+ } catch (e) {
+ return false;
+ }
+ },
+ decodeSafe: function (text) {
+ try {
+ return window.decodeURIComponent(text);
+ } catch (e) {
+ return window.unescape(text);
+ }
+ },
+ getQueryParameter: function (search, parameter) {
+ search = ('' + search).toLowerCase();
+ parameter = ('' + parameter).toLowerCase();
+
+ var regexp = new RegExp('[?&]' + parameter + '(=([^&#]*)|&|#|$)', 'i');
+ var matches = regexp.exec(search);
+
+ if (!matches) {
+ return null;
+ }
+
+ if (!matches[2]) {
+ return '';
+ }
+
+ var value = matches[2].replace(/\+/g, " ");
+
+ return this.decodeSafe(value);
+ },
+ removeQueryAndHashFromUrl: function (url) {
+ var posHash = url.indexOf('#');
+ if (posHash !== -1) {
+ url = url.substr(0, posHash);
+ }
+
+ var posQuery = url.indexOf('?');
+ if (posQuery !== -1) {
+ url = url.substr(0, posQuery);
+ }
+
+ return url;
+ },
+ removeProtocol: function (url) {
+ var posHash = ('' + url).indexOf('://');
+ if (posHash !== -1 && posHash < 9) {
+ return url.substr(posHash);
+ }
+
+ return url;
+ },
+ removeWwwSubdomain: function (url) {
+ return ('' + url).replace('://www.', '://');
+ },
+ getVariationTest: function (location) {
+ if (location && location.search) {
+ var testVariation = utils.getQueryParameter(location.search, 'pk_ab_test');
+ if (testVariation) {
+ logConsoleMessage('requested variation test ' + testVariation);
+ return String(testVariation).split(',');
+ }
+ }
+
+ return [];
+ }
+ };
+
+ var storage = {
+ local: function () {
+ var rawData = localStorage.getItem(storageNamespace) || '{}';
+ var data = JSON.parse(rawData) || {};
+ this.set = function (group, key, value) {
+ key = group + ':' + key;
+ data[key] = value;
+ localStorage.setItem(storageNamespace, JSON.stringify(data));
+ };
+ this.get = function (group, key) {
+ key = group + ':' + key;
+ if (data && key in data) {
+ return data[key];
+ }
+ };
+ this.clearAll = function (){
+ data = {};
+ localStorage.setItem(storageNamespace, JSON.stringify({}));
+ }
+ },
+ cookies: function () {
+ this.set = function (group, key, value) {
+ key = storageNamespace + ':' + group + ':' + key;
+ var days = 365;
+ var date = new Date();
+ date.setTime(date.getTime()+(days*24*60*60*1000));
+ var expires = "; expires="+date.toGMTString();
+
+ document.cookie = key + '=' + encodeURIComponent(value) +'; expires=' + expires + '; path=/';
+ };
+ this.get = function (group, key) {
+ key = storageNamespace + ':' + group + ':' + key;
+ var param = key + '=';
+ var cookieParts = document.cookie.split(';');
+
+ for (var i=0; i < cookieParts.length; i++) {
+ var cookiePart = cookieParts[i];
+ cookiePart = ('' + cookiePart).replace(/^\s+/, '');
+
+ if (cookiePart.indexOf(param) == 0) {
+ return decodeURIComponent(cookiePart.substring(param.length, cookiePart.length));
+ }
+ }
+ };
+ this.clearAll = function (){ }
+ }
+ };
+
+ var target = {
+ location: window.location,
+ matchesTarget: function (targetObj) {
+ if (!targetObj || !targetObj.type || !targetObj.attribute) {
+ return true;
+ }
+
+ var attributeValue = target._getValueForAttribute(targetObj);
+
+ return target._matchesAttribute(targetObj, attributeValue);
+ },
+ matchesTargets: function (includedTargets, excludedTargets) {
+ if (excludedTargets && excludedTargets.length) {
+ var excludedTarget;
+ for (var i = 0; i < excludedTargets.length; i++) {
+ excludedTarget = excludedTargets[i];
+ if (this.matchesTarget(excludedTarget)) {
+ return false;
+ }
+ }
+ }
+
+ if (includedTargets && includedTargets.length) {
+ var includedTarget;
+ for (var i = 0; i < includedTargets.length; i++) {
+ includedTarget = includedTargets[i];
+ if (this.matchesTarget(includedTarget)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ return true;
+ },
+ matchesDate: function(now, startDateTime, endDateTime) {
+ var currentTimestampUTC = now.getTime() + (now.getTimezoneOffset() * 60000);
+
+ try {
+ var start = new Date(startDateTime);
+ } catch (e) {
+ if (startDateTime) {
+ throwError('Invalid startDateTime given');
+ }
+ }
+
+ try {
+ var end = new Date(endDateTime);
+ } catch (e) {
+ if (endDateTime) {
+ throwError('Invalid startDateTime given');
+ }
+ }
+
+ if (startDateTime && isNaN && isNaN(start.getTime())) {
+ throwError('Invalid startDateTime given');
+ }
+
+ if (endDateTime && isNaN && isNaN(end.getTime())) {
+ throwError('Invalid endDateTime given');
+ }
+
+ if (startDateTime && currentTimestampUTC < (start.getTime() + (start.getTimezoneOffset() * 60000))) {
+ return false;
+ }
+
+ if (endDateTime && currentTimestampUTC > (end.getTime() + (end.getTimezoneOffset() * 60000))) {
+ return false;
+ }
+
+ return true;
+ },
+ _getValueForAttribute: function (target) {
+ var attribute = ('' + target.attribute).toLowerCase();
+
+ switch (attribute) {
+ case Experiment.TARGET_ATTRIBUTE_URL:
+ return utils.decodeSafe(this.location.href);
+ case Experiment.TARGET_ATTRIBUTE_PATH:
+ return utils.decodeSafe(this.location.pathname);
+ case Experiment.TARGET_ATTRIBUTE_URLPARAM:
+ return utils.getQueryParameter(this.location.search, target.value);
+ }
+ },
+ _matchesAttribute: function (target, attributeValue) {
+ var attribute = ('' + target.attribute).toLowerCase();
+
+ switch (attribute) {
+ case Experiment.TARGET_ATTRIBUTE_URL:
+ case Experiment.TARGET_ATTRIBUTE_PATH:
+ return this._matchesTargetValue(attributeValue, target.type, target.inverted, target.value);
+ case Experiment.TARGET_ATTRIBUTE_URLPARAM:
+ return this._matchesTargetValue(attributeValue, target.type, target.inverted, target.value2);
+ default:
+ throwError('Invalid target attribute');
+ }
+
+ return false;
+ },
+ _matchesTargetValue: function (attributeValue, type, invert, valueToMatch) {
+ var matches = false;
+ var invert = !!invert && invert !== '0';
+
+ if ('string' === typeof attributeValue) {
+ attributeValue = attributeValue.toLowerCase();
+ }
+
+ if ('string' === typeof valueToMatch && type !== 'regexp') {
+ valueToMatch = valueToMatch.toLowerCase();
+ }
+
+ switch (type) {
+ case Experiment.TARGET_TYPE_ANY:
+ matches = true;
+ break;
+ case Experiment.TARGET_TYPE_EXISTS:
+ if (typeof attributeValue !== 'undefined' && attributeValue !== null) {
+ matches = true;
+ }
+ break;
+ case Experiment.TARGET_TYPE_EQUALS_SIMPLE:
+ if (attributeValue && attributeValue === String(valueToMatch)) {
+ matches = true;
+ }
+
+ attributeValue = utils.removeQueryAndHashFromUrl(attributeValue);
+ attributeValue = utils.removeProtocol(attributeValue);
+ valueToMatch = utils.removeProtocol(valueToMatch);
+
+ attributeValue = utils.removeWwwSubdomain(attributeValue);
+ valueToMatch = utils.removeWwwSubdomain(valueToMatch);
+
+ if (attributeValue && (attributeValue === String(valueToMatch) ||
+ attributeValue + '/' === String(valueToMatch) ||
+ attributeValue === '/' + valueToMatch ||
+ attributeValue === valueToMatch + '/' ||
+ attributeValue === '/' + valueToMatch + '/')) {
+ matches = true;
+ }
+ break;
+ case Experiment.TARGET_TYPE_EQUALS_EXACTLY:
+
+ if (attributeValue && attributeValue === String(valueToMatch)) {
+ matches = true;
+ }
+
+ if (attributeValue && attributeValue.indexOf('://') > 0
+ && attributeValue.charAt(attributeValue.length - 1) === '/'
+ && 3 === (attributeValue.split('/').length - 1)
+ && attributeValue === (valueToMatch + '/')) {
+ // when url like https://⁄innocraft.com/ => match https://innocraft.com
+ matches = true;
+ }
+
+ if (valueToMatch && valueToMatch.indexOf('://') > 0
+ && valueToMatch.charAt(valueToMatch.length - 1) === '/'
+ && 3 === (valueToMatch.split('/').length - 1)
+ && valueToMatch === (attributeValue + '/')) {
+ // when url like https://⁄innocraft.com => match https://innocraft.com/
+ matches = true;
+ }
+ break;
+ case Experiment.TARGET_TYPE_CONTAINS:
+ if (attributeValue && attributeValue.indexOf(String(valueToMatch)) !== -1) {
+ matches = true;
+ }
+ break;
+ case Experiment.TARGET_TYPE_STARTS_WITH:
+ if (attributeValue && attributeValue.indexOf(String(valueToMatch)) === 0) {
+ matches = true;
+ }
+ break;
+ case Experiment.TARGET_TYPE_REGEXP:
+ if (new RegExp(valueToMatch).test(attributeValue)) {
+ matches = true;
+ }
+ break;
+ default:
+ throwError('Invalid target type given');
+ }
+
+ if (invert) {
+ return !matches;
+ }
+
+ return matches;
+ }
+ };
+
+ var Experiment = function (options) {
+
+ this.options = options ? options : {};
+
+ logConsoleMessage('creating experiment with options', options);
+
+ if (!this.options.name) {
+ throwError('Missing experiment name in options. Use eg: new PiwikAbTesting.Experiment({name: "MyName"})');
+ }
+
+ if (!this.options.variations) {
+ throwError('Missing "variations" option. Use eg: new PiwikAbTesting.Experiment({variations: [{...}, {...}]})');
+ }
+
+ if (typeof this.options.variations !== 'object' || !this.options.variations.length) {
+ throwError('"variations" has to be an array');
+ }
+
+ var i;
+ for (i = 0; i < this.options.variations.length; i++) {
+ if (typeof this.options.variations[i] !== 'object') {
+ throwError('Each variation has to be an object');
+ }
+
+ if (!this.options.variations[i].name) {
+ throwError('Missing variation name');
+ }
+
+ if (typeof this.options.variations[i].activate !== 'function') {
+ throwError('A variation does not implement the "activate" method' + JSON.stringify(options));
+ }
+ }
+
+ if (this.options.trigger && typeof this.options.trigger !== 'function') {
+ throwError('The "trigger" option is not a function');
+ }
+
+ if (this.options.piwikTracker) {
+ if (typeof this.options.piwikTracker !== 'object') {
+ throwError('The Matomo tracker must be an instance of Piwik');
+ }
+
+ if (!this.options.piwikTracker['trackEvent']) {
+ throwError('The Matomo instance does not implement the trackEvent method. Maybe a wrong Matomo instance is based as option?');
+ }
+
+ if (!this.options.piwikTracker['trackGoal']) {
+ throwError('The Matomo instance does not implement the trackGoal method. Maybe a wrong Matomo instance is based as option?');
+ }
+ }
+
+ if (this.options.percentage && this.options.percentage < 0 || this.options.percentage > 100) {
+ throwError('percentage has to be between 0 and 100');
+ }
+
+ this.name = null;
+ this.variations = null;
+ this.includedTargets = null;
+ this.excludedTargets = null;
+ this.startDateTime = null;
+ this.endDateTime = null;
+ this.percentage = 100;
+ this.piwikTracker = null;
+ this.trigger = function () {
+ return true;
+ };
+
+ // we cache this name once a name has been forced as it might be useful if localstorage does not work
+ // and cookies are disabled. In this case the method getActivatedVariationName() will behave as expected
+ this._cacheForcedVariationName = null;
+
+ if (utils.hasLocalStorage()) {
+ logConsoleMessage('using local storage');
+ this.storage = new storage.local();
+ } else {
+ logConsoleMessage('using cookies storage');
+ this.storage = new storage.cookies();
+ }
+
+ var j;
+ for (j in this.options) {
+ if (Object.prototype.hasOwnProperty.call(this.options, j)) {
+ this[j] = this.options[j];
+ }
+ }
+
+ this._track = function (method, args) {
+ if (this.piwikTracker) {
+ this.piwikTracker[method].apply(this.piwikTracker, args);
+ } else {
+ if (typeof _paq === 'undefined') {
+ _paq = [];
+ }
+
+ args.unshift(method);
+ _paq.push(args);
+ }
+
+ logConsoleMessage('sent tracking request', method, args);
+ };
+
+ this.trackUsedVariation = function (variationName) {
+ this._track('trackEvent', ['abtesting', this.name, variationName]);
+ }
+
+ this.trackGoal = function (idGoal) {
+ if (idGoal) {
+ this._track('trackGoal', [idGoal]);
+ }
+ };
+
+ this._getVariationByName = function (variationName) {
+ variationName = ('' + variationName).toLowerCase();
+
+ for (var i = 0; i < this.variations.length; i++) {
+ if (('' + this.variations[i].name).toLowerCase() === variationName) {
+ return this.variations[i];
+ }
+ }
+ };
+
+ this._makeEvent = function (variation) {
+ var experiment = this;
+
+ var onReady = function (callback) {
+ // minimal onready implementation in case matomo does not have DOM ready for some reason
+ callback();
+ };
+
+ if ('undefined' !== typeof Piwik && 'undefined' !== typeof Piwik.DOM && Piwik.DOM.onReady) {
+ onReady = Piwik.DOM.onReady;
+ }
+
+ return {
+ type: 'activate',
+ experiment: this,
+ onReady: onReady,
+ redirect: function (url) {
+ var part = 'pk_abe=' + encodeURIComponent(experiment.name) + '&pk_abv=' + encodeURIComponent(variation.name);
+ if (url && (url.indexOf('?') !== -1)) {
+ url += '&' + part;
+ } else {
+ url += '?' + part;
+ }
+
+ var trackers = Piwik.getAsyncTrackers();
+ for (var i = 0; i < trackers.length; i++) {
+ trackers[i].trackPageView = function () {};
+ trackers[i].trackEvent = function () {};
+ trackers[i].trackGoal = function () {};
+ }
+
+ if (window.location.href === url) {
+ return;
+ }
+
+ window.location.replace(url);
+ }
+ };
+ };
+
+ this.forceVariation = function (variationName) {
+ this._cacheForcedVariationName = variationName;
+
+ logConsoleMessage(this.name, 'forcing variation', variationName);
+
+ var variation = this._getVariationByName(variationName);
+
+ var result = this.storage.set('variation', this.name, variationName);
+
+ if (variation && variation.activate) {
+ var event = this._makeEvent(variation);
+ variation.activate.apply(variation, [event]);
+ }
+
+ this.trackUsedVariation(variationName);
+
+ return result;
+ };
+
+ // returns undefined if no variation has been activated so far
+ this.getActivatedVariationName = function () {
+ var variationName;
+ if (this._cacheForcedVariationName) {
+ variationName = this._cacheForcedVariationName;
+ } else {
+ variationName = this.storage.get('variation', this.name);
+ }
+
+ if (this._getVariationByName(variationName)) {
+ return variationName;
+ }
+ };
+
+ this._doVariationsIncludeOriginal = function () {
+ for (var i = 0; i < this.variations.length; i++) {
+ var variation = this.variations[i];
+ if (variation && variation.name && variation.name === NAME_ORIGINAL_VARIATION) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ this._getVariationDefaultPercentage = function () {
+ var percentageUsed = 100;
+
+ var numVariations = this.variations.length;
+
+ for (var i = 0; i < this.variations.length; i++) {
+ var variation = this.variations[i];
+ if (variation && (variation.percentage || variation.percentage === 0 || variation.percentage === '0')) {
+ percentageUsed = percentageUsed - parseInt(variation.percentage, 10);
+ numVariations--;
+ }
+ };
+
+ var result = Math.round(percentageUsed / numVariations);
+
+ if (result > 100) {
+ result = 100;
+ }
+
+ if (result < 0) {
+ result = 0;
+ }
+
+ return result;
+ }
+
+ this.getRandomVariationName = function () {
+ var defaultPercentage = this._getVariationDefaultPercentage();
+ var indexes = [];
+
+ for (var i = 0; i < this.variations.length; i++) {
+ var percentage = defaultPercentage;
+ if (this.variations[i].percentage || this.variations[i].percentage === 0 || this.variations[i].percentage === '0') {
+ percentage = this.variations[i].percentage;
+ }
+
+ for (var j = 0; j < percentage; j++) {
+ indexes.push(i);
+ }
+ }
+
+ var index = utils.getRandomNumber(0, indexes.length - 1);
+ var variationIndex = indexes[index];
+
+ return this.variations[variationIndex].name;
+ };
+
+ this._isInTestGroup = function () {
+ var active = this.storage.get('isInTestGroup', this.name);
+
+ if (typeof active !== 'undefined' && active !== null) {
+ return active === '1' ? true : false;
+ }
+
+ active = utils.getRandomNumber(1, 100) <= this.percentage;
+
+ this.storage.set('isInTestGroup', this.name, active ? '1' : '0');
+
+ return active;
+ };
+
+ this.selectRandomVariation = function () {
+ logConsoleMessage(this.name, 'select random variation');
+
+ var variationName = this.getRandomVariationName();
+
+ this.forceVariation(variationName);
+
+ return variationName;
+ };
+
+ this.shouldTrigger = function () {
+ if (!target.matchesDate(new Date(), this.startDateTime, this.endDateTime)) {
+ logConsoleMessage(this.name, 'wont run, scheduled date does not match');
+ return false;
+ }
+
+ if (!target.matchesTargets(this.includedTargets, this.excludedTargets)) {
+ logConsoleMessage(this.name, 'wont run, targets do not match');
+ return false;
+ }
+
+ if (!this.trigger()) {
+ logConsoleMessage(this.name, 'wont run, disabled by trigger method');
+ // trigger is optional user defined function passed via options
+ return false;
+ }
+
+ if (!this._isInTestGroup()) {
+ logConsoleMessage(this.name, 'wont run, not in test group');
+ return false;
+ }
+
+ return true;
+ };
+
+ if (!this._doVariationsIncludeOriginal()) {
+ this.variations.push({name: NAME_ORIGINAL_VARIATION, activate: function () {}});
+ }
+
+ var testVariations = utils.getVariationTest(window.location || null);
+ if (testVariations && testVariations.length) {
+ for (var i = 0; i < testVariations.length; i++) {
+ if (this._getVariationByName(testVariations[i])) {
+
+ logConsoleMessage('going to test variation and disable tracking ' + testVariations[i]);
+ // we ignore shouldTrigger and always execute it if the variation actually exists
+ this.trackUsedVariation = function () { };
+ // we make sure to not track anything when testing it so the experiment does not yet get activated in Piwik
+ this.forceVariation(testVariations[i]);
+ return;
+ }
+ }
+ }
+
+ if (!this.shouldTrigger()) {
+ logConsoleMessage(this.name, 'experiment should not trigger');
+ return;
+ }
+
+ logConsoleMessage(this.name, 'should trigger');
+
+ var variationName = this.getActivatedVariationName();
+
+ if (variationName) {
+ this.forceVariation(variationName);
+ } else {
+ logConsoleMessage(this.name, 'no existing variation found');
+ this.selectRandomVariation();
+ }
+ }
+
+ Experiment.NAME_ORIGINAL_VARIATION = NAME_ORIGINAL_VARIATION;
+ Experiment.TARGET_ATTRIBUTE_URL = 'url';
+ Experiment.TARGET_ATTRIBUTE_PATH = 'path';
+ Experiment.TARGET_ATTRIBUTE_URLPARAM = 'urlparam';
+ Experiment.TARGET_TYPE_ANY = 'any';
+ Experiment.TARGET_TYPE_EXISTS = 'exists';
+ Experiment.TARGET_TYPE_EQUALS_SIMPLE = 'equals_simple';
+ Experiment.TARGET_TYPE_EQUALS_EXACTLY = 'equals_exactly';
+ Experiment.TARGET_TYPE_CONTAINS = 'contains';
+ Experiment.TARGET_TYPE_STARTS_WITH = 'starts_with';
+ Experiment.TARGET_TYPE_REGEXP = 'regexp';
+ Experiment.THROW_ERRORS = true;
+
+ function callAsyncReadyMethod()
+ {
+ if (typeof window === 'object' && 'function' === typeof window.piwikAbTestingAsyncInit) {
+ window.piwikAbTestingAsyncInit();
+ }
+ }
+
+ function enterUserFromUrl()
+ {
+ if (window.location && utils.getQueryParameter(window.location.search, 'pk_abe')) {
+ var e = utils.getQueryParameter(window.location.search, 'pk_abe');
+ var v = utils.getQueryParameter(window.location.search, 'pk_abv');
+ Piwik.AbTesting.enter({experiment: e, variation: v});
+ logConsoleMessage('entered experiment from url parameters');
+ }
+ }
+
+ function init() {
+ if ('object' === typeof window && 'object' === typeof window.Piwik && 'object' === typeof window.Piwik.AbTesting) {
+ logConsoleMessage('wont initialize, AbTesting already loaded');
+ // do not initialize abtesting twice
+ return;
+ }
+
+ if ('object' === typeof window && 'object' !== typeof window.Piwik) {
+ logConsoleMessage('wont initialize, Matomo is not yet loaded');
+ // piwik is not defined yet
+ return;
+ }
+
+ Piwik.AbTesting = {
+ utils: utils, target: target, storage: storage, Experiment: Experiment,
+ enter: function (args) {
+ if (args && args.experiment) {
+ _paq = _paq || [];
+ _paq.push(['trackEvent', 'abtesting', args.experiment, args.variation || NAME_ORIGINAL_VARIATION]);
+ logConsoleMessage('entering user into an experiment', args);
+ } else {
+ logConsoleMessage('not entering user into an experiment, missing parameter experiment');
+ }
+ },
+ create: function (args) {
+ return new Experiment(args);
+ },
+ enableDebugMode: function () {
+ debugMode = true
+ }
+ };
+
+ if (window.Piwik.initialized) {
+ // tracker was separately loaded via separate include. we need to enrich already created tracker
+ // now that the methods are set on the tracker instance we check if there were calls that couldn't be executed
+ // the first time because the abtesting plugin was not loaded yet (but it is now)
+ Piwik.retryMissedPluginCalls();
+ callAsyncReadyMethod();
+ enterUserFromUrl();
+ } else {
+ Piwik.on('PiwikInitialized', function () {
+ callAsyncReadyMethod();
+ enterUserFromUrl();
+ });
+ }
+
+ }
+
+ if (typeof piwikExposeAbTestingTarget !== 'undefined' && piwikExposeAbTestingTarget) {
+ // needed for piwik itself
+ window.piwikAbTestingTarget = target;
+ }
+
+ if ('object' === typeof window.Piwik) {
+ logConsoleMessage('matomo was already loaded, initializing abTesting now');
+ init();
+ } else {
+ // tracker is loaded separately for sure
+ if ('object' !== typeof window.piwikPluginAsyncInit) {
+ window.piwikPluginAsyncInit = [];
+ }
+
+ window.piwikPluginAsyncInit.push(init);
+
+ logConsoleMessage('matomo not loaded yet, waiting for it to be loaded');
+ }
+
+})();
+/* END GENERATED: tracker.js */
+
+
+(function () {
+ 'use strict';
+
+ function hasPaqConfiguration()
+ {
+ if ('object' !== typeof _paq) {
+ return false;
+ }
+ // needed to write it this way for jslint
+ var lengthType = typeof _paq.length;
+ if ('undefined' === lengthType) {
+ return false;
+ }
+
+ return !!_paq.length;
+ }
+
+ if (window
+ && 'object' === typeof window.piwikPluginAsyncInit
+ && window.piwikPluginAsyncInit.length) {
+ var i = 0;
+ for (i; i < window.piwikPluginAsyncInit.length; i++) {
+ if (typeof window.piwikPluginAsyncInit[i] === 'function') {
+ window.piwikPluginAsyncInit[i]();
+ }
+ }
+ }
+
+ if (window && window.piwikAsyncInit) {
+ window.piwikAsyncInit();
+ }
+
+ if (!window.Piwik.getAsyncTrackers().length) {
+ // we only create an initial tracker when no other async tracker has been created yet in piwikAsyncInit()
+ if (hasPaqConfiguration()) {
+ // we only create an initial tracker if there is a configuration for it via _paq. Otherwise
+ // Piwik.getAsyncTrackers() would return unconfigured trackers
+ window.Piwik.addTracker();
+ } else {
+ _paq = {push: function (args) {
+ // needed to write it this way for jslint
+ var consoleType = typeof console;
+ if (consoleType !== 'undefined' && console && console.error) {
+ console.error('_paq.push() was used but Matomo tracker was not initialized before the matomo.js file was loaded. Make sure to configure the tracker via _paq.push before loading matomo.js. Alternatively, you can create a tracker via Matomo.addTracker() manually and then use _paq.push but it may not fully work as tracker methods may not be executed in the correct order.', args);
+ }
+ }};
+ }
+ }
+
+ window.Piwik.trigger('PiwikInitialized', []);
+ window.Piwik.initialized = true;
+}());
+
+
+/*jslint sloppy: true */
+(function () {
+ var jsTrackerType = (typeof AnalyticsTracker);
+ if (jsTrackerType === 'undefined') {
+ AnalyticsTracker = window.Piwik;
+ }
+}());
+/*jslint sloppy: false */
+
+/************************************************************
+ * Deprecated functionality below
+ * Legacy piwik.js compatibility ftw
+ ************************************************************/
+
+/*
+ * Piwik globals
+ *
+ * var piwik_install_tracker, piwik_tracker_pause, piwik_download_extensions, piwik_hosts_alias, piwik_ignore_classes;
+ */
+/*global piwik_log:true */
+/*global piwik_track:true */
+
+/**
+ * Track page visit
+ *
+ * @param string documentTitle
+ * @param int|string siteId
+ * @param string piwikUrl
+ * @param mixed customData
+ */
+if (typeof piwik_log !== 'function') {
+ piwik_log = function (documentTitle, siteId, piwikUrl, customData) {
+ 'use strict';
+
+ function getOption(optionName) {
+ try {
+ if (window['piwik_' + optionName]) {
+ return window['piwik_' + optionName];
+ }
+ } catch (ignore) { }
+
+ return; // undefined
+ }
+
+ // instantiate the tracker
+ var option,
+ piwikTracker = window.Piwik.getTracker(piwikUrl, siteId);
+
+ // initialize tracker
+ piwikTracker.setDocumentTitle(documentTitle);
+ piwikTracker.setCustomData(customData);
+
+ // handle Piwik globals
+ option = getOption('tracker_pause');
+
+ if (option) {
+ piwikTracker.setLinkTrackingTimer(option);
+ }
+
+ option = getOption('download_extensions');
+
+ if (option) {
+ piwikTracker.setDownloadExtensions(option);
+ }
+
+ option = getOption('hosts_alias');
+
+ if (option) {
+ piwikTracker.setDomains(option);
+ }
+
+ option = getOption('ignore_classes');
+
+ if (option) {
+ piwikTracker.setIgnoreClasses(option);
+ }
+
+ // track this page view
+ piwikTracker.trackPageView();
+
+ // default is to install the link tracker
+ if (getOption('install_tracker')) {
+
+ /**
+ * Track click manually (function is defined below)
+ *
+ * @param string sourceUrl
+ * @param int|string siteId
+ * @param string piwikUrl
+ * @param string linkType
+ */
+ piwik_track = function (sourceUrl, siteId, piwikUrl, linkType) {
+ piwikTracker.setSiteId(siteId);
+ piwikTracker.setTrackerUrl(piwikUrl);
+ piwikTracker.trackLink(sourceUrl, linkType);
+ };
+
+ // set-up link tracking
+ piwikTracker.enableLinkTracking();
+ }
+ };
+}
+
+/*! @license-end */