1/*!
2 * Matomo - free/libre analytics platform
3 *
4 * @link https://matomo.org
5 * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
6 */
7
8import {
9  reactive,
10  watch,
11  computed,
12  readonly,
13} from 'vue';
14import MatomoUrl from '../MatomoUrl/MatomoUrl';
15import Matomo from '../Matomo/Matomo';
16import translate from '../translate';
17import Periods from '../Periods/Periods';
18import AjaxHelper from '../AjaxHelper/AjaxHelper';
19import SegmentsStore from '../Segmentation/Segments.store';
20
21const SERIES_COLOR_COUNT = 8;
22const SERIES_SHADE_COUNT = 3;
23
24export interface SegmentComparison {
25  params: {
26    segment: string,
27  },
28  title: string,
29  index: number,
30}
31
32export interface PeriodComparison {
33  params: {
34    period: string,
35    date: string,
36  },
37  title: string,
38  index: number,
39}
40
41export interface AnyComparison {
42  params: { [name: string]: string },
43  title: string,
44  index: number,
45}
46
47export interface ComparisonsStoreState {
48  comparisonsDisabledFor: string[];
49}
50
51export interface ComparisonSeriesInfo {
52  index: number;
53  params: { [key: string]: string };
54  color: string;
55}
56
57function wrapArray<T>(values: T | T[]): T[] {
58  if (!values) {
59    return [];
60  }
61  return values instanceof Array ? values : [values];
62}
63
64export default class ComparisonsStore {
65  private privateState = reactive<ComparisonsStoreState>({
66    comparisonsDisabledFor: [],
67  });
68
69  readonly state = readonly(this.privateState); // for tests
70
71  private colors: { [key: string]: string } = {};
72
73  readonly segmentComparisons = computed(() => this.parseSegmentComparisons());
74
75  readonly periodComparisons = computed(() => this.parsePeriodComparisons());
76
77  readonly isEnabled = computed(() => this.checkEnabledForCurrentPage());
78
79  constructor() {
80    this.loadComparisonsDisabledFor();
81
82    $(() => {
83      this.colors = this.getAllSeriesColors() as { [key: string]: string };
84    });
85
86    watch(
87      () => this.getComparisons(),
88      () => Matomo.postEvent('piwikComparisonsChanged'),
89      { deep: true },
90    );
91  }
92
93  getComparisons(): AnyComparison[] {
94    return (this.getSegmentComparisons() as AnyComparison[])
95      .concat(this.getPeriodComparisons() as AnyComparison[]);
96  }
97
98  isComparing(): boolean {
99    return this.isComparisonEnabled()
100      // first two in each array are for the currently selected segment/period
101      && (this.segmentComparisons.value.length > 1
102        || this.periodComparisons.value.length > 1);
103  }
104
105  isComparingPeriods(): boolean {
106    return this.getPeriodComparisons().length > 1; // first is currently selected period
107  }
108
109  getSegmentComparisons(): SegmentComparison[] {
110    if (!this.isComparisonEnabled()) {
111      return [];
112    }
113
114    return this.segmentComparisons.value;
115  }
116
117  getPeriodComparisons(): PeriodComparison[] {
118    if (!this.isComparisonEnabled()) {
119      return [];
120    }
121
122    return this.periodComparisons.value;
123  }
124
125  getSeriesColor(
126    segmentComparison: SegmentComparison,
127    periodComparison: PeriodComparison,
128    metricIndex = 0,
129  ): string {
130    const seriesIndex = this.getComparisonSeriesIndex(
131      periodComparison.index,
132      segmentComparison.index,
133    ) % SERIES_COLOR_COUNT;
134
135    if (metricIndex === 0) {
136      return this.colors[`series${seriesIndex}`];
137    }
138
139    const shadeIndex = metricIndex % SERIES_SHADE_COUNT;
140    return this.colors[`series${seriesIndex}-shade${shadeIndex}`];
141  }
142
143  getSeriesColorName(seriesIndex: number, metricIndex: number): string {
144    let colorName = `series${(seriesIndex % SERIES_COLOR_COUNT)}`;
145    if (metricIndex > 0) {
146      colorName += `-shade${(metricIndex % SERIES_SHADE_COUNT)}`;
147    }
148    return colorName;
149  }
150
151  isComparisonEnabled(): boolean {
152    return this.isEnabled.value;
153  }
154
155  getIndividualComparisonRowIndices(seriesIndex: number): {
156    segmentIndex: number,
157    periodIndex: number,
158  } {
159    const segmentCount = this.getSegmentComparisons().length;
160    const segmentIndex = seriesIndex % segmentCount;
161    const periodIndex = Math.floor(seriesIndex / segmentCount);
162
163    return {
164      segmentIndex,
165      periodIndex,
166    };
167  }
168
169  getComparisonSeriesIndex(periodIndex: number, segmentIndex: number): number {
170    const segmentCount = this.getSegmentComparisons().length;
171    return periodIndex * segmentCount + segmentIndex;
172  }
173
174  getAllComparisonSeries(): ComparisonSeriesInfo[] {
175    const seriesInfo: ComparisonSeriesInfo[] = [];
176
177    let seriesIndex = 0;
178    this.getPeriodComparisons().forEach((periodComp) => {
179      this.getSegmentComparisons().forEach((segmentComp) => {
180        seriesInfo.push({
181          index: seriesIndex,
182          params: { ...segmentComp.params, ...periodComp.params },
183          color: this.colors[`series${seriesIndex}`],
184        });
185        seriesIndex += 1;
186      });
187    });
188
189    return seriesInfo;
190  }
191
192  removeSegmentComparison(index: number): void {
193    if (!this.isComparisonEnabled()) {
194      throw new Error('Comparison disabled.');
195    }
196
197    const newComparisons: SegmentComparison[] = [...this.segmentComparisons.value];
198    newComparisons.splice(index, 1);
199
200    const extraParams: {[key: string]: string} = {};
201    if (index === 0) {
202      extraParams.segment = newComparisons[0].params.segment;
203    }
204
205    this.updateQueryParamsFromComparisons(
206      newComparisons,
207      this.periodComparisons.value,
208      extraParams,
209    );
210  }
211
212  addSegmentComparison(params: { [name: string]: string }): void {
213    if (!this.isComparisonEnabled()) {
214      throw new Error('Comparison disabled.');
215    }
216
217    const newComparisons = this.segmentComparisons.value
218      .concat([{ params, index: -1, title: '' } as SegmentComparison]);
219    this.updateQueryParamsFromComparisons(newComparisons, this.periodComparisons.value);
220  }
221
222  private updateQueryParamsFromComparisons(
223    segmentComparisons: SegmentComparison[],
224    periodComparisons: PeriodComparison[],
225    extraParams = {},
226  ) {
227    // get unique segments/periods/dates from new Comparisons
228    const compareSegments: {[key: string]: boolean} = {};
229    const comparePeriodDatePairs: {[key: string]: boolean} = {};
230
231    let firstSegment = false;
232    let firstPeriod = false;
233
234    segmentComparisons.forEach((comparison) => {
235      if (firstSegment) {
236        compareSegments[comparison.params.segment] = true;
237      } else {
238        firstSegment = true;
239      }
240    });
241
242    periodComparisons.forEach((comparison) => {
243      if (firstPeriod) {
244        comparePeriodDatePairs[`${comparison.params.period}|${comparison.params.date}`] = true;
245      } else {
246        firstPeriod = true;
247      }
248    });
249
250    const comparePeriods: string[] = [];
251    const compareDates: string[] = [];
252    Object.keys(comparePeriodDatePairs).forEach((pair) => {
253      const parts = pair.split('|');
254      comparePeriods.push(parts[0]);
255      compareDates.push(parts[1]);
256    });
257
258    const compareParams: {[key: string]: string[]} = {
259      compareSegments: Object.keys(compareSegments),
260      comparePeriods,
261      compareDates,
262    };
263
264    // change the page w/ these new param values
265    if (Matomo.helper.isAngularRenderingThePage()) {
266      const search = MatomoUrl.hashParsed.value;
267
268      const newSearch: {[key: string]: string|string[]} = {
269        ...search,
270        ...compareParams,
271        ...extraParams,
272      };
273
274      delete newSearch['compareSegments[]'];
275      delete newSearch['comparePeriods[]'];
276      delete newSearch['compareDates[]'];
277
278      if (JSON.stringify(newSearch) !== JSON.stringify(search)) {
279        MatomoUrl.updateHash(newSearch);
280      }
281
282      return;
283    }
284
285    const paramsToRemove: string[] = [];
286    ['compareSegments', 'comparePeriods', 'compareDates'].forEach((name) => {
287      if (!compareParams[name].length) {
288        paramsToRemove.push(name);
289      }
290    });
291
292    // angular is not rendering the page (ie, we are in the embedded dashboard) or we need to change
293    // the segment
294    const url = MatomoUrl.stringify(extraParams);
295    const strHash = MatomoUrl.stringify(compareParams);
296
297    window.broadcast.propagateNewPage(url, undefined, strHash, paramsToRemove);
298  }
299
300  private getAllSeriesColors() {
301    const { ColorManager } = Matomo;
302    if (!ColorManager) {
303      return [];
304    }
305
306    const seriesColorNames = [];
307
308    for (let i = 0; i < SERIES_COLOR_COUNT; i += 1) {
309      seriesColorNames.push(`series${i}`);
310      for (let j = 0; j < SERIES_SHADE_COUNT; j += 1) {
311        seriesColorNames.push(`series${i}-shade${j}`);
312      }
313    }
314
315    return ColorManager.getColors('comparison-series-color', seriesColorNames);
316  }
317
318  private loadComparisonsDisabledFor() {
319    const matomoModule: string = MatomoUrl.parsed.value.module as string;
320    if (matomoModule === 'CoreUpdater'
321      || matomoModule === 'Installation'
322    ) {
323      this.privateState.comparisonsDisabledFor = [];
324      return;
325    }
326
327    AjaxHelper.fetch({
328      module: 'API',
329      method: 'API.getPagesComparisonsDisabledFor',
330    }).then((result) => {
331      this.privateState.comparisonsDisabledFor = result;
332    });
333  }
334
335  private parseSegmentComparisons(): SegmentComparison[] {
336    const { availableSegments } = SegmentsStore.state;
337
338    const compareSegments: string[] = [
339      ...wrapArray(MatomoUrl.parsed.value.compareSegments as string[]),
340    ];
341
342    // add base comparisons
343    compareSegments.unshift(MatomoUrl.parsed.value.segment as string || '');
344
345    const newSegmentComparisons: SegmentComparison[] = [];
346    compareSegments.forEach((segment, idx) => {
347      let storedSegment!: { definition: string, name: string };
348
349      availableSegments.forEach((s) => {
350        if (s.definition === segment
351          || s.definition === decodeURIComponent(segment)
352          || decodeURIComponent(s.definition) === segment
353        ) {
354          storedSegment = s;
355        }
356      });
357
358      let segmentTitle = storedSegment ? storedSegment.name : translate('General_Unknown');
359      if (segment.trim() === '') {
360        segmentTitle = translate('SegmentEditor_DefaultAllVisits');
361      }
362
363      newSegmentComparisons.push({
364        params: {
365          segment,
366        },
367        title: Matomo.helper.htmlDecode(segmentTitle),
368        index: idx,
369      });
370    });
371
372    return newSegmentComparisons;
373  }
374
375  private parsePeriodComparisons(): PeriodComparison[] {
376    const comparePeriods: string[] = [
377      ...wrapArray(MatomoUrl.parsed.value.comparePeriods as string[]),
378    ];
379
380    const compareDates: string[] = [
381      ...wrapArray(MatomoUrl.parsed.value.compareDates as string[]),
382    ];
383
384    comparePeriods.unshift(MatomoUrl.parsed.value.period as string);
385    compareDates.unshift(MatomoUrl.parsed.value.date as string);
386
387    const newPeriodComparisons: PeriodComparison[] = [];
388    for (let i = 0; i < Math.min(compareDates.length, comparePeriods.length); i += 1) {
389      let title;
390      try {
391        title = Periods.parse(comparePeriods[i], compareDates[i]).getPrettyString();
392      } catch (e) {
393        title = translate('General_Error');
394      }
395
396      newPeriodComparisons.push({
397        params: {
398          date: compareDates[i],
399          period: comparePeriods[i],
400        },
401        title,
402        index: i,
403      });
404    }
405
406    return newPeriodComparisons;
407  }
408
409  private checkEnabledForCurrentPage() {
410    // category/subcategory is not included on top bar pages, so in that case we use module/action
411    const category = MatomoUrl.parsed.value.category || MatomoUrl.parsed.value.module;
412    const subcategory = MatomoUrl.parsed.value.subcategory
413      || MatomoUrl.parsed.value.action;
414
415    const id = `${category}.${subcategory}`;
416    const isEnabled = this.privateState.comparisonsDisabledFor.indexOf(id) === -1
417      && this.privateState.comparisonsDisabledFor.indexOf(`${category}.*`) === -1;
418
419    document.documentElement.classList.toggle('comparisonsDisabled', !isEnabled);
420
421    return isEnabled;
422  }
423}
424