@luquimbo/bi-superpowers 3.2.0 → 4.1.0

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.
Files changed (91) hide show
  1. package/.claude-plugin/marketplace.json +5 -3
  2. package/.claude-plugin/plugin.json +28 -2
  3. package/.claude-plugin/skill-manifest.json +22 -6
  4. package/.plugin/plugin.json +1 -1
  5. package/AGENTS.md +52 -36
  6. package/CHANGELOG.md +295 -0
  7. package/README.md +75 -26
  8. package/bin/build-plugin.js +11 -4
  9. package/bin/cli.js +113 -16
  10. package/bin/commands/build-desktop.js +35 -16
  11. package/bin/commands/diff.js +31 -13
  12. package/bin/commands/install.js +7 -3
  13. package/bin/commands/lint.js +40 -26
  14. package/bin/commands/mcp-setup.js +3 -10
  15. package/bin/commands/update-check.js +389 -0
  16. package/bin/lib/generators/claude-plugin.js +144 -6
  17. package/bin/lib/generators/shared.js +29 -33
  18. package/bin/lib/mcp-config.js +168 -12
  19. package/bin/lib/skills.js +115 -27
  20. package/bin/postinstall.js +4 -2
  21. package/bin/utils/mcp-detect.js +2 -2
  22. package/commands/bi-start.md +218 -0
  23. package/commands/pbi-connect.md +43 -65
  24. package/commands/project-kickoff.md +393 -673
  25. package/commands/report-design.md +403 -0
  26. package/desktop-extension/manifest.json +3 -3
  27. package/package.json +7 -5
  28. package/skills/bi-start/SKILL.md +220 -0
  29. package/skills/bi-start/scripts/update-check.js +389 -0
  30. package/skills/pbi-connect/SKILL.md +45 -67
  31. package/skills/pbi-connect/scripts/update-check.js +389 -0
  32. package/skills/project-kickoff/SKILL.md +395 -675
  33. package/skills/project-kickoff/scripts/update-check.js +389 -0
  34. package/skills/report-design/SKILL.md +405 -0
  35. package/skills/report-design/references/cli-commands.md +184 -0
  36. package/skills/report-design/references/cli-setup.md +101 -0
  37. package/skills/report-design/references/close-write-open-pattern.md +80 -0
  38. package/skills/report-design/references/layouts/finance.md +65 -0
  39. package/skills/report-design/references/layouts/generic.md +46 -0
  40. package/skills/report-design/references/layouts/hr.md +48 -0
  41. package/skills/report-design/references/layouts/marketing.md +45 -0
  42. package/skills/report-design/references/layouts/operations.md +44 -0
  43. package/skills/report-design/references/layouts/sales.md +50 -0
  44. package/skills/report-design/references/native-visuals.md +341 -0
  45. package/skills/report-design/references/pbi-desktop-installation.md +87 -0
  46. package/skills/report-design/references/pbir-preview-activation.md +40 -0
  47. package/skills/report-design/references/slicer.md +89 -0
  48. package/skills/report-design/references/textbox.md +101 -0
  49. package/skills/report-design/references/themes/BISuperpowers.json +915 -0
  50. package/skills/report-design/references/troubleshooting.md +135 -0
  51. package/skills/report-design/references/visual-types.md +78 -0
  52. package/skills/report-design/scripts/apply-theme.js +243 -0
  53. package/skills/report-design/scripts/create-visual.js +878 -0
  54. package/skills/report-design/scripts/ensure-pbi-cli.sh +41 -0
  55. package/skills/report-design/scripts/update-check.js +389 -0
  56. package/skills/report-design/scripts/validate-pbir.js +322 -0
  57. package/src/content/base.md +12 -68
  58. package/src/content/mcp-requirements.json +0 -25
  59. package/src/content/routing.md +19 -74
  60. package/src/content/skills/bi-start.md +191 -0
  61. package/src/content/skills/pbi-connect.md +22 -65
  62. package/src/content/skills/project-kickoff.md +372 -673
  63. package/src/content/skills/report-design/SKILL.md +376 -0
  64. package/src/content/skills/report-design/references/cli-commands.md +184 -0
  65. package/src/content/skills/report-design/references/cli-setup.md +101 -0
  66. package/src/content/skills/report-design/references/close-write-open-pattern.md +80 -0
  67. package/src/content/skills/report-design/references/layouts/finance.md +65 -0
  68. package/src/content/skills/report-design/references/layouts/generic.md +46 -0
  69. package/src/content/skills/report-design/references/layouts/hr.md +48 -0
  70. package/src/content/skills/report-design/references/layouts/marketing.md +45 -0
  71. package/src/content/skills/report-design/references/layouts/operations.md +44 -0
  72. package/src/content/skills/report-design/references/layouts/sales.md +50 -0
  73. package/src/content/skills/report-design/references/native-visuals.md +341 -0
  74. package/src/content/skills/report-design/references/pbi-desktop-installation.md +87 -0
  75. package/src/content/skills/report-design/references/pbir-preview-activation.md +40 -0
  76. package/src/content/skills/report-design/references/slicer.md +89 -0
  77. package/src/content/skills/report-design/references/textbox.md +101 -0
  78. package/src/content/skills/report-design/references/themes/BISuperpowers.json +915 -0
  79. package/src/content/skills/report-design/references/troubleshooting.md +135 -0
  80. package/src/content/skills/report-design/references/visual-types.md +78 -0
  81. package/src/content/skills/report-design/scripts/apply-theme.js +243 -0
  82. package/src/content/skills/report-design/scripts/create-visual.js +878 -0
  83. package/src/content/skills/report-design/scripts/ensure-pbi-cli.sh +41 -0
  84. package/src/content/skills/report-design/scripts/validate-pbir.js +322 -0
  85. package/bin/commands/install.test.js +0 -289
  86. package/bin/commands/lint.test.js +0 -103
  87. package/bin/lib/generators/claude-plugin.test.js +0 -111
  88. package/bin/lib/mcp-config.test.js +0 -310
  89. package/bin/lib/microsoft-mcp.test.js +0 -115
  90. package/bin/utils/mcp-detect.test.js +0 -81
  91. package/bin/utils/tui.test.js +0 -127
