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,653 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Piwik PRO management API client and consumer config discovery.
|
|
3
|
+
*
|
|
4
|
+
* Auth is OAuth2 client credentials against https://{account}.piwik.pro/auth/token.
|
|
5
|
+
* Client id and secret come from the environment or a local env file, never from
|
|
6
|
+
* piwik/.piwik.json.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
|
|
12
|
+
import { getFlagValue, parseFlags } from './poeditor-common.mjs'
|
|
13
|
+
import {
|
|
14
|
+
PiwikDimensionError,
|
|
15
|
+
applyDesiredNames,
|
|
16
|
+
parseDimensionSource,
|
|
17
|
+
} from './piwik-dimensions.mjs'
|
|
18
|
+
|
|
19
|
+
export const JSON_API = 'application/vnd.api+json'
|
|
20
|
+
|
|
21
|
+
export const DIMENSION_CANDIDATES = [
|
|
22
|
+
'src/analytics/dimensions.ts',
|
|
23
|
+
'src/util/piwikDimensions.ts',
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
const ENV_NAMES = ['production', 'preprod', 'development', 'localhost']
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} filePath
|
|
30
|
+
* @param {string} key
|
|
31
|
+
*/
|
|
32
|
+
function readEnvValueFromFile(filePath, key) {
|
|
33
|
+
if (!fs.existsSync(filePath)) return ''
|
|
34
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
35
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
36
|
+
const trimmed = line.trim()
|
|
37
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
38
|
+
const noExport = trimmed.startsWith('export ') ? trimmed.slice(7).trim() : trimmed
|
|
39
|
+
const eqIdx = noExport.indexOf('=')
|
|
40
|
+
if (eqIdx <= 0) continue
|
|
41
|
+
if (noExport.slice(0, eqIdx).trim() !== key) continue
|
|
42
|
+
let value = noExport.slice(eqIdx + 1).trim()
|
|
43
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
44
|
+
value = value.slice(1, -1)
|
|
45
|
+
} else {
|
|
46
|
+
const hashIdx = value.indexOf(' #')
|
|
47
|
+
if (hashIdx >= 0) value = value.slice(0, hashIdx).trim()
|
|
48
|
+
}
|
|
49
|
+
return value
|
|
50
|
+
}
|
|
51
|
+
return ''
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} key
|
|
56
|
+
* @param {string} cwd
|
|
57
|
+
*/
|
|
58
|
+
function readSecret(key, cwd) {
|
|
59
|
+
if (process.env[key]) return process.env[key]
|
|
60
|
+
for (const file of ['env.local', '.env.local', '.env']) {
|
|
61
|
+
const value = readEnvValueFromFile(path.join(cwd, file), key)
|
|
62
|
+
if (value) return value
|
|
63
|
+
}
|
|
64
|
+
return ''
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {string} url
|
|
69
|
+
*/
|
|
70
|
+
export function accountFromPiwikUrl(url) {
|
|
71
|
+
let hostname = ''
|
|
72
|
+
try {
|
|
73
|
+
hostname = new URL(url).hostname
|
|
74
|
+
} catch {
|
|
75
|
+
throw new PiwikDimensionError(`Invalid Piwik URL: ${url}`)
|
|
76
|
+
}
|
|
77
|
+
const account = hostname.split('.')[0]
|
|
78
|
+
if (!account || !hostname.endsWith('.piwik.pro')) {
|
|
79
|
+
throw new PiwikDimensionError(`Expected a https://{account}.piwik.pro URL, got ${url}`)
|
|
80
|
+
}
|
|
81
|
+
return account
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} source
|
|
86
|
+
* @param {number} openIndex
|
|
87
|
+
*/
|
|
88
|
+
function readBrace(source, openIndex) {
|
|
89
|
+
let depth = 0
|
|
90
|
+
for (let i = openIndex; i < source.length; i += 1) {
|
|
91
|
+
if (source[i] === '{') depth += 1
|
|
92
|
+
else if (source[i] === '}') {
|
|
93
|
+
depth -= 1
|
|
94
|
+
if (depth === 0) return source.slice(openIndex + 1, i)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return ''
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Find piwikUrl/piwikId (or piwikProBaseUrl/piwikProId) on each environment object.
|
|
102
|
+
* @param {string} source
|
|
103
|
+
*/
|
|
104
|
+
export function discoverPiwikSites(source) {
|
|
105
|
+
/** @type {{ environment: string, account: string, websiteId: string, url: string }[]} */
|
|
106
|
+
const sites = []
|
|
107
|
+
const re = /const\s+([A-Za-z0-9_]+)\s*(?::[^=]+)?=\s*\{/g
|
|
108
|
+
let match = re.exec(source)
|
|
109
|
+
while (match) {
|
|
110
|
+
const constName = match[1]
|
|
111
|
+
const braceAt = source.indexOf('{', match.index)
|
|
112
|
+
const body = readBrace(source, braceAt)
|
|
113
|
+
const environmentMatch = body.match(/environment:\s*['"]([A-Za-z0-9_:-]+)['"]/)
|
|
114
|
+
const environment = environmentMatch?.[1] ?? (ENV_NAMES.includes(constName) ? constName : '')
|
|
115
|
+
const url = body.match(/piwik(?:ProBase)?Url:\s*['"]([^'"]+)['"]/)?.[1] ?? ''
|
|
116
|
+
const websiteId = body.match(/piwik(?:Pro)?Id:\s*['"]([^'"]+)['"]/)?.[1] ?? ''
|
|
117
|
+
if (environment && url && websiteId) {
|
|
118
|
+
sites.push({
|
|
119
|
+
environment,
|
|
120
|
+
account: accountFromPiwikUrl(url),
|
|
121
|
+
websiteId,
|
|
122
|
+
url,
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
match = re.exec(source)
|
|
126
|
+
}
|
|
127
|
+
return sites
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Prefer these names when several environments share one piwik id. */
|
|
131
|
+
const ENV_LABEL_PREFERENCE = ['preprod', 'staging', 'production', 'demo', 'development', 'localhost', 'local']
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* One entry per piwik id. localhost/development/preprod that share an id become a single "preprod" choice.
|
|
135
|
+
* @param {{ environment: string, account: string, websiteId: string, url?: string }[]} sites
|
|
136
|
+
*/
|
|
137
|
+
export function uniquePiwikEnvironments(sites) {
|
|
138
|
+
/** @type {Map<string, { environment: string, account: string, websiteId: string }[]>} */
|
|
139
|
+
const groups = new Map()
|
|
140
|
+
for (const site of sites) {
|
|
141
|
+
const group = groups.get(site.websiteId) ?? []
|
|
142
|
+
group.push(site)
|
|
143
|
+
groups.set(site.websiteId, group)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const unique = [...groups.values()].map((group) => {
|
|
147
|
+
const preferred =
|
|
148
|
+
ENV_LABEL_PREFERENCE.map((name) => group.find((site) => site.environment === name)).find(Boolean) ?? group[0]
|
|
149
|
+
return {
|
|
150
|
+
environment: preferred.environment,
|
|
151
|
+
account: preferred.account,
|
|
152
|
+
websiteId: preferred.websiteId,
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const rank = (name) => {
|
|
157
|
+
const index = ENV_LABEL_PREFERENCE.indexOf(name)
|
|
158
|
+
return index === -1 ? ENV_LABEL_PREFERENCE.length : index
|
|
159
|
+
}
|
|
160
|
+
unique.sort((a, b) => rank(a.environment) - rank(b.environment))
|
|
161
|
+
return unique
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Default site is the first non-production environment. Production is used only when it is the only site.
|
|
166
|
+
* @param {{ environment: string }[]} environments
|
|
167
|
+
*/
|
|
168
|
+
export function defaultPiwikEnvironment(environments) {
|
|
169
|
+
const preferred = environments.find((item) => item.environment !== 'production')
|
|
170
|
+
return preferred?.environment || environments[0]?.environment || ''
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Compare and migrate from the first non-production site to production when that site exists.
|
|
175
|
+
* @param {{ environment: string }[]} environments
|
|
176
|
+
*/
|
|
177
|
+
export function defaultEnvironmentPair(environments) {
|
|
178
|
+
const from = defaultPiwikEnvironment(environments)
|
|
179
|
+
const to =
|
|
180
|
+
environments.find((item) => item.environment === 'production' && item.environment !== from)?.environment ||
|
|
181
|
+
environments.find((item) => item.environment !== from)?.environment ||
|
|
182
|
+
''
|
|
183
|
+
return { from, to }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @param {string} cwd
|
|
188
|
+
* @param {string} fromName
|
|
189
|
+
* @param {string} toName
|
|
190
|
+
*/
|
|
191
|
+
export function resolveEnvironmentPair(cwd, fromName, toName) {
|
|
192
|
+
const environments = listPiwikEnvironments(cwd)
|
|
193
|
+
if (environments.length < 2) {
|
|
194
|
+
throw new PiwikDimensionError('Need two Piwik sites in src/config/env.ts to compare environments.')
|
|
195
|
+
}
|
|
196
|
+
const defaults = defaultEnvironmentPair(environments)
|
|
197
|
+
const from = environments.find((item) => item.environment === (fromName || defaults.from))
|
|
198
|
+
const to = environments.find((item) => item.environment === (toName || defaults.to))
|
|
199
|
+
const known = environments.map((item) => item.environment).join(', ')
|
|
200
|
+
if (!from) {
|
|
201
|
+
throw new PiwikDimensionError(`No Piwik site named "${fromName || defaults.from}". Found: ${known}.`)
|
|
202
|
+
}
|
|
203
|
+
if (!to) {
|
|
204
|
+
throw new PiwikDimensionError(`No Piwik site named "${toName || defaults.to}". Found: ${known}.`)
|
|
205
|
+
}
|
|
206
|
+
if (from.websiteId === to.websiteId) {
|
|
207
|
+
throw new PiwikDimensionError(`${from.environment} and ${to.environment} use the same Piwik site.`)
|
|
208
|
+
}
|
|
209
|
+
return { from, to }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* @param {string} cwd
|
|
214
|
+
*/
|
|
215
|
+
export function listPiwikEnvironments(cwd) {
|
|
216
|
+
const envFile = path.join(cwd, 'src/config/env.ts')
|
|
217
|
+
if (!fs.existsSync(envFile)) return []
|
|
218
|
+
return uniquePiwikEnvironments(discoverPiwikSites(fs.readFileSync(envFile, 'utf8')))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* @param {string} cwd
|
|
223
|
+
* @param {string} [relativePath]
|
|
224
|
+
*/
|
|
225
|
+
function readJson(cwd, relativePath) {
|
|
226
|
+
const filePath = path.resolve(cwd, relativePath ?? 'piwik/.piwik.json')
|
|
227
|
+
if (!fs.existsSync(filePath)) return { filePath, json: null }
|
|
228
|
+
try {
|
|
229
|
+
return { filePath, json: JSON.parse(fs.readFileSync(filePath, 'utf8')) }
|
|
230
|
+
} catch (error) {
|
|
231
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
232
|
+
throw new PiwikDimensionError(`Could not parse ${filePath}: ${message}`)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* @param {string[]} argv
|
|
238
|
+
* @param {string} [cwd]
|
|
239
|
+
*/
|
|
240
|
+
export function resolvePiwikConfig(argv, cwd = process.cwd()) {
|
|
241
|
+
const { positional, flags } = parseFlags(argv)
|
|
242
|
+
const command = positional[0] ?? ''
|
|
243
|
+
const configArg = getFlagValue(flags, 'config')
|
|
244
|
+
const { filePath: configPath, json } = readJson(cwd, typeof configArg === 'string' ? configArg : undefined)
|
|
245
|
+
const config = json && typeof json === 'object' ? json : {}
|
|
246
|
+
|
|
247
|
+
let envName = String(getFlagValue(flags, 'env', typeof config.environment === 'string' ? config.environment : ''))
|
|
248
|
+
const dimensionsFlag = getFlagValue(flags, 'dimensions')
|
|
249
|
+
const configuredFile = typeof config.dimensionsFile === 'string' ? config.dimensionsFile : ''
|
|
250
|
+
const dimensionFiles = resolveDimensionFiles(cwd, dimensionsFlag, configuredFile)
|
|
251
|
+
|
|
252
|
+
let account = String(getFlagValue(flags, 'account', config.account ?? process.env.PIWIK_ACCOUNT ?? ''))
|
|
253
|
+
let websiteId = String(getFlagValue(flags, 'website-id', config.websiteId ?? process.env.PIWIK_WEBSITE_ID ?? ''))
|
|
254
|
+
let siteSource = websiteId ? 'flag, config, or PIWIK_WEBSITE_ID' : ''
|
|
255
|
+
|
|
256
|
+
if (!websiteId || !account) {
|
|
257
|
+
const envFile = path.join(cwd, 'src/config/env.ts')
|
|
258
|
+
if (fs.existsSync(envFile)) {
|
|
259
|
+
const sites = discoverPiwikSites(fs.readFileSync(envFile, 'utf8'))
|
|
260
|
+
if (!envName) envName = defaultPiwikEnvironment(uniquePiwikEnvironments(sites))
|
|
261
|
+
const site = sites.find((item) => item.environment === envName)
|
|
262
|
+
if (!site && (!websiteId || !account)) {
|
|
263
|
+
const known = sites.map((item) => item.environment).join(', ') || 'none'
|
|
264
|
+
throw new PiwikDimensionError(
|
|
265
|
+
`No Piwik site for environment "${envName}" in src/config/env.ts. Found: ${known}. Pass --website-id and --account, or --env.`,
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
if (site) {
|
|
269
|
+
if (!account) account = site.account
|
|
270
|
+
if (!websiteId) {
|
|
271
|
+
websiteId = site.websiteId
|
|
272
|
+
siteSource = `src/config/env.ts (${site.environment})`
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const clientId = String(getFlagValue(flags, 'client-id', readSecret('PIWIK_CLIENT_ID', cwd)))
|
|
279
|
+
const clientSecret = String(getFlagValue(flags, 'client-secret', readSecret('PIWIK_CLIENT_SECRET', cwd)))
|
|
280
|
+
const names = config.names && typeof config.names === 'object' ? config.names : undefined
|
|
281
|
+
const scope = String(getFlagValue(flags, 'scope', ''))
|
|
282
|
+
if (scope && scope !== 'event' && scope !== 'session' && scope !== 'product') {
|
|
283
|
+
throw new PiwikDimensionError(`Unknown --scope ${scope}. Use event, session, or product.`)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
command,
|
|
288
|
+
cwd,
|
|
289
|
+
configPath: json ? configPath : '',
|
|
290
|
+
envName,
|
|
291
|
+
account,
|
|
292
|
+
websiteId,
|
|
293
|
+
siteSource,
|
|
294
|
+
clientId,
|
|
295
|
+
clientSecret,
|
|
296
|
+
dimensionFiles,
|
|
297
|
+
names,
|
|
298
|
+
scope,
|
|
299
|
+
yes: getFlagValue(flags, 'yes') === 'true' || flags.has('yes'),
|
|
300
|
+
dryRun: getFlagValue(flags, 'dry-run') === 'true' || flags.has('dry-run'),
|
|
301
|
+
localOnly: getFlagValue(flags, 'local') === 'true' || flags.has('local'),
|
|
302
|
+
fromEnv: String(getFlagValue(flags, 'from', '')),
|
|
303
|
+
toEnv: String(getFlagValue(flags, 'to', '')),
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* @param {string} cwd
|
|
309
|
+
* @param {string | undefined} flag
|
|
310
|
+
* @param {string} configuredFile
|
|
311
|
+
*/
|
|
312
|
+
function resolveDimensionFiles(cwd, flag, configuredFile) {
|
|
313
|
+
if (typeof flag === 'string' && flag.trim() !== '') {
|
|
314
|
+
return [path.resolve(cwd, flag)]
|
|
315
|
+
}
|
|
316
|
+
if (configuredFile.trim() !== '') {
|
|
317
|
+
return [path.resolve(cwd, configuredFile)]
|
|
318
|
+
}
|
|
319
|
+
return DIMENSION_CANDIDATES.map((relative) => path.join(cwd, relative)).filter((file) => fs.existsSync(file))
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* @param {ReturnType<typeof resolvePiwikConfig>} config
|
|
324
|
+
*/
|
|
325
|
+
export function loadLocalDimensions(config) {
|
|
326
|
+
if (config.dimensionFiles.length === 0) {
|
|
327
|
+
throw new PiwikDimensionError(
|
|
328
|
+
'No local dimension file found. Expected src/analytics/dimensions.ts or src/util/piwikDimensions.ts, or pass --dimensions.',
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
const missing = config.dimensionFiles.filter((file) => !fs.existsSync(file))
|
|
332
|
+
if (missing.length > 0) {
|
|
333
|
+
throw new PiwikDimensionError(`Dimension file not found: ${missing.join(', ')}`)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const locals = config.dimensionFiles.flatMap((file) =>
|
|
337
|
+
parseDimensionSource(fs.readFileSync(file, 'utf8'), path.relative(config.cwd, file)),
|
|
338
|
+
)
|
|
339
|
+
const named = applyDesiredNames(locals, config.names)
|
|
340
|
+
const filtered = config.scope ? named.filter((item) => item.scope === config.scope) : named
|
|
341
|
+
|
|
342
|
+
const seen = new Set()
|
|
343
|
+
for (const item of filtered) {
|
|
344
|
+
const key = `${item.scope}:${item.slot}`
|
|
345
|
+
if (seen.has(key)) {
|
|
346
|
+
throw new PiwikDimensionError(`Duplicate ${item.scope} slot ${item.slot} across local dimension files.`)
|
|
347
|
+
}
|
|
348
|
+
seen.add(key)
|
|
349
|
+
}
|
|
350
|
+
return filtered
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* @param {number} status
|
|
355
|
+
* @param {unknown} body
|
|
356
|
+
*/
|
|
357
|
+
function formatApiError(status, body) {
|
|
358
|
+
if (body && typeof body === 'object' && Array.isArray(/** @type {{ errors?: unknown }} */ (body).errors)) {
|
|
359
|
+
const details = /** @type {{ errors: { detail?: string, title?: string }[] }} */ (body).errors
|
|
360
|
+
.map((error) => error.detail || error.title || '')
|
|
361
|
+
.filter(Boolean)
|
|
362
|
+
if (details.length > 0) return `Piwik API ${status}: ${details.join('; ')}`
|
|
363
|
+
}
|
|
364
|
+
const text = typeof body === 'string' ? body : JSON.stringify(body)
|
|
365
|
+
return `Piwik API ${status}: ${text}`
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* @param {{ account: string, clientId: string, clientSecret: string, fetchImpl?: typeof fetch }} options
|
|
370
|
+
*/
|
|
371
|
+
export function createPiwikClient(options) {
|
|
372
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
373
|
+
const base = `https://${options.account}.piwik.pro`
|
|
374
|
+
/** @type {{ token: string, expiresAt: number }} */
|
|
375
|
+
const cache = { token: '', expiresAt: 0 }
|
|
376
|
+
|
|
377
|
+
async function accessToken() {
|
|
378
|
+
if (cache.token && Date.now() < cache.expiresAt) return cache.token
|
|
379
|
+
const response = await fetchImpl(`${base}/auth/token`, {
|
|
380
|
+
method: 'POST',
|
|
381
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
382
|
+
body: JSON.stringify({
|
|
383
|
+
grant_type: 'client_credentials',
|
|
384
|
+
client_id: options.clientId,
|
|
385
|
+
client_secret: options.clientSecret,
|
|
386
|
+
}),
|
|
387
|
+
})
|
|
388
|
+
const body = await response.json().catch(() => ({}))
|
|
389
|
+
if (!response.ok) {
|
|
390
|
+
throw new PiwikDimensionError(formatApiError(response.status, body))
|
|
391
|
+
}
|
|
392
|
+
const token = body?.access_token
|
|
393
|
+
if (typeof token !== 'string' || token === '') {
|
|
394
|
+
throw new PiwikDimensionError('Piwik token response did not include access_token.')
|
|
395
|
+
}
|
|
396
|
+
const expiresIn = Number(body.expires_in ?? 1800)
|
|
397
|
+
cache.token = token
|
|
398
|
+
cache.expiresAt = Date.now() + Math.max(30, expiresIn - 30) * 1000
|
|
399
|
+
return token
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* @param {string} method
|
|
404
|
+
* @param {string} pathname
|
|
405
|
+
* @param {{ query?: Record<string, string | number>, body?: unknown }} [extra]
|
|
406
|
+
*/
|
|
407
|
+
async function request(method, pathname, extra = {}) {
|
|
408
|
+
const token = await accessToken()
|
|
409
|
+
const url = new URL(pathname, base)
|
|
410
|
+
for (const [key, value] of Object.entries(extra.query ?? {})) {
|
|
411
|
+
url.searchParams.set(key, String(value))
|
|
412
|
+
}
|
|
413
|
+
const response = await fetchImpl(url, {
|
|
414
|
+
method,
|
|
415
|
+
headers: {
|
|
416
|
+
Authorization: `Bearer ${token}`,
|
|
417
|
+
Accept: JSON_API,
|
|
418
|
+
...(extra.body ? { 'Content-Type': JSON_API } : {}),
|
|
419
|
+
},
|
|
420
|
+
body: extra.body ? JSON.stringify(extra.body) : undefined,
|
|
421
|
+
})
|
|
422
|
+
const text = await response.text()
|
|
423
|
+
/** @type {unknown} */
|
|
424
|
+
let body = {}
|
|
425
|
+
if (text) {
|
|
426
|
+
try {
|
|
427
|
+
body = JSON.parse(text)
|
|
428
|
+
} catch {
|
|
429
|
+
body = text
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (!response.ok) {
|
|
433
|
+
throw new PiwikDimensionError(formatApiError(response.status, body))
|
|
434
|
+
}
|
|
435
|
+
return body
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* @param {string} pathname
|
|
440
|
+
* @param {string} websiteId
|
|
441
|
+
*/
|
|
442
|
+
async function listAll(pathname, websiteId) {
|
|
443
|
+
/** @type {unknown[]} */
|
|
444
|
+
const rows = []
|
|
445
|
+
let offset = 0
|
|
446
|
+
const limit = 1000
|
|
447
|
+
while (true) {
|
|
448
|
+
const page = await request('GET', pathname, { query: { website_id: websiteId, limit, offset } })
|
|
449
|
+
const data = Array.isArray(page?.data) ? page.data : []
|
|
450
|
+
rows.push(...data)
|
|
451
|
+
const total = Number(page?.meta?.total ?? rows.length)
|
|
452
|
+
offset += data.length
|
|
453
|
+
if (data.length === 0 || rows.length >= total) break
|
|
454
|
+
}
|
|
455
|
+
return rows
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
/**
|
|
460
|
+
* @param {string} websiteId
|
|
461
|
+
*/
|
|
462
|
+
async listDimensions(websiteId) {
|
|
463
|
+
const [custom, product, slots] = await Promise.all([
|
|
464
|
+
listAll('/api/analytics/v1/manage/custom-dimensions/', websiteId),
|
|
465
|
+
listAll('/api/analytics/v1/manage/product-custom-dimensions/', websiteId).catch((error) => {
|
|
466
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
467
|
+
if (/Piwik API (400|403|404)/.test(message)) return []
|
|
468
|
+
throw error
|
|
469
|
+
}),
|
|
470
|
+
request('GET', '/api/analytics/v1/manage/custom-dimensions/slots/', { query: { website_id: websiteId } }),
|
|
471
|
+
])
|
|
472
|
+
return {
|
|
473
|
+
dimensions: [
|
|
474
|
+
...custom.map((row) => normalizeDimension(row, 'event')),
|
|
475
|
+
...product.map((row) => normalizeDimension(row, 'product')),
|
|
476
|
+
],
|
|
477
|
+
slots: slots?.data?.attributes ?? {},
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
/**
|
|
481
|
+
* @param {{ websiteId: string, name: string, scope: 'event' | 'session', description?: string, active?: boolean }} input
|
|
482
|
+
*/
|
|
483
|
+
async createDimension(input) {
|
|
484
|
+
const body = await request('POST', '/api/analytics/v1/manage/custom-dimensions/', {
|
|
485
|
+
body: {
|
|
486
|
+
data: {
|
|
487
|
+
type: 'CustomDimension',
|
|
488
|
+
attributes: {
|
|
489
|
+
website_id: input.websiteId,
|
|
490
|
+
name: input.name,
|
|
491
|
+
active: input.active !== false,
|
|
492
|
+
case_sensitive: false,
|
|
493
|
+
scope: input.scope,
|
|
494
|
+
...(input.description ? { description: input.description } : {}),
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
})
|
|
499
|
+
return normalizeDimension(body?.data, input.scope)
|
|
500
|
+
},
|
|
501
|
+
/**
|
|
502
|
+
* @param {import('./piwik-dimensions.mjs').RemoteDimension} remote
|
|
503
|
+
* @param {string} name
|
|
504
|
+
*/
|
|
505
|
+
async updateDimension(remote, name) {
|
|
506
|
+
const body = await request('PUT', `/api/analytics/v1/manage/custom-dimensions/${remote.id}/`, {
|
|
507
|
+
body: {
|
|
508
|
+
data: {
|
|
509
|
+
type: 'CustomDimension',
|
|
510
|
+
id: remote.id,
|
|
511
|
+
attributes: {
|
|
512
|
+
website_id: remote.websiteId,
|
|
513
|
+
name,
|
|
514
|
+
active: remote.active,
|
|
515
|
+
case_sensitive: remote.caseSensitive,
|
|
516
|
+
scope: remote.scope,
|
|
517
|
+
...(remote.description ? { description: remote.description } : {}),
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
})
|
|
522
|
+
return normalizeDimension(body?.data, remote.scope)
|
|
523
|
+
},
|
|
524
|
+
/**
|
|
525
|
+
* @param {import('./piwik-dimensions.mjs').RemoteDimension} remote
|
|
526
|
+
* @param {{ name: string, description: string, active: boolean }} desired
|
|
527
|
+
*/
|
|
528
|
+
async updateDimensionState(remote, desired) {
|
|
529
|
+
const body = await request('PUT', `/api/analytics/v1/manage/custom-dimensions/${remote.id}/`, {
|
|
530
|
+
body: {
|
|
531
|
+
data: {
|
|
532
|
+
type: 'CustomDimension',
|
|
533
|
+
id: remote.id,
|
|
534
|
+
attributes: {
|
|
535
|
+
website_id: remote.websiteId,
|
|
536
|
+
name: desired.name,
|
|
537
|
+
active: desired.active,
|
|
538
|
+
case_sensitive: remote.caseSensitive,
|
|
539
|
+
scope: remote.scope,
|
|
540
|
+
description: desired.description,
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
})
|
|
545
|
+
return normalizeDimension(body?.data, remote.scope)
|
|
546
|
+
},
|
|
547
|
+
/**
|
|
548
|
+
* @param {{ websiteId: string, name: string, slot: number, description?: string }} input
|
|
549
|
+
*/
|
|
550
|
+
async createProductDimension(input) {
|
|
551
|
+
const body = await request('POST', '/api/analytics/v1/manage/product-custom-dimensions/', {
|
|
552
|
+
body: {
|
|
553
|
+
data: {
|
|
554
|
+
type: 'ProductCustomDimension',
|
|
555
|
+
attributes: {
|
|
556
|
+
website_id: input.websiteId,
|
|
557
|
+
name: input.name,
|
|
558
|
+
slot: input.slot,
|
|
559
|
+
...(input.description ? { description: input.description } : {}),
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
},
|
|
563
|
+
})
|
|
564
|
+
return normalizeDimension(body?.data, 'product')
|
|
565
|
+
},
|
|
566
|
+
/**
|
|
567
|
+
* @param {import('./piwik-dimensions.mjs').RemoteDimension} remote
|
|
568
|
+
* @param {string} name
|
|
569
|
+
*/
|
|
570
|
+
async updateProductDimension(remote, name) {
|
|
571
|
+
const body = await request('PUT', `/api/analytics/v1/manage/product-custom-dimensions/${remote.id}/`, {
|
|
572
|
+
body: {
|
|
573
|
+
data: {
|
|
574
|
+
type: 'ProductCustomDimension',
|
|
575
|
+
id: remote.id,
|
|
576
|
+
attributes: {
|
|
577
|
+
website_id: remote.websiteId,
|
|
578
|
+
name,
|
|
579
|
+
...(remote.description ? { description: remote.description } : {}),
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
})
|
|
584
|
+
return normalizeDimension(body?.data, 'product')
|
|
585
|
+
},
|
|
586
|
+
/**
|
|
587
|
+
* @param {import('./piwik-dimensions.mjs').RemoteDimension} remote
|
|
588
|
+
* @param {{ name: string, description: string }} desired
|
|
589
|
+
*/
|
|
590
|
+
async updateProductDimensionState(remote, desired) {
|
|
591
|
+
const body = await request('PUT', `/api/analytics/v1/manage/product-custom-dimensions/${remote.id}/`, {
|
|
592
|
+
body: {
|
|
593
|
+
data: {
|
|
594
|
+
type: 'ProductCustomDimension',
|
|
595
|
+
id: remote.id,
|
|
596
|
+
attributes: {
|
|
597
|
+
website_id: remote.websiteId,
|
|
598
|
+
name: desired.name,
|
|
599
|
+
description: desired.description,
|
|
600
|
+
},
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
})
|
|
604
|
+
return normalizeDimension(body?.data, 'product')
|
|
605
|
+
},
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* @param {unknown} row
|
|
611
|
+
* @param {'event' | 'session' | 'product'} scopeFallback
|
|
612
|
+
* @returns {import('./piwik-dimensions.mjs').RemoteDimension & { websiteId: string }}
|
|
613
|
+
*/
|
|
614
|
+
export function normalizeDimension(row, scopeFallback) {
|
|
615
|
+
const record = row && typeof row === 'object' ? /** @type {Record<string, unknown>} */ (row) : {}
|
|
616
|
+
const attributes =
|
|
617
|
+
record.attributes && typeof record.attributes === 'object'
|
|
618
|
+
? /** @type {Record<string, unknown>} */ (record.attributes)
|
|
619
|
+
: {}
|
|
620
|
+
const scope = attributes.scope === 'session' || attributes.scope === 'event' || attributes.scope === 'product'
|
|
621
|
+
? attributes.scope
|
|
622
|
+
: scopeFallback
|
|
623
|
+
const slot = Number(attributes.slot)
|
|
624
|
+
const trackingId = Number(attributes.tracking_id ?? attributes.slot)
|
|
625
|
+
return {
|
|
626
|
+
id: String(record.id ?? ''),
|
|
627
|
+
name: String(attributes.name ?? ''),
|
|
628
|
+
slot,
|
|
629
|
+
trackingId,
|
|
630
|
+
scope,
|
|
631
|
+
active: attributes.active !== false,
|
|
632
|
+
caseSensitive: Boolean(attributes.case_sensitive),
|
|
633
|
+
description: typeof attributes.description === 'string' ? attributes.description : '',
|
|
634
|
+
websiteId: String(attributes.website_id ?? ''),
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* @param {ReturnType<typeof resolvePiwikConfig>} config
|
|
640
|
+
*/
|
|
641
|
+
export function assertApiConfig(config) {
|
|
642
|
+
if (!config.account) {
|
|
643
|
+
throw new PiwikDimensionError('Missing Piwik account. Pass --account or set it in src/config/env.ts / piwik/.piwik.json.')
|
|
644
|
+
}
|
|
645
|
+
if (!config.websiteId) {
|
|
646
|
+
throw new PiwikDimensionError('Missing website id. Pass --website-id or set piwikId in src/config/env.ts.')
|
|
647
|
+
}
|
|
648
|
+
if (!config.clientId || !config.clientSecret) {
|
|
649
|
+
throw new PiwikDimensionError(
|
|
650
|
+
'Missing Piwik API credentials. Set PIWIK_CLIENT_ID and PIWIK_CLIENT_SECRET in the environment, .env.local, or env.local.',
|
|
651
|
+
)
|
|
652
|
+
}
|
|
653
|
+
}
|