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.
- package/.cursor/rules/core-coding-standards.mdc +24 -0
- package/README.md +25 -0
- package/configs/dependency-policy.vue-workspace.json +30 -30
- package/package.json +17 -14
- package/scaffold/vue/package.json +12 -12
- package/scripts/fg-devkit.mjs +154 -2
- package/scripts/piwik-common.mjs +653 -0
- package/scripts/piwik-dimensions.mjs +785 -0
- package/scripts/piwik-dimensions.test.mjs +400 -0
- package/scripts/piwik.mjs +502 -0
|
@@ -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
|
+
}
|