@@ -0,0 +1,878 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * create-visual.js — Node-only PBIR visual creator.
4
+ *
5
+ * Replaces `pbi visual add` + `pbi visual bind` for the 28 native visualTypes
6
+ * that PBI Desktop (March 2026, schema 2.7.0) accepts. No dependency on the
7
+ * pbi-cli-tool upstream. The exact count is locked in by create-visual.test.js
8
+ * (the EXPECTED_TYPES array + a length assertion) — update both if the
9
+ * allowlist changes.
10
+ *
11
+ * Why: the CLI has 3 known regressions (see SKILL.md audit):
12
+ * 1. advertises only 5 types in `--type`; silently accepts ~20 more.
13
+ * 2. accepts type aliases that rewrite to invalid PBIR (e.g. `pie` → `donutChart`).
14
+ * 3. accepts `stackedBarChart` / `stackedColumnChart` which are NOT native
15
+ * visualTypes — Desktop renders them as "objeto visual personalizado".
16
+ *
17
+ * This script is the single source of truth for what a native PBIR visual
18
+ * looks like. Shape canonical derived from:
19
+ * - CLI-generated shapes (for the 23 types the CLI can create)
20
+ * - examples/smoke-test/.../theme/visuals/*.json (hundredPercentStackedBarChart,
21
+ * hundredPercentStackedColumnChart, pieChart, scatterChart-with-Legend-and-Size,
22
+ * waterfallChart-with-Breakdown)
23
+ *
24
+ * Usage:
25
+ * node create-visual.js \
26
+ * --report-path ./pbip-files/Foo.Report \
27
+ * --page experiment \
28
+ * --type clusteredBarChart \
29
+ * --name my_bar \
30
+ * --x 24 --y 24 --width 600 --height 280 \
31
+ * --bind category="Categorias Finanzas[Categoria]" \
32
+ * --bind y="_Measures[Gastos]" \
33
+ * --bind legend="Cuentas[Banco]" \
34
+ * --title "Gasto por categoría"
35
+ *
36
+ * Special visual types with shortcuts:
37
+ *
38
+ * --type textbox use --paragraph "text" (repeatable), no --bind
39
+ * or --title/--description for title+body shortcut.
40
+ *
41
+ * --type slicer use --slicer-mode {basic|horizontal|dropdown|between|relative}
42
+ * + --bind values=Table[Column]
43
+ * (horizontal implies Basic mode + orientation 2)
44
+ *
45
+ * Options:
46
+ * -p, --report-path Path to the .Report folder. [required]
47
+ * --page Page name (folder under definition/pages). [required]
48
+ * -t, --type Native PBIR visualType. See `--list-types`. [required]
49
+ * -n, --name Visual name (also the folder name). Auto-generated if omitted.
50
+ * --x, --y Canvas position (pixels). Default 0.
51
+ * --width, --height Size. Defaults 400x300 for charts, 280x120 for cards.
52
+ * --tab-order Tab order. Auto-incremented from existing visuals if omitted.
53
+ * -z z-index. Auto-incremented from existing visuals if omitted.
54
+ * --bind role=FIELD Bind a field to a data role. Repeatable.
55
+ * FIELD = "Table[Column]" or "Table[Measure]".
56
+ * Use 'Table Name'[Field] if the table has spaces.
57
+ * Role is case-insensitive (category = Category).
58
+ * --title TEXT Set container title text (overrides the default "" title).
59
+ * --no-title Hide the container title.
60
+ * --paragraph TEXT Textbox paragraph. Repeatable. First paragraph is styled
61
+ * bigger by default (title-ish).
62
+ * --description TEXT Textbox body shortcut (used with --title).
63
+ * --slicer-mode MODE basic | horizontal | dropdown | between | relative
64
+ * --list-types Print all supported visualTypes with their roles and exit.
65
+ * -h, --help Show this help.
66
+ *
67
+ * Idempotent: if a visual with the same --name already exists on --page,
68
+ * this script FAILS (no silent overwrite). Delete the folder manually or
69
+ * use a different --name. This matches the CLI's behavior and avoids
70
+ * accidental data loss on bindings.
71
+ *
72
+ * Exit codes: 0 success, 1 user error (bad args, unknown type), 2 I/O error.
73
+ */
74
+
75
+ 'use strict';
76
+
77
+ const fs = require('fs');
78
+ const path = require('path');
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // NATIVE_VISUAL_TYPES — the single source of truth for what PBIR accepts.
82
+ //
83
+ // Each entry describes one visualType:
84
+ // - description: one-line human explanation.
85
+ // - roles: { <RoleName>: { kind: 'measure'|'column', required, multi } }
86
+ // Role name is the PBIR Pascal-case name (Category, Y, Legend,
87
+ // ColumnY, LineY, Breakdown, Details, X, Size, Indicator, etc.)
88
+ // - defaults: (optional) extra fields to set on visual (e.g.
89
+ // sortDefinition for cardVisual).
90
+ // - notes: (optional) extra guidance printed with --list-types.
91
+ //
92
+ // NOT in this table = NOT a supported native PBIR visualType. Do NOT add
93
+ // `stackedBarChart`, `stackedColumnChart`, or `stackedAreaChart` — they are
94
+ // patterns (use the non-stacked type with a Legend field), not types.
95
+ // ---------------------------------------------------------------------------
96
+
97
+ const NATIVE_VISUAL_TYPES = {
98
+ // ---- Single value ----
99
+ card: {
100
+ description: 'Tarjeta KPI clásica (un valor grande + label).',
101
+ roles: {
102
+ Values: { kind: 'measure', required: true, multi: false },
103
+ },
104
+ },
105
+ cardVisual: {
106
+ description: 'New card (March 2026). Multiple measures + targets inline.',
107
+ roles: {
108
+ Data: { kind: 'measure', required: true, multi: true },
109
+ },
110
+ defaults: {
111
+ query: { sortDefinition: { sort: [], isDefaultSort: true } },
112
+ visualContainerObjects: {},
113
+ },
114
+ },
115
+ kpi: {
116
+ description: 'KPI con Indicator + Goal + TrendLine.',
117
+ roles: {
118
+ Indicator: { kind: 'measure', required: true, multi: false },
119
+ Goal: { kind: 'measure', required: false, multi: false },
120
+ TrendLine: { kind: 'column', required: false, multi: false },
121
+ },
122
+ },
123
+ gauge: {
124
+ description: 'Gauge arco: Y actual vs MaxValue (+ target opcional).',
125
+ roles: {
126
+ Y: { kind: 'measure', required: true, multi: false },
127
+ MaxValue: { kind: 'measure', required: false, multi: false },
128
+ TargetValue: { kind: 'measure', required: false, multi: false },
129
+ MinValue: { kind: 'measure', required: false, multi: false },
130
+ },
131
+ },
132
+ multiRowCard: {
133
+ description: 'Card multi-valor (múltiples measures apiladas).',
134
+ roles: {
135
+ Values: { kind: 'measure', required: true, multi: true },
136
+ },
137
+ },
138
+
139
+ // ---- Bar / Column ----
140
+ barChart: {
141
+ description: 'Bar chart. Agrega Legend para stacked-bar (no uses stackedBarChart).',
142
+ roles: {
143
+ Category: { kind: 'column', required: true, multi: false },
144
+ Y: { kind: 'measure', required: true, multi: true },
145
+ Legend: { kind: 'column', required: false, multi: false },
146
+ },
147
+ notes: 'Para stacked: agregá --bind legend=Tabla[Col]. NO existe stackedBarChart nativo.',
148
+ },
149
+ clusteredBarChart: {
150
+ description: 'Bar chart agrupado (side-by-side por Series).',
151
+ roles: {
152
+ Category: { kind: 'column', required: true, multi: false },
153
+ Y: { kind: 'measure', required: true, multi: true },
154
+ Legend: { kind: 'column', required: false, multi: false },
155
+ },
156
+ },
157
+ columnChart: {
158
+ description: 'Column chart. Agrega Legend para stacked-column (no uses stackedColumnChart).',
159
+ roles: {
160
+ Category: { kind: 'column', required: true, multi: false },
161
+ Y: { kind: 'measure', required: true, multi: true },
162
+ Legend: { kind: 'column', required: false, multi: false },
163
+ },
164
+ notes: 'Para stacked: agregá --bind legend=Tabla[Col]. NO existe stackedColumnChart nativo.',
165
+ },
166
+ clusteredColumnChart: {
167
+ description: 'Column chart agrupado (side-by-side por Series).',
168
+ roles: {
169
+ Category: { kind: 'column', required: true, multi: false },
170
+ Y: { kind: 'measure', required: true, multi: true },
171
+ Legend: { kind: 'column', required: false, multi: false },
172
+ },
173
+ },
174
+ hundredPercentStackedBarChart: {
175
+ description: 'Bar 100% apilado (Legend obligatorio).',
176
+ roles: {
177
+ Category: { kind: 'column', required: true, multi: false },
178
+ Y: { kind: 'measure', required: true, multi: false },
179
+ Legend: { kind: 'column', required: true, multi: false },
180
+ },
181
+ notes: 'Legend es mandatorio — sin él no tiene sentido el 100%.',
182
+ },
183
+ hundredPercentStackedColumnChart: {
184
+ description: 'Column 100% apilado (Legend obligatorio).',
185
+ roles: {
186
+ Category: { kind: 'column', required: true, multi: false },
187
+ Y: { kind: 'measure', required: true, multi: false },
188
+ Legend: { kind: 'column', required: true, multi: false },
189
+ },
190
+ notes: 'Legend es mandatorio — sin él no tiene sentido el 100%.',
191
+ },
192
+
193
+ // ---- Line / Area ----
194
+ lineChart: {
195
+ description: 'Line chart para time series.',
196
+ roles: {
197
+ Category: { kind: 'column', required: true, multi: false },
198
+ Y: { kind: 'measure', required: true, multi: true },
199
+ Legend: { kind: 'column', required: false, multi: false },
200
+ },
201
+ },
202
+ areaChart: {
203
+ description: 'Area chart. Agrega Legend para stacked-area (no uses stackedAreaChart).',
204
+ roles: {
205
+ Category: { kind: 'column', required: true, multi: false },
206
+ Y: { kind: 'measure', required: true, multi: true },
207
+ Legend: { kind: 'column', required: false, multi: false },
208
+ },
209
+ notes: 'Para stacked: agregá --bind legend=Tabla[Col]. NO existe stackedAreaChart nativo.',
210
+ },
211
+ lineClusteredColumnComboChart: {
212
+ description: 'Combo: columnas agrupadas + líneas sobre el mismo eje X.',
213
+ roles: {
214
+ Category: { kind: 'column', required: true, multi: false },
215
+ ColumnY: { kind: 'measure', required: true, multi: true },
216
+ LineY: { kind: 'measure', required: true, multi: true },
217
+ Legend: { kind: 'column', required: false, multi: false },
218
+ },
219
+ notes:
220
+ 'ÚNICA variante combo que Desktop March 2026 renderiza (la variante stacked está rota — ver KNOWN_NON_NATIVE_TYPES). Usa --bind columny= y --bind liney=.',
221
+ },
222
+ ribbonChart: {
223
+ description: 'Ribbon chart (muestra ranking changing over time).',
224
+ roles: {
225
+ Category: { kind: 'column', required: true, multi: false },
226
+ Y: { kind: 'measure', required: true, multi: false },
227
+ Legend: { kind: 'column', required: false, multi: false },
228
+ },
229
+ notes:
230
+ 'Puede requerir el preview flag "Ribbon visual" en Desktop Options. Verificá en `references/visual-types.md` antes de publicar un reporte que depende de este tipo.',
231
+ },
232
+
233
+ // ---- Part-to-whole ----
234
+ pieChart: {
235
+ description: 'Torta (pie).',
236
+ roles: {
237
+ Category: { kind: 'column', required: true, multi: false },
238
+ Y: { kind: 'measure', required: true, multi: false },
239
+ },
240
+ },
241
+ donutChart: {
242
+ description: 'Dona (donut).',
243
+ roles: {
244
+ Category: { kind: 'column', required: true, multi: false },
245
+ Y: { kind: 'measure', required: true, multi: false },
246
+ },
247
+ },
248
+ treemap: {
249
+ description: 'Treemap (rectángulos proporcionales).',
250
+ roles: {
251
+ Category: { kind: 'column', required: true, multi: false },
252
+ Values: { kind: 'measure', required: true, multi: false },
253
+ },
254
+ },
255
+
256
+ // ---- Análisis ----
257
+ funnelChart: {
258
+ description: 'Embudo (stages con drop-off).',
259
+ roles: {
260
+ Category: { kind: 'column', required: true, multi: false },
261
+ Y: { kind: 'measure', required: true, multi: false },
262
+ },
263
+ },
264
+ scatterChart: {
265
+ description: 'Scatter / bubble (X vs Y, opcional Size → bubble).',
266
+ roles: {
267
+ Details: { kind: 'column', required: true, multi: false },
268
+ X: { kind: 'measure', required: true, multi: false },
269
+ Y: { kind: 'measure', required: true, multi: false },
270
+ Legend: { kind: 'column', required: false, multi: false },
271
+ Size: { kind: 'measure', required: false, multi: false },
272
+ },
273
+ notes: 'Si agregás --bind size=..., se convierte en bubble chart.',
274
+ },
275
+ waterfallChart: {
276
+ description: 'Cascada (accumulator con deltas positivos/negativos).',
277
+ roles: {
278
+ Category: { kind: 'column', required: true, multi: false },
279
+ Y: { kind: 'measure', required: true, multi: false },
280
+ Breakdown: { kind: 'column', required: false, multi: false },
281
+ },
282
+ },
283
+
284
+ // ---- Tabular ----
285
+ tableEx: {
286
+ description: 'Tabla (row-detail, muchas columnas).',
287
+ roles: {
288
+ Values: { kind: 'any', required: true, multi: true },
289
+ },
290
+ },
291
+ pivotTable: {
292
+ description: 'Matriz (rows × columns × values).',
293
+ roles: {
294
+ Rows: { kind: 'column', required: true, multi: true },
295
+ Columns: { kind: 'column', required: false, multi: true },
296
+ Values: { kind: 'measure', required: true, multi: true },
297
+ },
298
+ },
299
+
300
+ // ---- Filtros ----
301
+ slicer: {
302
+ description: 'Slicer clásico (5 modos: basic, horizontal, dropdown, between, relative).',
303
+ roles: {
304
+ Values: { kind: 'column', required: true, multi: false },
305
+ },
306
+ notes: 'Usá --slicer-mode para elegir modo. Default: basic (vertical list).',
307
+ },
308
+ advancedSlicerVisual: {
309
+ description: 'Advanced slicer (March 2026 new slicer).',
310
+ roles: {
311
+ Values: { kind: 'column', required: true, multi: false },
312
+ },
313
+ },
314
+ listSlicer: {
315
+ description: 'List slicer (legacy typeId, list-only).',
316
+ roles: {
317
+ Values: { kind: 'column', required: true, multi: false },
318
+ },
319
+ notes: 'Legacy typeId. Preferí `slicer` con `--slicer-mode basic` para reportes nuevos.',
320
+ },
321
+ textSlicer: {
322
+ description: 'Text input slicer (filtra por substring).',
323
+ roles: {
324
+ Values: { kind: 'column', required: true, multi: false },
325
+ },
326
+ },
327
+
328
+ // ---- Texto ----
329
+ textbox: {
330
+ description: 'Textbox libre (no tiene query). Usá --paragraph o --title+--description.',
331
+ roles: {}, // no data roles
332
+ notes: 'No admite --bind. Usá --paragraph (repeatable) o --title/--description.',
333
+ },
334
+ };
335
+
336
+ // Known non-native types we want to *block* with a clear error message.
337
+ const KNOWN_NON_NATIVE_TYPES = {
338
+ stackedBarChart:
339
+ 'stackedBarChart NO es un tipo nativo. Usá --type barChart + --bind legend=Tabla[Col] para el mismo efecto.',
340
+ stackedColumnChart:
341
+ 'stackedColumnChart NO es un tipo nativo. Usá --type columnChart + --bind legend=Tabla[Col].',
342
+ stackedAreaChart:
343
+ 'stackedAreaChart NO es un tipo nativo. Usá --type areaChart + --bind legend=Tabla[Col].',
344
+ lineStackedColumnComboChart:
345
+ 'lineStackedColumnComboChart pasa validate pero Desktop no lo renderiza (container vacío). Usá --type lineClusteredColumnComboChart (la variante clustered, que SÍ funciona).',
346
+ funnel: 'funnel es un typeId legacy. Usá --type funnelChart.',
347
+ pie: 'pie no es un visualType. Usá --type pieChart (o --type donutChart).',
348
+ donut: 'donut no es un visualType. Usá --type donutChart.',
349
+ bar_chart: 'bar_chart es alias del CLI upstream. Este script usa nombres PBIR nativos — usá --type barChart.',
350
+ line_chart: 'line_chart es alias del CLI upstream. Usá --type lineChart.',
351
+ column_chart: 'column_chart no existe. Usá --type columnChart.',
352
+ area_chart: 'area_chart no existe. Usá --type areaChart.',
353
+ };
354
+
355
+ const SLICER_MODES = {
356
+ basic: { mode: 'Basic', orientation: 1 },
357
+ horizontal: { mode: 'Basic', orientation: 2 },
358
+ dropdown: { mode: 'Dropdown' },
359
+ between: { mode: 'Between' },
360
+ relative: { mode: 'Relative' },
361
+ };
362
+
363
+ const VISUAL_SCHEMA = 'https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.7.0/schema.json';
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // CLI argument parsing
367
+ // ---------------------------------------------------------------------------
368
+
369
+ function fail(msg, code = 1) {
370
+ process.stderr.write(`create-visual: ${msg}\n`);
371
+ process.exit(code);
372
+ }
373
+
374
+ function parseArgs(argv) {
375
+ const out = {
376
+ bindings: {}, // { RoleName: [fieldRef, ...] } always array to handle multi
377
+ paragraphs: [],
378
+ };
379
+ for (let i = 0; i < argv.length; i += 1) {
380
+ const a = argv[i];
381
+ const next = () => {
382
+ const v = argv[++i];
383
+ if (v === undefined) fail(`missing value for ${a}`);
384
+ return v;
385
+ };
386
+ if (a === '-p' || a === '--report-path') out.reportPath = next();
387
+ else if (a === '--page') out.page = next();
388
+ else if (a === '-t' || a === '--type') out.type = next();
389
+ else if (a === '-n' || a === '--name') out.name = next();
390
+ else if (a === '--x') out.x = Number(next());
391
+ else if (a === '--y') out.y = Number(next());
392
+ else if (a === '--width') out.width = Number(next());
393
+ else if (a === '--height') out.height = Number(next());
394
+ else if (a === '--tab-order') out.tabOrder = Number(next());
395
+ else if (a === '-z' || a === '--z') out.z = Number(next());
396
+ else if (a === '--bind' || a === '--binding') {
397
+ const v = next();
398
+ const eq = v.indexOf('=');
399
+ if (eq < 1) fail(`--bind expects role=Table[Field], got: ${v}`);
400
+ const role = v.slice(0, eq).trim();
401
+ const ref = v.slice(eq + 1).trim();
402
+ if (!role || !ref) fail(`--bind expects role=Table[Field], got: ${v}`);
403
+ out.bindings[role] = out.bindings[role] || [];
404
+ out.bindings[role].push(ref);
405
+ } else if (a === '--title') out.title = next();
406
+ else if (a === '--no-title') out.noTitle = true;
407
+ else if (a === '--paragraph') out.paragraphs.push(next());
408
+ else if (a === '--description') out.description = next();
409
+ else if (a === '--slicer-mode') out.slicerMode = next();
410
+ else if (a === '--list-types') out.listTypes = true;
411
+ else if (a === '-h' || a === '--help') out.help = true;
412
+ else fail(`unknown argument: ${a}`);
413
+ }
414
+ return out;
415
+ }
416
+
417
+ function printHelp() {
418
+ process.stdout.write(
419
+ [
420
+ 'Usage: create-visual.js --report-path <path> --page <name> --type <visualType> [options]',
421
+ '',
422
+ 'Required:',
423
+ ' -p, --report-path PATH Path to .Report folder',
424
+ ' --page NAME Page folder name under definition/pages/',
425
+ ' -t, --type TYPE Native PBIR visualType (use --list-types to see all)',
426
+ '',
427
+ 'Visual placement:',
428
+ ' -n, --name NAME Visual folder name (auto-generated if omitted)',
429
+ ' --x N --y N Canvas position in px (default 0,0)',
430
+ ' --width N Width in px (default 400)',
431
+ ' --height N Height in px (default 300)',
432
+ ' --tab-order N Tab order (auto-incremented if omitted)',
433
+ ' -z, --z N z-index (auto-incremented if omitted)',
434
+ '',
435
+ 'Data bindings (repeatable):',
436
+ ' --bind ROLE=FIELD e.g. --bind category="Cuentas[Banco]"',
437
+ ' --bind y="_Measures[Monto]"',
438
+ ' Role names are case-insensitive.',
439
+ '',
440
+ 'Container:',
441
+ ' --title TEXT Container title text',
442
+ ' --no-title Hide container title',
443
+ '',
444
+ 'Textbox shortcuts (--type textbox):',
445
+ ' --paragraph TEXT Repeatable paragraph',
446
+ ' --title TEXT Title paragraph (28pt bold by default)',
447
+ ' --description TEXT Body paragraph (14pt by default)',
448
+ '',
449
+ 'Slicer shortcut (--type slicer):',
450
+ ' --slicer-mode MODE basic | horizontal | dropdown | between | relative',
451
+ '',
452
+ 'Other:',
453
+ ' --list-types Show all supported visualTypes with roles',
454
+ ' -h, --help Show this help',
455
+ '',
456
+ ].join('\n')
457
+ );
458
+ }
459
+
460
+ function printTypes() {
461
+ const names = Object.keys(NATIVE_VISUAL_TYPES).sort();
462
+ process.stdout.write(`Native PBIR visualTypes (${names.length}):\n\n`);
463
+ for (const name of names) {
464
+ const def = NATIVE_VISUAL_TYPES[name];
465
+ process.stdout.write(` ${name}\n ${def.description}\n`);
466
+ const roleLines = Object.entries(def.roles).map(([r, spec]) => {
467
+ const flags = [];
468
+ if (spec.required) flags.push('required');
469
+ if (spec.multi) flags.push('multi');
470
+ flags.push(spec.kind);
471
+ return ` ${r} (${flags.join(', ')})`;
472
+ });
473
+ if (roleLines.length) process.stdout.write(` roles:\n${roleLines.join('\n')}\n`);
474
+ if (def.notes) process.stdout.write(` note: ${def.notes}\n`);
475
+ process.stdout.write('\n');
476
+ }
477
+ }
478
+
479
+ // ---------------------------------------------------------------------------
480
+ // Field ref parsing
481
+ // ---------------------------------------------------------------------------
482
+
483
+ // Parses "Table[Field]" or "'Table With Spaces'[Field]" into { table, field }.
484
+ function parseFieldRef(ref) {
485
+ // Quoted: 'Some Table'[Field]
486
+ let m = ref.match(/^'([^']+)'\[([^\]]+)\]$/);
487
+ if (m) return { table: m[1], field: m[2] };
488
+ // Unquoted: Table[Field] (table can have spaces too in some models)
489
+ m = ref.match(/^([^[]+)\[([^\]]+)\]$/);
490
+ if (m) return { table: m[1].trim(), field: m[2] };
491
+ fail(`invalid field ref: ${ref} — use Table[Field] or 'Table Name'[Field]`);
492
+ return null;
493
+ }
494
+
495
+ function buildProjection(fieldRef, kind) {
496
+ const { table, field } = parseFieldRef(fieldRef);
497
+ const wrapper = kind === 'measure' ? 'Measure' : 'Column';
498
+ return {
499
+ field: {
500
+ [wrapper]: {
501
+ Expression: { SourceRef: { Entity: table } },
502
+ Property: field,
503
+ },
504
+ },
505
+ queryRef: `${table}.${field}`,
506
+ nativeQueryRef: field,
507
+ // Only columns get "active: true" in the CLI-generated shapes; measures don't.
508
+ ...(kind === 'column' ? { active: true } : {}),
509
+ };
510
+ }
511
+
512
+ // Role name canonicalization: users can type 'category' and we match it to 'Category'.
513
+ function canonicalRoleName(roles, input) {
514
+ const lower = input.toLowerCase();
515
+ for (const key of Object.keys(roles)) {
516
+ if (key.toLowerCase() === lower) return key;
517
+ }
518
+ return null;
519
+ }
520
+
521
+ // ---------------------------------------------------------------------------
522
+ // Shape builder per visualType category
523
+ // ---------------------------------------------------------------------------
524
+
525
+ function buildQueryState(def, bindingsCanonical) {
526
+ const queryState = {};
527
+ for (const role of Object.keys(def.roles)) {
528
+ const spec = def.roles[role];
529
+ const refs = bindingsCanonical[role] || [];
530
+ const projections = refs.map((ref) => {
531
+ // 'any' kind → decide by table prefix convention: _Measures → measure.
532
+ let kind = spec.kind;
533
+ if (kind === 'any') {
534
+ const { table } = parseFieldRef(ref);
535
+ kind = table === '_Measures' || table.endsWith('_Measures') ? 'measure' : 'column';
536
+ }
537
+ return buildProjection(ref, kind);
538
+ });
539
+ queryState[role] = { projections };
540
+ }
541
+ return queryState;
542
+ }
543
+
544
+ function buildTextbox(args) {
545
+ const paragraphs = [];
546
+ // --title + --description combo shortcut
547
+ if (args.title) {
548
+ paragraphs.push({
549
+ textRuns: [
550
+ {
551
+ value: args.title,
552
+ textStyle: {
553
+ fontFamily: "'Segoe UI Semibold', wf_segoe-ui_semibold, helvetica, arial, sans-serif",
554
+ fontSize: '19pt',
555
+ color: '#111827',
556
+ },
557
+ },
558
+ ],
559
+ });
560
+ }
561
+ if (args.description) {
562
+ paragraphs.push({
563
+ textRuns: [
564
+ {
565
+ value: args.description,
566
+ textStyle: {
567
+ fontFamily: "'Segoe UI', wf_segoe-ui_normal, helvetica, arial, sans-serif",
568
+ fontSize: '10.5pt',
569
+ color: '#6B7280',
570
+ },
571
+ },
572
+ ],
573
+ });
574
+ }
575
+ // --paragraph is raw (no styling).
576
+ for (const p of args.paragraphs) {
577
+ paragraphs.push({ textRuns: [{ value: p }] });
578
+ }
579
+ if (paragraphs.length === 0) {
580
+ fail('textbox needs at least one of --title, --description, or --paragraph');
581
+ }
582
+ return {
583
+ objects: {
584
+ general: [{ properties: { paragraphs } }],
585
+ },
586
+ };
587
+ }
588
+
589
+ function buildSlicerObjects(args) {
590
+ const mode = args.slicerMode || 'basic';
591
+ const spec = SLICER_MODES[mode];
592
+ if (!spec) fail(`unknown --slicer-mode: ${mode}. Valid: ${Object.keys(SLICER_MODES).join(', ')}`);
593
+ const objects = {
594
+ data: [{ properties: { mode: { expr: { Literal: { Value: `'${spec.mode}'` } } } } }],
595
+ };
596
+ if (spec.orientation != null) {
597
+ objects.general = [
598
+ { properties: { orientation: { expr: { Literal: { Value: String(spec.orientation) } } } } },
599
+ ];
600
+ }
601
+ return { objects };
602
+ }
603
+
604
+ function buildContainerTitle(args) {
605
+ if (args.noTitle) {
606
+ return {
607
+ title: [{ properties: { show: { expr: { Literal: { Value: 'false' } } } } }],
608
+ };
609
+ }
610
+ if (args.title) {
611
+ return {
612
+ title: [
613
+ {
614
+ properties: {
615
+ show: { expr: { Literal: { Value: 'true' } } },
616
+ text: { expr: { Literal: { Value: `'${escapePbiLiteral(args.title)}'` } } },
617
+ },
618
+ },
619
+ ],
620
+ };
621
+ }
622
+ return null; // default: don't emit visualContainerObjects.title
623
+ }
624
+
625
+ function escapePbiLiteral(s) {
626
+ // PBI literal strings use single-quote delimiters — escape internal quotes,
627
+ // and collapse newlines/tabs to single spaces so the resulting literal
628
+ // stays on one line (PBI doesn't parse multi-line literals for these fields).
629
+ return String(s)
630
+ .replace(/[\r\n\t]+/g, ' ')
631
+ .replace(/'/g, "''");
632
+ }
633
+
634
+ // ---------------------------------------------------------------------------
635
+ // File I/O — atomic write (same pattern as apply-theme.js)
636
+ // ---------------------------------------------------------------------------
637
+
638
+ function writeJsonAtomic(filePath, data) {
639
+ const dir = path.dirname(filePath);
640
+ fs.mkdirSync(dir, { recursive: true });
641
+ if (fs.existsSync(filePath)) fs.copyFileSync(filePath, `${filePath}.bak`);
642
+ const tmp = `${filePath}.tmp`;
643
+ try {
644
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
645
+ fs.renameSync(tmp, filePath);
646
+ } catch (err) {
647
+ try {
648
+ fs.unlinkSync(tmp);
649
+ } catch (_) {
650
+ /* best-effort */
651
+ }
652
+ throw err;
653
+ }
654
+ }
655
+
656
+ function readExistingVisuals(pageDir) {
657
+ const visualsDir = path.join(pageDir, 'visuals');
658
+ if (!fs.existsSync(visualsDir)) return [];
659
+ const out = [];
660
+ for (const name of fs.readdirSync(visualsDir)) {
661
+ const vp = path.join(visualsDir, name, 'visual.json');
662
+ if (!fs.existsSync(vp)) continue;
663
+ try {
664
+ const data = JSON.parse(fs.readFileSync(vp, 'utf8'));
665
+ out.push({ name, data, path: vp });
666
+ } catch (_) {
667
+ // Skip malformed visuals; don't block creation.
668
+ }
669
+ }
670
+ return out;
671
+ }
672
+
673
+ function nextInt(existing, key) {
674
+ let max = -1;
675
+ for (const v of existing) {
676
+ const pos = v.data && v.data.position;
677
+ if (pos && typeof pos[key] === 'number' && pos[key] > max) max = pos[key];
678
+ }
679
+ return max + 1;
680
+ }
681
+
682
+ // ---------------------------------------------------------------------------
683
+ // main()
684
+ // ---------------------------------------------------------------------------
685
+
686
+ function main() {
687
+ const args = parseArgs(process.argv.slice(2));
688
+ if (args.help || process.argv.length === 2) {
689
+ printHelp();
690
+ return;
691
+ }
692
+ if (args.listTypes) {
693
+ printTypes();
694
+ return;
695
+ }
696
+
697
+ // ---- required args
698
+ if (!args.reportPath) fail('--report-path is required');
699
+ if (!args.page) fail('--page is required');
700
+ if (!args.type) fail('--type is required (use --list-types to see options)');
701
+
702
+ // ---- type validation with friendly errors for known non-native aliases
703
+ if (KNOWN_NON_NATIVE_TYPES[args.type]) {
704
+ fail(KNOWN_NON_NATIVE_TYPES[args.type]);
705
+ }
706
+ const def = NATIVE_VISUAL_TYPES[args.type];
707
+ if (!def) {
708
+ fail(`unknown visualType: ${args.type}. Run --list-types for the allowlist.`);
709
+ }
710
+
711
+ // ---- resolve paths
712
+ const reportPath = path.resolve(args.reportPath);
713
+ const pageDir = path.join(reportPath, 'definition', 'pages', args.page);
714
+ if (!fs.existsSync(pageDir)) {
715
+ fail(`page not found: ${pageDir}. Create the page first (e.g. via pbi report add-page or create-page.js).`);
716
+ }
717
+
718
+ // ---- auto-generate name if omitted
719
+ let name = args.name;
720
+ if (!name) {
721
+ const existing = readExistingVisuals(pageDir);
722
+ let n = existing.length;
723
+ let candidate;
724
+ do {
725
+ candidate = `${args.type}_${n}`;
726
+ n += 1;
727
+ } while (existing.some((v) => v.name === candidate));
728
+ name = candidate;
729
+ }
730
+
731
+ const visualDir = path.join(pageDir, 'visuals', name);
732
+ const visualJsonPath = path.join(visualDir, 'visual.json');
733
+ if (fs.existsSync(visualJsonPath)) {
734
+ fail(
735
+ `visual already exists: ${visualJsonPath}. Delete the folder or use a different --name (got: ${name}).`
736
+ );
737
+ }
738
+
739
+ // ---- canonicalize bindings against role names
740
+ const bindingsCanonical = {};
741
+ for (const [inputRole, refs] of Object.entries(args.bindings)) {
742
+ // Textbox has no roles — reject bindings early.
743
+ if (args.type === 'textbox') {
744
+ fail('textbox does not accept --bind. Use --title, --description, or --paragraph.');
745
+ }
746
+ const canonical = canonicalRoleName(def.roles, inputRole);
747
+ if (!canonical) {
748
+ const valid = Object.keys(def.roles).join(', ') || '(none)';
749
+ fail(`--bind role "${inputRole}" is not valid for --type ${args.type}. Valid roles: ${valid}.`);
750
+ }
751
+ const spec = def.roles[canonical];
752
+ if (refs.length > 1 && !spec.multi) {
753
+ fail(`role ${canonical} does not accept multiple bindings (got ${refs.length}).`);
754
+ }
755
+ bindingsCanonical[canonical] = refs;
756
+ }
757
+
758
+ // ---- check required roles (textbox/cardVisual exceptions)
759
+ if (args.type !== 'textbox') {
760
+ for (const [role, spec] of Object.entries(def.roles)) {
761
+ if (spec.required && !(role in bindingsCanonical)) {
762
+ fail(`required role missing: --bind ${role.toLowerCase()}=Table[Field]`);
763
+ }
764
+ }
765
+ }
766
+
767
+ // ---- build position
768
+ const existing = readExistingVisuals(pageDir);
769
+ const autoZ = nextInt(existing, 'z');
770
+ const autoTabOrder = nextInt(existing, 'tabOrder');
771
+
772
+ // Size defaults: cards are smaller, charts bigger.
773
+ const defaultSize = ['card', 'cardVisual', 'kpi', 'gauge', 'multiRowCard'].includes(args.type)
774
+ ? { w: 280, h: 120 }
775
+ : { w: 400, h: 300 };
776
+
777
+ const position = {
778
+ x: args.x != null ? args.x : 0,
779
+ y: args.y != null ? args.y : 0,
780
+ z: args.z != null ? args.z : autoZ,
781
+ height: args.height != null ? args.height : defaultSize.h,
782
+ width: args.width != null ? args.width : defaultSize.w,
783
+ tabOrder: args.tabOrder != null ? args.tabOrder : autoTabOrder,
784
+ };
785
+
786
+ // ---- assemble visual.json
787
+ const visual = { visualType: args.type };
788
+
789
+ if (args.type === 'textbox') {
790
+ Object.assign(visual, buildTextbox(args));
791
+ } else {
792
+ visual.query = { queryState: buildQueryState(def, bindingsCanonical) };
793
+ if (def.defaults && def.defaults.query) {
794
+ Object.assign(visual.query, def.defaults.query);
795
+ }
796
+ if (args.type === 'slicer') {
797
+ Object.assign(visual, buildSlicerObjects(args));
798
+ }
799
+ }
800
+
801
+ // drillFilterOtherVisuals only makes sense for data visuals; textboxes
802
+ // have no query so there's nothing to filter — omit the flag entirely
803
+ // to keep the written shape minimal.
804
+ if (args.type !== 'textbox') {
805
+ visual.drillFilterOtherVisuals = true;
806
+ }
807
+
808
+ // Container defaults (cardVisual spawns an empty visualContainerObjects).
809
+ if (def.defaults && def.defaults.visualContainerObjects !== undefined) {
810
+ visual.visualContainerObjects = def.defaults.visualContainerObjects;
811
+ }
812
+
813
+ if (args.type === 'textbox') {
814
+ // Textbox chrome is always hidden — the paragraphs ARE the content,
815
+ // and --title/--description are paragraph shortcuts (see buildTextbox),
816
+ // not container title overrides. Emitting container title here on top
817
+ // of the inner title paragraph causes a double-render in PBI Desktop.
818
+ visual.visualContainerObjects = {
819
+ title: [{ properties: { show: { expr: { Literal: { Value: 'false' } } } } }],
820
+ background: [{ properties: { show: { expr: { Literal: { Value: 'false' } } } } }],
821
+ border: [{ properties: { show: { expr: { Literal: { Value: 'false' } } } } }],
822
+ dropShadow: [{ properties: { show: { expr: { Literal: { Value: 'false' } } } } }],
823
+ };
824
+ } else {
825
+ // For data visuals, --title / --no-title control the container title.
826
+ const titleBlock = buildContainerTitle(args);
827
+ if (titleBlock) {
828
+ visual.visualContainerObjects = visual.visualContainerObjects || {};
829
+ Object.assign(visual.visualContainerObjects, titleBlock);
830
+ }
831
+ }
832
+
833
+ const visualJson = {
834
+ $schema: VISUAL_SCHEMA,
835
+ name,
836
+ position,
837
+ visual,
838
+ };
839
+
840
+ // ---- write
841
+ try {
842
+ writeJsonAtomic(visualJsonPath, visualJson);
843
+ } catch (err) {
844
+ fail(`failed to write ${visualJsonPath}: ${err.message}`, 2);
845
+ }
846
+
847
+ process.stdout.write(
848
+ [
849
+ `create-visual: ✓ created ${args.type}`,
850
+ ` name: ${name}`,
851
+ ` page: ${args.page}`,
852
+ ` file: ${visualJsonPath}`,
853
+ ` pos: x=${position.x} y=${position.y} w=${position.width} h=${position.height}`,
854
+ '',
855
+ ].join('\n')
856
+ );
857
+ }
858
+
859
+ // Export internals for unit tests.
860
+ module.exports = {
861
+ NATIVE_VISUAL_TYPES,
862
+ KNOWN_NON_NATIVE_TYPES,
863
+ SLICER_MODES,
864
+ parseArgs,
865
+ parseFieldRef,
866
+ buildProjection,
867
+ canonicalRoleName,
868
+ buildQueryState,
869
+ buildTextbox,
870
+ buildSlicerObjects,
871
+ buildContainerTitle,
872
+ escapePbiLiteral,
873
+ };
874
+
875
+ // Only run main() when invoked as script (not when required from tests).
876
+ if (require.main === module) {
877
+ main();
878
+ }