fg-devkit 1.17.3 → 1.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,785 @@
1
+ /**
2
+ * Local custom-dimension maps and the diff against Piwik PRO.
3
+ *
4
+ * Session and event slots are assigned by Piwik as the next free number in that
5
+ * scope. The number is not reusable after deactivation, and the API does not
6
+ * accept a chosen slot. Product slots can be chosen in the 1–20 range.
7
+ */
8
+
9
+ export const SCOPES = ['event', 'session', 'product']
10
+
11
+ export const SLOT_LIMITS = {
12
+ event: 200,
13
+ session: 200,
14
+ product: 20,
15
+ }
16
+
17
+ /** @typedef {'event' | 'session' | 'product'} DimensionScope */
18
+
19
+ /**
20
+ * @typedef {object} LocalDimension
21
+ * @property {string} key
22
+ * @property {string} name Desired Piwik name.
23
+ * @property {number} slot
24
+ * @property {DimensionScope} scope
25
+ * @property {string} file
26
+ * @property {string} exportName
27
+ */
28
+
29
+ /**
30
+ * @typedef {object} RemoteDimension
31
+ * @property {string} id
32
+ * @property {string} name
33
+ * @property {number} slot
34
+ * @property {number} trackingId
35
+ * @property {DimensionScope} scope
36
+ * @property {boolean} active
37
+ * @property {boolean} caseSensitive
38
+ * @property {string} description
39
+ * @property {string} [websiteId]
40
+ */
41
+
42
+ export class PiwikDimensionError extends Error {
43
+ /**
44
+ * @param {string} message
45
+ */
46
+ constructor(message) {
47
+ super(message)
48
+ this.name = 'PiwikDimensionError'
49
+ }
50
+ }
51
+
52
+ /**
53
+ * @param {string} exportName
54
+ * @returns {DimensionScope | null}
55
+ */
56
+ export function scopeForExportName(exportName) {
57
+ const lower = exportName.toLowerCase()
58
+ if (lower.includes('session')) return 'session'
59
+ if (lower.includes('product')) return 'product'
60
+ if (lower.includes('event') || lower.includes('custom')) return 'event'
61
+ return null
62
+ }
63
+
64
+ /**
65
+ * @param {string} source
66
+ * @param {number} openIndex Index of the opening brace.
67
+ */
68
+ function readBrace(source, openIndex) {
69
+ let depth = 0
70
+ for (let i = openIndex; i < source.length; i += 1) {
71
+ const char = source[i]
72
+ if (char === '{') depth += 1
73
+ else if (char === '}') {
74
+ depth -= 1
75
+ if (depth === 0) return source.slice(openIndex + 1, i)
76
+ }
77
+ }
78
+ throw new PiwikDimensionError('Unclosed brace while reading a dimension export.')
79
+ }
80
+
81
+ /**
82
+ * @param {string} source
83
+ */
84
+ function stripComments(source) {
85
+ return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"])\/\/.*$/gm, '$1')
86
+ }
87
+
88
+ /**
89
+ * @param {string} body
90
+ * @param {{ exportName: string, scope: DimensionScope, file: string }} meta
91
+ * @returns {LocalDimension[]}
92
+ */
93
+ function parseEntries(body, meta) {
94
+ /** @type {LocalDimension[]} */
95
+ const dimensions = []
96
+ const seenKeys = new Set()
97
+ const seenSlots = new Set()
98
+ const entryRe = /([A-Za-z_][A-Za-z0-9_]*)\s*[:=]\s*(\d+)/g
99
+ let match = entryRe.exec(body)
100
+ while (match) {
101
+ const key = match[1]
102
+ const slot = Number(match[2])
103
+ if (seenKeys.has(key)) {
104
+ throw new PiwikDimensionError(`Duplicate dimension key "${key}" in ${meta.exportName} (${meta.file}).`)
105
+ }
106
+ if (seenSlots.has(slot)) {
107
+ throw new PiwikDimensionError(
108
+ `Duplicate ${meta.scope} slot ${slot} in ${meta.exportName} (${meta.file}).`,
109
+ )
110
+ }
111
+ if (!Number.isInteger(slot) || slot < 1) {
112
+ throw new PiwikDimensionError(`Slot for "${key}" must be an integer >= 1.`)
113
+ }
114
+ if (slot > SLOT_LIMITS[meta.scope]) {
115
+ throw new PiwikDimensionError(
116
+ `${meta.scope} slot ${slot} for "${key}" is above the Piwik limit of ${SLOT_LIMITS[meta.scope]}.`,
117
+ )
118
+ }
119
+ seenKeys.add(key)
120
+ seenSlots.add(slot)
121
+ dimensions.push({
122
+ key,
123
+ name: key,
124
+ slot,
125
+ scope: meta.scope,
126
+ file: meta.file,
127
+ exportName: meta.exportName,
128
+ })
129
+ match = entryRe.exec(body)
130
+ }
131
+ return dimensions
132
+ }
133
+
134
+ /**
135
+ * Parse a dimensions module (const map or enum).
136
+ * `customDimensions` / `CustomDimensions` are event scope.
137
+ * Names containing "session" or "product" select those scopes.
138
+ *
139
+ * @param {string} source
140
+ * @param {string} [file]
141
+ * @returns {LocalDimension[]}
142
+ */
143
+ export function parseDimensionSource(source, file = 'dimensions.ts') {
144
+ const stripped = stripComments(source)
145
+ /** @type {LocalDimension[]} */
146
+ const dimensions = []
147
+ const exportRe = /export\s+(?:const|enum)\s+([A-Za-z0-9_]+)/g
148
+ let match = exportRe.exec(stripped)
149
+ while (match) {
150
+ const exportName = match[1]
151
+ const scope = scopeForExportName(exportName)
152
+ const braceAt = stripped.indexOf('{', match.index)
153
+ if (scope && braceAt >= 0) {
154
+ const body = readBrace(stripped, braceAt)
155
+ dimensions.push(...parseEntries(body, { exportName, scope, file }))
156
+ }
157
+ match = exportRe.exec(stripped)
158
+ }
159
+ return dimensions
160
+ }
161
+
162
+ /**
163
+ * @param {Record<string, unknown> | undefined} names
164
+ * @param {LocalDimension} local
165
+ */
166
+ export function desiredName(names, local) {
167
+ if (!names || typeof names !== 'object') return local.key
168
+ const direct = names[local.key]
169
+ if (typeof direct === 'string' && direct.trim() !== '') return direct
170
+ const scoped = names[local.scope]
171
+ if (scoped && typeof scoped === 'object') {
172
+ const value = /** @type {Record<string, unknown>} */ (scoped)[local.key]
173
+ if (typeof value === 'string' && value.trim() !== '') return value
174
+ }
175
+ return local.key
176
+ }
177
+
178
+ /**
179
+ * @param {LocalDimension[]} locals
180
+ * @param {Record<string, unknown> | undefined} names
181
+ * @returns {LocalDimension[]}
182
+ */
183
+ export function applyDesiredNames(locals, names) {
184
+ return locals.map((local) => ({ ...local, name: desiredName(names, local) }))
185
+ }
186
+
187
+ /**
188
+ * Plan writing Piwik tracking ids back into the local map. Keys match on name within a scope.
189
+ * @param {LocalDimension[]} locals
190
+ * @param {RemoteDimension[]} remotes
191
+ */
192
+ export function planLocalPull(locals, remotes) {
193
+ /** @type {Map<string, RemoteDimension>} */
194
+ const byName = new Map()
195
+ /** @type {string[]} */
196
+ const ambiguous = []
197
+ for (const remote of remotes) {
198
+ const key = `${remote.scope}:${remote.name}`
199
+ if (byName.has(key)) ambiguous.push(`${remote.scope} "${remote.name}"`)
200
+ else byName.set(key, remote)
201
+ }
202
+ if (ambiguous.length > 0) {
203
+ throw new PiwikDimensionError(`Piwik has more than one dimension named ${ambiguous.join(', ')}.`)
204
+ }
205
+
206
+ /** @type {{ local: LocalDimension, remote: RemoteDimension, next: number }[]} */
207
+ const updates = []
208
+ /** @type {LocalDimension[]} */
209
+ const inSync = []
210
+ /** @type {LocalDimension[]} */
211
+ const missingInPiwik = []
212
+ for (const local of locals) {
213
+ const remote = byName.get(`${local.scope}:${local.name}`)
214
+ if (!remote) {
215
+ missingInPiwik.push(local)
216
+ continue
217
+ }
218
+ const next = local.scope === 'product' ? remote.slot : remote.trackingId
219
+ if (next === local.slot) inSync.push(local)
220
+ else updates.push({ local, remote, next })
221
+ }
222
+
223
+ /** @type {Map<string, string>} */
224
+ const occupied = new Map()
225
+ for (const local of locals) {
226
+ const moving = updates.find((item) => item.local === local)
227
+ const slot = moving ? moving.next : local.slot
228
+ const key = `${local.scope}:${slot}`
229
+ const existing = occupied.get(key)
230
+ if (existing) {
231
+ throw new PiwikDimensionError(
232
+ `${local.scope} tracking_id ${slot} would be used by both "${existing}" and "${local.key}".`,
233
+ )
234
+ }
235
+ occupied.set(key, local.key)
236
+ }
237
+
238
+ const remoteOnly = remotes.filter(
239
+ (remote) => !locals.some((local) => local.scope === remote.scope && local.name === remote.name),
240
+ )
241
+ return { updates, inSync, missingInPiwik, remoteOnly }
242
+ }
243
+
244
+ /**
245
+ * Replace dimension numbers in a source file. `from` is matched together with the key so swaps stay unique.
246
+ * @param {string} source
247
+ * @param {{ key: string, from: number, to: number }[]} updates
248
+ */
249
+ export function rewriteDimensionNumbers(source, updates) {
250
+ let next = source
251
+ for (const [index, update] of updates.entries()) {
252
+ const pattern = new RegExp(`(\\b${update.key}\\s*[:=]\\s*)${update.from}\\b`)
253
+ if (!pattern.test(next)) {
254
+ throw new PiwikDimensionError(`Could not find ${update.key}: ${update.from} in the dimension file.`)
255
+ }
256
+ next = next.replace(pattern, `$1__PIWIK_${index}__`)
257
+ }
258
+ for (const [index, update] of updates.entries()) {
259
+ next = next.replace(`__PIWIK_${index}__`, String(update.to))
260
+ }
261
+ return next
262
+ }
263
+
264
+ /**
265
+ * The app sends dimension{id}. For event and session that id is tracking_id.
266
+ * Product dimensions are chosen by slot.
267
+ * @param {{ scope: DimensionScope, slot: number, trackingId?: number }} dimension
268
+ */
269
+ function dimensionKey(dimension) {
270
+ if (dimension.scope === 'product') return `product:slot:${dimension.slot}`
271
+ const trackingId = dimension.trackingId ?? dimension.slot
272
+ return `${dimension.scope}:tracking:${trackingId}`
273
+ }
274
+
275
+ /**
276
+ * @param {RemoteDimension[]} remotes
277
+ */
278
+ function remoteByAppId(remotes) {
279
+ /** @type {Map<string, RemoteDimension>} */
280
+ const map = new Map()
281
+ for (const remote of remotes) {
282
+ const key = dimensionKey(remote)
283
+ if (map.has(key)) {
284
+ const label = remote.scope === 'product' ? `slot ${remote.slot}` : `tracking_id ${remote.trackingId}`
285
+ throw new PiwikDimensionError(`Piwik returned two ${remote.scope} dimensions for ${label}.`)
286
+ }
287
+ map.set(key, remote)
288
+ }
289
+ return map
290
+ }
291
+
292
+ /**
293
+ * @param {LocalDimension[]} locals
294
+ * @param {RemoteDimension[]} remotes
295
+ */
296
+ export function diffDimensions(locals, remotes) {
297
+ const remoteMap = remoteByAppId(remotes)
298
+ const matched = new Set()
299
+ /** @type {LocalDimension[]} */
300
+ const missing = []
301
+ /** @type {{ local: LocalDimension, remote: RemoteDimension }[]} */
302
+ const inSync = []
303
+ /** @type {{ local: LocalDimension, remote: RemoteDimension }[]} */
304
+ const nameMismatch = []
305
+ /** @type {{ local: LocalDimension, remote: RemoteDimension }[]} */
306
+ const trackingMismatch = []
307
+
308
+ for (const local of locals) {
309
+ const remote = remoteMap.get(dimensionKey(local))
310
+ if (!remote) {
311
+ missing.push(local)
312
+ continue
313
+ }
314
+ matched.add(dimensionKey(remote))
315
+ if (remote.scope !== 'product' && remote.trackingId !== remote.slot) {
316
+ trackingMismatch.push({ local, remote })
317
+ }
318
+ if (remote.name !== local.name) nameMismatch.push({ local, remote })
319
+ else inSync.push({ local, remote })
320
+ }
321
+
322
+ const remoteOnly = remotes.filter((remote) => !matched.has(dimensionKey(remote)))
323
+ return { missing, inSync, nameMismatch, trackingMismatch, remoteOnly }
324
+ }
325
+
326
+ /**
327
+ * Lowest unused slot in a scope. Inactive dimensions still occupy their slot.
328
+ * @param {Set<number>} used
329
+ * @param {DimensionScope} scope
330
+ */
331
+ export function nextFreeSlot(used, scope) {
332
+ const limit = SLOT_LIMITS[scope]
333
+ for (let slot = 1; slot <= limit; slot += 1) {
334
+ if (!used.has(slot)) return slot
335
+ }
336
+ return null
337
+ }
338
+
339
+ /**
340
+ * Lowest unused tracking_id across event and session. The app sends this number as dimensionN.
341
+ * @param {Set<number>} used
342
+ */
343
+ export function nextFreeTrackingId(used) {
344
+ const limit = SLOT_LIMITS.event + SLOT_LIMITS.session
345
+ for (let id = 1; id <= limit; id += 1) {
346
+ if (!used.has(id)) return id
347
+ }
348
+ return null
349
+ }
350
+
351
+ /**
352
+ * @param {LocalDimension[]} missing
353
+ * @param {RemoteDimension[]} remotes
354
+ */
355
+ export function planCreates(missing, remotes) {
356
+ const usedProductSlots = new Set(remotes.filter((remote) => remote.scope === 'product').map((remote) => remote.slot))
357
+ const usedTrackingIds = new Set(
358
+ remotes.filter((remote) => remote.scope !== 'product').map((remote) => remote.trackingId),
359
+ )
360
+
361
+ /** @type {LocalDimension[]} */
362
+ const creates = []
363
+ /** @type {{ local: LocalDimension, reason: string }[]} */
364
+ const blocked = []
365
+
366
+ const products = missing.filter((item) => item.scope === 'product').sort((a, b) => a.slot - b.slot)
367
+ for (const local of products) {
368
+ if (usedProductSlots.has(local.slot)) {
369
+ blocked.push({ local, reason: `Product slot ${local.slot} is already used in Piwik.` })
370
+ continue
371
+ }
372
+ creates.push(local)
373
+ usedProductSlots.add(local.slot)
374
+ }
375
+
376
+ const items = missing
377
+ .filter((item) => item.scope !== 'product')
378
+ .sort((a, b) => a.slot - b.slot || SCOPES.indexOf(a.scope) - SCOPES.indexOf(b.scope))
379
+ let blockedEarlier = false
380
+ for (const local of items) {
381
+ if (blockedEarlier) {
382
+ blocked.push({
383
+ local,
384
+ reason: 'An earlier dimension must be added first. Piwik assigns the next tracking_id and does not let you choose it.',
385
+ })
386
+ continue
387
+ }
388
+ if (usedTrackingIds.has(local.slot)) {
389
+ blocked.push({
390
+ local,
391
+ reason: `dimension${local.slot} is already used by another scope.`,
392
+ })
393
+ continue
394
+ }
395
+ const next = nextFreeTrackingId(usedTrackingIds)
396
+ if (next !== local.slot) {
397
+ blockedEarlier = true
398
+ blocked.push({
399
+ local,
400
+ reason:
401
+ next === null
402
+ ? 'No free tracking_id left.'
403
+ : `Next tracking_id is ${next}. ${local.key} sends dimension${local.slot}, and Piwik does not let you choose it.`,
404
+ })
405
+ continue
406
+ }
407
+ creates.push(local)
408
+ usedTrackingIds.add(local.slot)
409
+ }
410
+
411
+ return { creates, blocked }
412
+ }
413
+
414
+ /**
415
+ * @param {string} value
416
+ * @param {number} width
417
+ */
418
+ function pad(value, width) {
419
+ const text = String(value)
420
+ return text.length >= width ? text : text.padEnd(width)
421
+ }
422
+
423
+ /**
424
+ * @param {boolean | null | undefined} active
425
+ */
426
+ function activeLabel(active) {
427
+ if (active === true) return 'Active'
428
+ if (active === false) return 'Inactive'
429
+ return '—'
430
+ }
431
+
432
+ /**
433
+ * @param {string[]} headers
434
+ * @param {string[][]} rows
435
+ */
436
+ export function renderTable(headers, rows) {
437
+ const widths = headers.map((header, index) =>
438
+ Math.max(header.length, ...rows.map((row) => String(row[index] ?? '').length)),
439
+ )
440
+ const rule = (left, join, right) => left + widths.map((width) => '─'.repeat(width + 2)).join(join) + right
441
+ const formatRow = (cells) => `│${cells.map((cell, index) => ` ${pad(cell, widths[index])} `).join('│')}│`
442
+ return [rule('┌', '┬', '┐'), formatRow(headers), rule('├', '┼', '┤'), ...rows.map(formatRow), rule('└', '┴', '┘')]
443
+ }
444
+
445
+ /**
446
+ * @param {ReturnType<typeof diffDimensions>} diff
447
+ * @param {ReturnType<typeof planCreates>} plan
448
+ */
449
+ export function formatDimensionReport(diff, plan) {
450
+ const lines = []
451
+ lines.push(
452
+ `[piwik] ${diff.inSync.length} in sync, ${diff.nameMismatch.length} different name, ${plan.creates.length} missing in Piwik, ${diff.remoteOnly.length} only in Piwik, ${plan.blocked.length} cannot add`,
453
+ )
454
+ lines.push('[piwik] in app = name in the repo. in Piwik = name in Piwik. tracking_id = dimensionN sent by the app.')
455
+
456
+ /** @type {{ scope: DimensionScope, order: number, slot: string, trackingId: string, appName: string, piwikName: string, active: string, status: string, description: string }[]} */
457
+ const rows = []
458
+ const pushRow = (scope, order, slot, trackingId, appName, piwikName, active, status, description) => {
459
+ rows.push({
460
+ scope,
461
+ order,
462
+ slot: String(slot),
463
+ trackingId: String(trackingId),
464
+ appName,
465
+ piwikName,
466
+ active,
467
+ status,
468
+ description: description.trim(),
469
+ })
470
+ }
471
+ for (const { local, remote } of diff.inSync) {
472
+ pushRow(
473
+ local.scope,
474
+ remote.scope === 'product' ? remote.slot : remote.trackingId,
475
+ remote.slot,
476
+ remote.scope === 'product' ? '—' : remote.trackingId,
477
+ local.name,
478
+ remote.name,
479
+ activeLabel(remote.active),
480
+ 'in sync',
481
+ remote.description,
482
+ )
483
+ }
484
+ for (const { local, remote } of diff.nameMismatch) {
485
+ pushRow(
486
+ local.scope,
487
+ remote.scope === 'product' ? remote.slot : remote.trackingId,
488
+ remote.slot,
489
+ remote.scope === 'product' ? '—' : remote.trackingId,
490
+ local.name,
491
+ remote.name,
492
+ activeLabel(remote.active),
493
+ 'different name',
494
+ remote.description,
495
+ )
496
+ }
497
+ for (const local of plan.creates) {
498
+ pushRow(
499
+ local.scope,
500
+ local.slot,
501
+ '—',
502
+ local.scope === 'product' ? '—' : local.slot,
503
+ local.name,
504
+ '—',
505
+ '—',
506
+ 'missing in Piwik',
507
+ '',
508
+ )
509
+ }
510
+ for (const { local } of plan.blocked) {
511
+ pushRow(
512
+ local.scope,
513
+ local.slot,
514
+ '—',
515
+ local.scope === 'product' ? '—' : local.slot,
516
+ local.name,
517
+ '—',
518
+ '—',
519
+ 'cannot add',
520
+ '',
521
+ )
522
+ }
523
+ for (const remote of diff.remoteOnly) {
524
+ pushRow(
525
+ remote.scope,
526
+ remote.scope === 'product' ? remote.slot : remote.trackingId,
527
+ remote.slot,
528
+ remote.scope === 'product' ? '—' : remote.trackingId,
529
+ '—',
530
+ remote.name,
531
+ activeLabel(remote.active),
532
+ 'only in Piwik',
533
+ remote.description,
534
+ )
535
+ }
536
+
537
+ const showDescription = rows.some((row) => row.description !== '')
538
+ for (const scope of SCOPES) {
539
+ const scoped = rows.filter((row) => row.scope === scope).sort((a, b) => a.order - b.order)
540
+ if (scoped.length === 0) continue
541
+ const headers = ['slot', 'tracking_id', 'in app', 'in Piwik', 'active', 'status']
542
+ if (showDescription) headers.push('description')
543
+ lines.push('')
544
+ lines.push(scope)
545
+ lines.push(
546
+ ...renderTable(
547
+ headers,
548
+ scoped.map((row) => {
549
+ const cells = [row.slot, row.trackingId, row.appName, row.piwikName, row.active, row.status]
550
+ if (showDescription) cells.push(row.description)
551
+ return cells
552
+ }),
553
+ ),
554
+ )
555
+ }
556
+
557
+ for (const { local, reason } of plan.blocked) {
558
+ lines.push(`[piwik] ${local.scope} dimension${local.slot} ${local.key}: ${reason}`)
559
+ }
560
+
561
+ return lines.join('\n')
562
+ }
563
+
564
+ /**
565
+ * @param {RemoteDimension} source
566
+ * @param {RemoteDimension} target
567
+ */
568
+ function environmentChanges(source, target) {
569
+ /** @type {string[]} */
570
+ const fields = []
571
+ if (source.name !== target.name) fields.push('name')
572
+ if (source.description.trim() !== target.description.trim()) fields.push('description')
573
+ if (source.active !== target.active) fields.push('active')
574
+ return fields
575
+ }
576
+
577
+ /**
578
+ * @param {string[]} fields
579
+ */
580
+ function environmentChangeStatus(fields) {
581
+ const labels = {
582
+ name: 'different name',
583
+ description: 'different description',
584
+ active: 'different active',
585
+ }
586
+ return fields.map((field) => labels[field] ?? field).join(', ')
587
+ }
588
+
589
+ /**
590
+ * Compare two Piwik sites. Event and session rows match on tracking_id. Product rows match on slot.
591
+ * @param {RemoteDimension[]} source
592
+ * @param {RemoteDimension[]} target
593
+ */
594
+ export function diffEnvironments(source, target) {
595
+ const targetByKey = new Map(target.map((item) => [dimensionKey(item), item]))
596
+ const sourceKeys = new Set()
597
+ /** @type {{ source: RemoteDimension, target: RemoteDimension }[]} */
598
+ const inSync = []
599
+ /** @type {{ source: RemoteDimension, target: RemoteDimension, fields: string[] }[]} */
600
+ const changed = []
601
+ /** @type {RemoteDimension[]} */
602
+ const missing = []
603
+
604
+ for (const item of source) {
605
+ const key = dimensionKey(item)
606
+ sourceKeys.add(key)
607
+ const other = targetByKey.get(key)
608
+ if (!other) {
609
+ missing.push(item)
610
+ continue
611
+ }
612
+ const fields = environmentChanges(item, other)
613
+ if (fields.length === 0) inSync.push({ source: item, target: other })
614
+ else changed.push({ source: item, target: other, fields })
615
+ }
616
+
617
+ const targetOnly = target.filter((item) => !sourceKeys.has(dimensionKey(item)))
618
+ return { inSync, changed, missing, targetOnly }
619
+ }
620
+
621
+ /**
622
+ * @param {RemoteDimension} remote
623
+ * @returns {LocalDimension}
624
+ */
625
+ function sourceAsLocal(remote) {
626
+ return {
627
+ key: remote.name,
628
+ name: remote.name,
629
+ slot: remote.scope === 'product' ? remote.slot : remote.trackingId,
630
+ scope: remote.scope,
631
+ file: '',
632
+ exportName: '',
633
+ }
634
+ }
635
+
636
+ /**
637
+ * @param {ReturnType<typeof diffEnvironments>} diff
638
+ * @param {RemoteDimension[]} target
639
+ */
640
+ export function planEnvironmentMigration(diff, target) {
641
+ return planCreates(diff.missing.map(sourceAsLocal), target)
642
+ }
643
+
644
+ /**
645
+ * @param {string} sourceDescription
646
+ * @param {string} targetDescription
647
+ */
648
+ function environmentDescription(sourceDescription, targetDescription) {
649
+ const from = sourceDescription.trim()
650
+ const to = targetDescription.trim()
651
+ if (from === to) return from
652
+ return `${from || '—'} → ${to || '—'}`
653
+ }
654
+
655
+ /**
656
+ * @param {boolean | null | undefined} sourceActive
657
+ * @param {boolean | null | undefined} targetActive
658
+ */
659
+ function environmentActive(sourceActive, targetActive) {
660
+ const from = activeLabel(sourceActive)
661
+ const to = activeLabel(targetActive)
662
+ if (from === to) return from
663
+ return `${from} → ${to}`
664
+ }
665
+
666
+ /**
667
+ * @param {ReturnType<typeof diffEnvironments>} diff
668
+ * @param {ReturnType<typeof planCreates>} plan
669
+ * @param {string} fromName
670
+ * @param {string} toName
671
+ */
672
+ export function formatEnvironmentReport(diff, plan, fromName, toName) {
673
+ const lines = []
674
+ lines.push(
675
+ `[piwik] ${fromName} → ${toName}: ${diff.inSync.length} in sync, ${diff.changed.length} different, ${plan.creates.length} missing in ${toName}, ${diff.targetOnly.length} only in ${toName}, ${plan.blocked.length} cannot add`,
676
+ )
677
+ lines.push('[piwik] Event and session rows match on tracking_id. Each environment has its own tracking_id column. A different slot is still in sync.')
678
+
679
+ /** @type {{ scope: DimensionScope, order: number, cells: string[], description: string }[]} */
680
+ const rows = []
681
+ const trackingCell = (remote) => (remote.scope === 'product' ? '—' : String(remote.trackingId))
682
+ const pushRow = (scope, order, slotFrom, trackingFrom, nameFrom, slotTo, trackingTo, nameTo, active, status, description) => {
683
+ rows.push({
684
+ scope,
685
+ order,
686
+ description,
687
+ cells: [String(slotFrom), String(trackingFrom), nameFrom, String(slotTo), String(trackingTo), nameTo, active, status],
688
+ })
689
+ }
690
+ for (const { source, target } of diff.inSync) {
691
+ pushRow(
692
+ source.scope,
693
+ source.scope === 'product' ? source.slot : source.trackingId,
694
+ source.slot,
695
+ trackingCell(source),
696
+ source.name,
697
+ target.slot,
698
+ trackingCell(target),
699
+ target.name,
700
+ environmentActive(source.active, target.active),
701
+ 'in sync',
702
+ environmentDescription(source.description, target.description),
703
+ )
704
+ }
705
+ for (const { source, target, fields } of diff.changed) {
706
+ pushRow(
707
+ source.scope,
708
+ source.scope === 'product' ? source.slot : source.trackingId,
709
+ source.slot,
710
+ trackingCell(source),
711
+ source.name,
712
+ target.slot,
713
+ trackingCell(target),
714
+ target.name,
715
+ environmentActive(source.active, target.active),
716
+ environmentChangeStatus(fields),
717
+ environmentDescription(source.description, target.description),
718
+ )
719
+ }
720
+ const blockedIds = new Set(plan.blocked.map(({ local }) => `${local.scope}:${local.slot}`))
721
+ for (const source of diff.missing) {
722
+ const id = source.scope === 'product' ? source.slot : source.trackingId
723
+ const blocked = blockedIds.has(`${source.scope}:${id}`)
724
+ pushRow(
725
+ source.scope,
726
+ id,
727
+ source.slot,
728
+ trackingCell(source),
729
+ source.name,
730
+ '—',
731
+ '—',
732
+ '—',
733
+ activeLabel(source.active),
734
+ blocked ? 'cannot add' : `missing in ${toName}`,
735
+ source.description.trim(),
736
+ )
737
+ }
738
+ for (const target of diff.targetOnly) {
739
+ const id = target.scope === 'product' ? target.slot : target.trackingId
740
+ pushRow(
741
+ target.scope,
742
+ id,
743
+ '—',
744
+ '—',
745
+ '—',
746
+ target.slot,
747
+ trackingCell(target),
748
+ target.name,
749
+ activeLabel(target.active),
750
+ `only in ${toName}`,
751
+ target.description.trim(),
752
+ )
753
+ }
754
+
755
+ const showDescription = rows.some((row) => row.description !== '')
756
+ const headers = [
757
+ `slot ${fromName}`,
758
+ `tracking_id ${fromName}`,
759
+ fromName,
760
+ `slot ${toName}`,
761
+ `tracking_id ${toName}`,
762
+ toName,
763
+ 'active',
764
+ 'status',
765
+ ]
766
+ if (showDescription) headers.push('description')
767
+ for (const scope of SCOPES) {
768
+ const scoped = rows.filter((row) => row.scope === scope).sort((a, b) => a.order - b.order)
769
+ if (scoped.length === 0) continue
770
+ lines.push('')
771
+ lines.push(scope)
772
+ lines.push(
773
+ ...renderTable(
774
+ headers,
775
+ scoped.map((row) => (showDescription ? [...row.cells, row.description] : row.cells)),
776
+ ),
777
+ )
778
+ }
779
+
780
+ for (const { local, reason } of plan.blocked) {
781
+ lines.push(`[piwik] ${local.scope} dimension${local.slot} ${local.key}: ${reason}`)
782
+ }
783
+
784
+ return lines.join('\n')
785
+ }