Ubuntu
/*!
* Matomo - free/libre analytics platform
*
* @link https://matomo.org
* @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
*/
import { ILocationService, ITimeoutService } from 'angular';
import { computed, ref, readonly } from 'vue';
import Matomo from '../Matomo/Matomo';
import { Periods, format } from '../Periods'; // important to load all periods here
const { piwik, broadcast } = window;
function isValidPeriod(periodStr: string, dateStr: string) {
try {
Periods.parse(periodStr, dateStr);
return true;
} catch (e) {
return false;
}
}
// using unknown since readonly does not work well with recursive types like QueryParameters
type ParsedQueryParameters = Record;
/**
* URL store and helper functions.
*/
class MatomoUrl {
readonly url = ref(null);
readonly urlQuery = computed(
() => (this.url.value ? this.url.value.search.replace(/^\?/, '') : ''),
);
readonly hashQuery = computed(
() => (this.url.value ? this.url.value.hash.replace(/^[#/?]+/, '') : ''),
);
readonly urlParsed = computed(() => readonly(
this.parse(this.urlQuery.value) as ParsedQueryParameters,
));
readonly hashParsed = computed(() => readonly(
this.parse(this.hashQuery.value) as ParsedQueryParameters,
));
readonly parsed = computed(() => readonly({
...this.urlParsed.value,
...this.hashParsed.value,
} as ParsedQueryParameters));
constructor() {
this.url.value = new URL(window.location.href);
// $locationChangeSuccess is triggered before angularjs changes actual window the hash, so we
// have to hook into this method if we want our event handlers to execute before other angularjs
// handlers (like the reporting page one)
Matomo.on('$locationChangeSuccess', (absUrl: string) => {
this.url.value = new URL(absUrl);
});
this.updatePeriodParamsFromUrl();
}
updateHashToUrl(url: string) {
const $location: ILocationService = Matomo.helper.getAngularDependency('$location');
$location.url(url);
}
updateHash(params: QueryParameters|string) {
const modifiedParams = this.getFinalHashParams(params);
const serializedParams = this.stringify(modifiedParams);
const $location: ILocationService = Matomo.helper.getAngularDependency('$location');
$location.search(serializedParams);
const $timeout: ITimeoutService = Matomo.helper.getAngularDependency('$timeout');
$timeout();
}
updateUrl(params: QueryParameters|string, hashParams: QueryParameters|string = {}) {
const serializedParams: string = typeof params !== 'string' ? this.stringify(params) : params;
const modifiedHashParams = Object.keys(hashParams).length
? this.getFinalHashParams(hashParams, params)
: {};
const serializedHashParams: string = this.stringify(modifiedHashParams);
let url = `?${serializedParams}`;
if (serializedHashParams.length) {
url = `${url}#?${serializedHashParams}`;
}
window.broadcast.propagateNewPage('', undefined, undefined, undefined, url);
}
private getFinalHashParams(
params: QueryParameters|string,
urlParams: QueryParameters|string = {},
) {
const paramsObj = typeof params !== 'string'
? params as QueryParameters
: this.parse(params as string);
const urlParamsObj = typeof params !== 'string'
? urlParams as QueryParameters
: this.parse(urlParams as string);
return {
// these params must always be present in the hash
period: urlParamsObj.period || this.parsed.value.period,
date: urlParamsObj.date || this.parsed.value.date,
segment: urlParamsObj.segment || this.parsed.value.segment,
...paramsObj,
};
}
// if we're in an embedded context, loads an entire new URL, otherwise updates the hash
updateLocation(params: QueryParameters|string) {
if (Matomo.helper.isAngularRenderingThePage()) {
this.updateHash(params);
return;
}
this.updateUrl(params);
}
getSearchParam(paramName: string): string {
const hash = window.location.href.split('#');
const regex = new RegExp(`${paramName}(\\[]|=)`);
if (hash && hash[1] && regex.test(decodeURIComponent(hash[1]))) {
const valueFromHash = window.broadcast.getValueFromHash(paramName, window.location.href);
// for date, period and idsite fall back to parameter from url, if non in hash was provided
if (valueFromHash
|| (paramName !== 'date' && paramName !== 'period' && paramName !== 'idSite')
) {
return valueFromHash;
}
}
return window.broadcast.getValueFromUrl(paramName, window.location.search);
}
parse(query: string): QueryParameters {
return broadcast.getValuesFromUrl(`?${query}`, true);
}
stringify(search: QueryParameters): string {
const searchWithoutEmpty = Object.fromEntries(
Object.entries(search).filter(([, value]) => value !== '' && value !== null && value !== undefined),
);
// TODO: using $ since URLSearchParams does not handle array params the way Matomo uses them
return $.param(searchWithoutEmpty).replace(/%5B%5D/g, '[]')
// some browsers treat URLs w/ date=a,b differently from date=a%2Cb, causing multiple
// entries to show up in the browser history. this has a compounding effect w/ angular.js,
// which when the back button is pressed to effectively abort the back navigation.
.replace(/%2C/g, ',')
// jquery seems to encode space characters as '+', but certain parts of matomo won't
// decode it correctly, so we make sure to use %20 instead
.replace(/\+/g, '%20');
}
updatePeriodParamsFromUrl(): void {
let date = this.getSearchParam('date');
const period = this.getSearchParam('period');
if (!isValidPeriod(period, date)) {
// invalid data in URL
return;
}
if (piwik.period === period && piwik.currentDateString === date) {
// this period / date is already loaded
return;
}
piwik.period = period;
const dateRange = Periods.parse(period, date).getDateRange();
piwik.startDateString = format(dateRange[0]);
piwik.endDateString = format(dateRange[1]);
piwik.updateDateInTitle(date, period);
// do not set anything to previousN/lastN, as it's more useful to plugins
// to have the dates than previousN/lastN.
if (piwik.period === 'range') {
date = `${piwik.startDateString},${piwik.endDateString}`;
}
piwik.currentDateString = date;
}
}
const instance = new MatomoUrl();
export default instance;
piwik.updatePeriodParamsFromUrl = instance.updatePeriodParamsFromUrl.bind(instance);