@clipboard-health/ai-rules 2.29.2 → 2.29.4
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/package.json +1 -1
- package/skills/metabase-to-hex/SKILL.md +444 -0
- package/skills/metabase-to-hex/references/cbh_stack.md +123 -0
- package/skills/metabase-to-hex/references/gotchas.md +454 -0
- package/skills/metabase-to-hex/references/yaml_schemas.md +281 -0
- package/skills/metabase-to-hex/scripts/audit_vars.sh +26 -0
- package/skills/metabase-to-hex/scripts/build_layout.py +227 -0
- package/skills/metabase-to-hex/scripts/create_cells.sh.tmpl +41 -0
- package/skills/metabase-to-hex/scripts/inventory.py +120 -0
- package/skills/metabase-to-hex/scripts/rename_cells.py +88 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# Hex YAML cell shapes — exact schemas
|
|
2
|
+
|
|
3
|
+
Hex's YAML import is picky. Use the shapes below verbatim. These are the only YAML
|
|
4
|
+
constructs the round-trip path supports for adding new cells programmatically.
|
|
5
|
+
|
|
6
|
+
## Top-level structure
|
|
7
|
+
|
|
8
|
+
```yaml
|
|
9
|
+
schemaVersion: 3
|
|
10
|
+
meta:
|
|
11
|
+
sourceVersionId: <do not change>
|
|
12
|
+
description: ...
|
|
13
|
+
projectId: <do not change — Hex matches imports by this>
|
|
14
|
+
title: ...
|
|
15
|
+
timezone: null
|
|
16
|
+
appTheme: SYS_PREF
|
|
17
|
+
codeLanguage: PYTHON
|
|
18
|
+
status: { name: Development }
|
|
19
|
+
# ... other meta fields preserved from export ...
|
|
20
|
+
projectAssets: { dataConnections: [], envVars: [], secrets: [] }
|
|
21
|
+
sharedAssets:
|
|
22
|
+
secrets: []
|
|
23
|
+
vcsPackages: []
|
|
24
|
+
dataConnections:
|
|
25
|
+
- dataConnectionId: <conn-id>
|
|
26
|
+
externalFileIntegrations: []
|
|
27
|
+
cells:
|
|
28
|
+
- <cell> # SQL cells (already created by hex cell create)
|
|
29
|
+
- <cell> # INPUT cells (added during YAML round-trip)
|
|
30
|
+
- <cell> # MARKDOWN cells (added during YAML round-trip)
|
|
31
|
+
appLayout:
|
|
32
|
+
visibleMetadataFields: [NAME, DESCRIPTION, AUTHOR, LAST_EDITED, LAST_RUN, CATEGORIES, STATUS, TABLE_OF_CONTENTS]
|
|
33
|
+
fullWidth: true
|
|
34
|
+
tabs:
|
|
35
|
+
- name: <tab-name>
|
|
36
|
+
rows: [...]
|
|
37
|
+
sharedFilters: []
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## SQL cell (created by `hex cell create -t sql`)
|
|
41
|
+
|
|
42
|
+
```yaml
|
|
43
|
+
- cellType: SQL
|
|
44
|
+
cellId: <hex-assigned-uuid7>
|
|
45
|
+
cellLabel: <human-readable label>
|
|
46
|
+
config:
|
|
47
|
+
source: |-
|
|
48
|
+
SELECT ...
|
|
49
|
+
FROM ...
|
|
50
|
+
WHERE col = {{ shift_id }} # NO QUOTES around {{ var }}
|
|
51
|
+
dataFrameCell: false
|
|
52
|
+
dataConnectionId: 530b70b8-b300-43c9-9b3e-e4b98ded0379
|
|
53
|
+
resultVariableName: <snake_case_slug>
|
|
54
|
+
useRichDisplay: true
|
|
55
|
+
enablePreview: true
|
|
56
|
+
sqlCellOutputType: PANDAS
|
|
57
|
+
useQueryMode: false
|
|
58
|
+
castDecimals: true
|
|
59
|
+
useNativeDates: true
|
|
60
|
+
outputFilteredResult: true
|
|
61
|
+
allowDuplicateColumns: false
|
|
62
|
+
tableDisplayConfig:
|
|
63
|
+
pageSize: 50
|
|
64
|
+
# ... other display config preserved from export ...
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
You will rarely hand-write SQL cells. Create them with `hex cell create` and edit
|
|
68
|
+
their `source` only if you need to re-push fixes (use `hex cell update`).
|
|
69
|
+
|
|
70
|
+
## INPUT cell (added via YAML round-trip)
|
|
71
|
+
|
|
72
|
+
```yaml
|
|
73
|
+
- cellType: INPUT
|
|
74
|
+
cellId: <fresh uuidv7>
|
|
75
|
+
cellLabel: Shift ID # what the UI shows above the box
|
|
76
|
+
config:
|
|
77
|
+
inputType: TEXT_INPUT
|
|
78
|
+
name: shift_id # the Jinja variable name — MUST match SQL refs
|
|
79
|
+
outputType: STRING
|
|
80
|
+
options: null # required key — omitting it = malformed import
|
|
81
|
+
defaultValue: "" # or a sample value to pre-populate
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Key constraints:
|
|
85
|
+
|
|
86
|
+
- `options: null` is **required** for `TEXT_INPUT`. Omitting it triggers a
|
|
87
|
+
generic "request was rejected as malformed" error during `hex project import`.
|
|
88
|
+
- Do **not** add `label:` inside `config`. Only `cellLabel` at the cell level.
|
|
89
|
+
- `name` is the Jinja variable, not just a display thing. The SQL cells reference
|
|
90
|
+
it as `{{ shift_id }}` and `{% if shift_id %}`.
|
|
91
|
+
|
|
92
|
+
### Dropdown backed by a SQL cell (single-select)
|
|
93
|
+
|
|
94
|
+
```yaml
|
|
95
|
+
- cellType: INPUT
|
|
96
|
+
cellId: <fresh uuidv7>
|
|
97
|
+
cellLabel: MSA list # what the UI shows above the dropdown
|
|
98
|
+
config:
|
|
99
|
+
inputType: DROPDOWN
|
|
100
|
+
name: msa_list # the Jinja variable name
|
|
101
|
+
outputType: DYNAMIC
|
|
102
|
+
options:
|
|
103
|
+
valueOptions:
|
|
104
|
+
dfName: msa_options # SQL cell whose first column becomes the dropdown values
|
|
105
|
+
variableName: null
|
|
106
|
+
optional: false # set true to allow no selection
|
|
107
|
+
defaultValue: null
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Notes:
|
|
111
|
+
|
|
112
|
+
- `options.valueOptions.dfName` references the **SQL cell's output dataframe
|
|
113
|
+
name**. The YAML key for that on a SQL cell is `resultVariableName` (see the
|
|
114
|
+
SQL cell shape above), which is what `hex cell create --output-dataframe <name>`
|
|
115
|
+
writes. If you grep for `outputDataframe` you'll come up empty — the YAML
|
|
116
|
+
exports use `resultVariableName`.
|
|
117
|
+
- The options SQL cell must be **run at least once** for the dataframe to
|
|
118
|
+
materialize, otherwise the dropdown is empty. After a fresh import, trigger
|
|
119
|
+
`hex project run` so the kernel populates it.
|
|
120
|
+
- **Multi-select is NOT settable via YAML.** Push this exact single-select shape
|
|
121
|
+
and ask the user to flip the multi-select toggle in the cell's right-sidebar
|
|
122
|
+
config. The SQL referencing the variable should use `IN ({{ var | array }})`,
|
|
123
|
+
which is a no-op for single-select scalars but required once multi-select is
|
|
124
|
+
on (see gotchas 10/16).
|
|
125
|
+
|
|
126
|
+
### Date input — single date or date range (set via UI, then SQL update)
|
|
127
|
+
|
|
128
|
+
Date inputs (single or range) cannot be created via YAML import. Every variant
|
|
129
|
+
tried — `DATE_PICKER`, `DATE_INPUT`, `DATE`, `DATE_RANGE`, `DATE_RANGE_PICKER`,
|
|
130
|
+
`DATERANGE` — is rejected as "malformed" (gotcha 17). The working flow is:
|
|
131
|
+
|
|
132
|
+
1. Push a `TEXT_INPUT` placeholder with the same `name`. Use a snake_case name
|
|
133
|
+
the SQL can reference even after the type changes (e.g. `shift_start_range`).
|
|
134
|
+
2. Ask the user to open the cell in the UI and change **Input type** to
|
|
135
|
+
`Date`. For a range, they then toggle **Allow date range** on the same cell.
|
|
136
|
+
3. _Then_ push SQL that references the variable correctly (see access patterns
|
|
137
|
+
below).
|
|
138
|
+
|
|
139
|
+
After the UI flip, the cell's YAML export looks like this (do not hand-write
|
|
140
|
+
it — Hex's import API still rejects it; capture it via `hex project export`
|
|
141
|
+
only for diffing / understanding):
|
|
142
|
+
|
|
143
|
+
```yaml
|
|
144
|
+
# Single date
|
|
145
|
+
- cellType: INPUT
|
|
146
|
+
cellId: <id>
|
|
147
|
+
cellLabel: As-of date
|
|
148
|
+
config:
|
|
149
|
+
inputType: DATE
|
|
150
|
+
name: as_of_date
|
|
151
|
+
outputType: DATETIME # Hex returns a datetime.datetime
|
|
152
|
+
options:
|
|
153
|
+
enableTime: false
|
|
154
|
+
showRelativeDates: true
|
|
155
|
+
useDateRange: false
|
|
156
|
+
defaultValue:
|
|
157
|
+
- dateString: 2026-06-01
|
|
158
|
+
|
|
159
|
+
# Date range — same cell, useDateRange: true, defaultValue has TWO entries
|
|
160
|
+
- cellType: INPUT
|
|
161
|
+
cellId: <id>
|
|
162
|
+
cellLabel: Shift start range
|
|
163
|
+
config:
|
|
164
|
+
inputType: DATE
|
|
165
|
+
name: shift_start_range
|
|
166
|
+
outputType: DATETIME
|
|
167
|
+
options:
|
|
168
|
+
enableTime: false
|
|
169
|
+
showRelativeDates: true
|
|
170
|
+
useDateRange: true
|
|
171
|
+
defaultValue:
|
|
172
|
+
- dateString: 2025-10-17
|
|
173
|
+
- dateString: 2025-10-30
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**Jinja access pattern for date range — two separate variables suffixed
|
|
177
|
+
`_start` and `_end`.** With `options.useDateRange: true`, Hex does **not**
|
|
178
|
+
bind the cell's `name` to anything; instead it injects two new variables
|
|
179
|
+
`<name>_start` and `<name>_end`, both `datetime.date`. Referencing the base
|
|
180
|
+
`name` (e.g. `{{ shift_start_range }}`, `.start`, `.end`, `[0]`, `[1]`) all
|
|
181
|
+
fail with `Undefined variable(s): <base name>` because the base is literally
|
|
182
|
+
not in scope. Use:
|
|
183
|
+
|
|
184
|
+
```sql
|
|
185
|
+
{% if shift_start_range_start %}AND SHIFT_START_AT >= CAST({{ shift_start_range_start }} AS TIMESTAMP_NTZ){% endif %}
|
|
186
|
+
{% if shift_start_range_end %}AND SHIFT_START_AT < DATEADD(day, 1, CAST({{ shift_start_range_end }} AS TIMESTAMP_NTZ)){% endif %}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
The two `if` guards short-circuit when either bound is cleared in the UI. The
|
|
190
|
+
`+1 day` on the upper bound makes the range inclusive of the to-date (Hex
|
|
191
|
+
passes dates at midnight UTC, so `< to_date` would exclude the to-day).
|
|
192
|
+
|
|
193
|
+
**How to verify the variable name for any input cell:** create a one-off Python
|
|
194
|
+
diagnostic cell with `globals().get('<candidate>', '<not in globals>')` for
|
|
195
|
+
each plausible name (`<name>`, `<name>_start`, `<name>_end`, `<name>_from`,
|
|
196
|
+
`<name>_to`, etc.). Run the project, read the output, fix SQL, then delete the
|
|
197
|
+
cell. This took ~2 minutes on the card 21241 migration and saved guessing.
|
|
198
|
+
|
|
199
|
+
Confirmed shape on project `019e83b9-aed0-723b-9b96-313269b324bc` (card 21241
|
|
200
|
+
migration, 2026-06-01). The `name` field in the YAML is essentially a _prefix_
|
|
201
|
+
for date-range inputs, not the variable name itself.
|
|
202
|
+
|
|
203
|
+
### Other input types
|
|
204
|
+
|
|
205
|
+
Hex also supports `NUMERIC_INPUT`, `SLIDER`, `FILE_INPUT`, etc. — but the YAML
|
|
206
|
+
import API only accepts `TEXT_INPUT` and the single-select `DROPDOWN` shape
|
|
207
|
+
above. For everything else, push `TEXT_INPUT` and cast/parse in SQL or Python,
|
|
208
|
+
or stop after the cells are imported and ask the user to configure the input
|
|
209
|
+
type in the Hex UI. See gotcha 17 for the full accepted/rejected list.
|
|
210
|
+
|
|
211
|
+
## MARKDOWN cell (added via YAML round-trip)
|
|
212
|
+
|
|
213
|
+
```yaml
|
|
214
|
+
- cellType: MARKDOWN
|
|
215
|
+
cellId: <fresh uuidv7>
|
|
216
|
+
config:
|
|
217
|
+
source: |
|
|
218
|
+
## Section header
|
|
219
|
+
|
|
220
|
+
Body text in CommonMark markdown. Hex renders it inline.
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
No `cellLabel` for markdown cells. If you set one, Hex preserves it but the UI
|
|
224
|
+
doesn't surface it.
|
|
225
|
+
|
|
226
|
+
## appLayout — tabs / rows / columns / elements
|
|
227
|
+
|
|
228
|
+
```yaml
|
|
229
|
+
appLayout:
|
|
230
|
+
fullWidth: true # full-width vs centered narrow column
|
|
231
|
+
tabs:
|
|
232
|
+
- name: Main
|
|
233
|
+
rows:
|
|
234
|
+
# Row 1: three inputs side-by-side
|
|
235
|
+
- columns:
|
|
236
|
+
- start: 0
|
|
237
|
+
end: 40
|
|
238
|
+
elements:
|
|
239
|
+
- <cell-element>
|
|
240
|
+
- start: 40
|
|
241
|
+
end: 80
|
|
242
|
+
elements:
|
|
243
|
+
- <cell-element>
|
|
244
|
+
- start: 80
|
|
245
|
+
end: 120
|
|
246
|
+
elements:
|
|
247
|
+
- <cell-element>
|
|
248
|
+
# Row 2: full-width SQL output
|
|
249
|
+
- columns:
|
|
250
|
+
- start: 0
|
|
251
|
+
end: 120
|
|
252
|
+
elements:
|
|
253
|
+
- <cell-element>
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Columns span 0–120. Common patterns:
|
|
257
|
+
|
|
258
|
+
- Full width: `start: 0, end: 120`
|
|
259
|
+
- Halves: `start: 0, end: 60` and `start: 60, end: 120`
|
|
260
|
+
- Thirds: `start: 0/40/80, end: 40/80/120`
|
|
261
|
+
- Quarters: 0/30/60/90/120
|
|
262
|
+
|
|
263
|
+
Multiple `elements` per column stack vertically.
|
|
264
|
+
|
|
265
|
+
## Cell element shape (inside `elements: []`)
|
|
266
|
+
|
|
267
|
+
```python
|
|
268
|
+
{
|
|
269
|
+
"showSource": False, # hide the SQL source pane (True = developer view)
|
|
270
|
+
"hideOutput": False, # show the SQL result
|
|
271
|
+
"type": "CELL", # always CELL for this layout
|
|
272
|
+
"cellId": "<id>", # the cell to render here
|
|
273
|
+
"sharedFilterId": None,
|
|
274
|
+
"height": None, # auto-fit
|
|
275
|
+
"showLabel": True, # show the cellLabel above the cell
|
|
276
|
+
"explorable": None,
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Use exactly these keys with exactly these defaults unless you have a specific UI
|
|
281
|
+
need. Hex's parser is sensitive to missing keys.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Audit every Jinja variable referenced in the SQL cells of a migration.
|
|
3
|
+
# Use this to cross-check against the project's defined Input cells before importing.
|
|
4
|
+
# A variable referenced in SQL but missing from inputs → "Undefined variable(s)" at run time.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# audit_vars.sh <cells_dir>
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
|
|
10
|
+
DIR="${1:-./hex_cells}"
|
|
11
|
+
|
|
12
|
+
echo "=== Jinja variables per cell in $DIR ==="
|
|
13
|
+
for f in "$DIR"/*.sql; do
|
|
14
|
+
vars=$(grep -oE '\{[%{][- ]*(if +[a-z_]+|[a-z_]+) [- ]*[%}]\}' "$f" 2>/dev/null \
|
|
15
|
+
| grep -oE '[a-z_]+' \
|
|
16
|
+
| grep -v -E '^(if|else|elif|endif|endfor|for|in)$' \
|
|
17
|
+
| sort -u | tr '\n' ',' | sed 's/,$//')
|
|
18
|
+
echo "$(basename "$f") → ${vars:-(none)}"
|
|
19
|
+
done
|
|
20
|
+
|
|
21
|
+
echo
|
|
22
|
+
echo "=== Union of all referenced vars (this must be a subset of the project's Input names) ==="
|
|
23
|
+
grep -rohE '\{[%{][- ]*(if +[a-z_]+|[a-z_]+) [- ]*[%}]\}' "$DIR"/*.sql 2>/dev/null \
|
|
24
|
+
| grep -oE '[a-z_]+' \
|
|
25
|
+
| grep -v -E '^(if|else|elif|endif|endfor|for|in)$' \
|
|
26
|
+
| sort -u
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
YAML round-trip helper for a Hex project: adds Input cells, Markdown cells,
|
|
4
|
+
and an appLayout with per-tab input bars matching the Metabase parameter mappings.
|
|
5
|
+
|
|
6
|
+
This is a TEMPLATE — copy and customize the constants at the top for each migration.
|
|
7
|
+
|
|
8
|
+
Workflow:
|
|
9
|
+
hex project export <project_id> -o exported.yaml
|
|
10
|
+
python3 build_layout.py # writes import_ready.yaml
|
|
11
|
+
hex project import import_ready.yaml
|
|
12
|
+
|
|
13
|
+
Inputs:
|
|
14
|
+
exported.yaml — fresh Hex export with SQL cells already created
|
|
15
|
+
dashboard_<id>.json — Metabase dashboard JSON (for layout order + text cards)
|
|
16
|
+
"""
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import yaml
|
|
25
|
+
|
|
26
|
+
# ---------- CONFIGURE PER MIGRATION ----------
|
|
27
|
+
|
|
28
|
+
WORKDIR = Path("/home/alex/pii-migrations/dashboard_<ID>")
|
|
29
|
+
INPUT_YAML = WORKDIR / "artifacts/exported.yaml"
|
|
30
|
+
OUTPUT_YAML = WORKDIR / "artifacts/import_ready.yaml"
|
|
31
|
+
DASHBOARD_JSON = WORKDIR / "dashboard_<ID>.json"
|
|
32
|
+
|
|
33
|
+
# One entry per Metabase tab — order here is the order in the Hex App view
|
|
34
|
+
TABS = [
|
|
35
|
+
# {"mb_tab_id": 91, "name": "Main"},
|
|
36
|
+
# ...
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# For each tab, the input slugs that tab's cards use (mirrors Metabase
|
|
40
|
+
# parameter_mappings). Tab inputs render side-by-side in the first row of that tab.
|
|
41
|
+
TAB_INPUTS = {
|
|
42
|
+
# 91: ["shift_id", "hcp_id", "facility_id"],
|
|
43
|
+
# ...
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# card_id -> exact cellLabel as already created via `hex cell create -l <label>`
|
|
47
|
+
CARD_LABEL = {
|
|
48
|
+
# 22130: "App Shiftlogs Stg",
|
|
49
|
+
# ...
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Inputs to create. The `name` is the Jinja variable; `label` is the UI label.
|
|
53
|
+
INPUTS = [
|
|
54
|
+
# {"name": "shift_id", "label": "Shift ID", "default": ""},
|
|
55
|
+
# ...
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
# ---------- END CONFIGURE ----------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def uuidv7() -> str:
|
|
62
|
+
"""Generate a UUID v7 — timestamp-prefixed, matches what Hex's export uses."""
|
|
63
|
+
ts_ms = int(time.time() * 1000)
|
|
64
|
+
rand = os.urandom(10)
|
|
65
|
+
b = bytearray(16)
|
|
66
|
+
b[0] = (ts_ms >> 40) & 0xFF
|
|
67
|
+
b[1] = (ts_ms >> 32) & 0xFF
|
|
68
|
+
b[2] = (ts_ms >> 24) & 0xFF
|
|
69
|
+
b[3] = (ts_ms >> 16) & 0xFF
|
|
70
|
+
b[4] = (ts_ms >> 8) & 0xFF
|
|
71
|
+
b[5] = ts_ms & 0xFF
|
|
72
|
+
b[6] = (0x70 | (rand[0] & 0x0F)) & 0xFF # version 7
|
|
73
|
+
b[7] = rand[1]
|
|
74
|
+
b[8] = (0x80 | (rand[2] & 0x3F)) & 0xFF # RFC variant
|
|
75
|
+
b[9:16] = rand[3:10]
|
|
76
|
+
return str(uuid.UUID(bytes=bytes(b)))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def make_input_cell(input_def):
|
|
80
|
+
cell_id = uuidv7()
|
|
81
|
+
return cell_id, {
|
|
82
|
+
"cellType": "INPUT",
|
|
83
|
+
"cellId": cell_id,
|
|
84
|
+
"cellLabel": input_def["label"],
|
|
85
|
+
"config": {
|
|
86
|
+
"inputType": "TEXT_INPUT",
|
|
87
|
+
"name": input_def["name"],
|
|
88
|
+
"outputType": "STRING",
|
|
89
|
+
"options": None, # REQUIRED — omitting it = "malformed" import
|
|
90
|
+
"defaultValue": input_def.get("default", ""),
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def make_markdown_cell(text):
|
|
96
|
+
cell_id = uuidv7()
|
|
97
|
+
return cell_id, {
|
|
98
|
+
"cellType": "MARKDOWN",
|
|
99
|
+
"cellId": cell_id,
|
|
100
|
+
"config": {"source": text},
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cell_element(cell_id, show_source=False, show_label=True):
|
|
105
|
+
"""Element shape for appLayout. Use exactly this — Hex's parser is picky."""
|
|
106
|
+
return {
|
|
107
|
+
"showSource": show_source,
|
|
108
|
+
"hideOutput": False,
|
|
109
|
+
"type": "CELL",
|
|
110
|
+
"cellId": cell_id,
|
|
111
|
+
"sharedFilterId": None,
|
|
112
|
+
"height": None,
|
|
113
|
+
"showLabel": show_label,
|
|
114
|
+
"explorable": None,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def main():
|
|
119
|
+
with open(INPUT_YAML) as f:
|
|
120
|
+
doc = yaml.safe_load(f)
|
|
121
|
+
with open(DASHBOARD_JSON) as f:
|
|
122
|
+
dashboard = json.load(f)
|
|
123
|
+
|
|
124
|
+
# Existing SQL cells: build cellLabel -> cellId map
|
|
125
|
+
label_to_id = {c["cellLabel"]: c["cellId"] for c in doc.get("cells", []) if c.get("cellLabel")}
|
|
126
|
+
card_to_cell = {cid: label_to_id[lbl] for cid, lbl in CARD_LABEL.items() if lbl in label_to_id}
|
|
127
|
+
missing = [cid for cid in CARD_LABEL if cid not in card_to_cell]
|
|
128
|
+
if missing:
|
|
129
|
+
print(f"WARN: no Hex cell found for cards: {missing}", file=sys.stderr)
|
|
130
|
+
|
|
131
|
+
# Build dashcards by tab (with row/col positions) for ordering markdown vs SQL
|
|
132
|
+
pid2slug = {p["id"]: p.get("slug") for p in dashboard.get("parameters", [])}
|
|
133
|
+
dashcards_by_tab = {t["mb_tab_id"]: [] for t in TABS}
|
|
134
|
+
for dc in dashboard.get("dashcards", []):
|
|
135
|
+
tab_id = dc.get("dashboard_tab_id")
|
|
136
|
+
if tab_id not in dashcards_by_tab:
|
|
137
|
+
continue
|
|
138
|
+
card = dc.get("card") or {}
|
|
139
|
+
cid = card.get("id")
|
|
140
|
+
dashcards_by_tab[tab_id].append({
|
|
141
|
+
"kind": "query" if cid else "text",
|
|
142
|
+
"card_id": cid,
|
|
143
|
+
"row": dc.get("row") or 0,
|
|
144
|
+
"col": dc.get("col") or 0,
|
|
145
|
+
"text": (dc.get("visualization_settings") or {}).get("text", "") if not cid else None,
|
|
146
|
+
})
|
|
147
|
+
for tab_id in dashcards_by_tab:
|
|
148
|
+
dashcards_by_tab[tab_id].sort(key=lambda d: (d["row"], d["col"]))
|
|
149
|
+
|
|
150
|
+
# Create INPUT cells (one per slug in INPUTS)
|
|
151
|
+
new_cells = []
|
|
152
|
+
input_name_to_cellid = {}
|
|
153
|
+
for inp in INPUTS:
|
|
154
|
+
cid, cell = make_input_cell(inp)
|
|
155
|
+
new_cells.append(cell)
|
|
156
|
+
input_name_to_cellid[inp["name"]] = cid
|
|
157
|
+
|
|
158
|
+
# Create MARKDOWN cells (one per text card)
|
|
159
|
+
md_cells_by_tab = {t["mb_tab_id"]: [] for t in TABS}
|
|
160
|
+
for tab_id, cards in dashcards_by_tab.items():
|
|
161
|
+
for c in cards:
|
|
162
|
+
if c["kind"] == "text":
|
|
163
|
+
cid, cell = make_markdown_cell(c["text"])
|
|
164
|
+
new_cells.append(cell)
|
|
165
|
+
md_cells_by_tab[tab_id].append({"row": c["row"], "cell_id": cid})
|
|
166
|
+
|
|
167
|
+
doc["cells"].extend(new_cells)
|
|
168
|
+
|
|
169
|
+
# Build appLayout.tabs
|
|
170
|
+
layout_tabs = []
|
|
171
|
+
for tab_meta in TABS:
|
|
172
|
+
mb_id = tab_meta["mb_tab_id"]
|
|
173
|
+
rows = []
|
|
174
|
+
|
|
175
|
+
# Top row: this tab's input cells side-by-side
|
|
176
|
+
input_slugs = TAB_INPUTS.get(mb_id, [])
|
|
177
|
+
if input_slugs:
|
|
178
|
+
n = len(input_slugs)
|
|
179
|
+
span = 120 // n
|
|
180
|
+
cols = []
|
|
181
|
+
for i, slug in enumerate(input_slugs):
|
|
182
|
+
start = i * span
|
|
183
|
+
end = start + span if i < n - 1 else 120
|
|
184
|
+
cols.append({
|
|
185
|
+
"start": start, "end": end,
|
|
186
|
+
"elements": [cell_element(input_name_to_cellid[slug])],
|
|
187
|
+
})
|
|
188
|
+
rows.append({"columns": cols})
|
|
189
|
+
|
|
190
|
+
# Body: markdown + SQL outputs in dashcard order, full-width
|
|
191
|
+
body_items = []
|
|
192
|
+
for c in dashcards_by_tab[mb_id]:
|
|
193
|
+
if c["kind"] == "query" and c["card_id"] in card_to_cell:
|
|
194
|
+
body_items.append((c["row"], card_to_cell[c["card_id"]]))
|
|
195
|
+
elif c["kind"] == "text":
|
|
196
|
+
md = next((m for m in md_cells_by_tab[mb_id] if m["row"] == c["row"]), None)
|
|
197
|
+
if md:
|
|
198
|
+
body_items.append((c["row"], md["cell_id"]))
|
|
199
|
+
body_items.sort(key=lambda x: x[0])
|
|
200
|
+
for _, cid in body_items:
|
|
201
|
+
rows.append({"columns": [{"start": 0, "end": 120,
|
|
202
|
+
"elements": [cell_element(cid)]}]})
|
|
203
|
+
|
|
204
|
+
layout_tabs.append({"name": tab_meta["name"], "rows": rows})
|
|
205
|
+
|
|
206
|
+
app_layout = doc.setdefault("appLayout", {})
|
|
207
|
+
app_layout["tabs"] = layout_tabs
|
|
208
|
+
app_layout["fullWidth"] = True
|
|
209
|
+
|
|
210
|
+
# CRITICAL: sort cells so INPUT comes before MARKDOWN before SQL.
|
|
211
|
+
# Otherwise SQL cells produce "Undefined variable(s)" at run time.
|
|
212
|
+
priority = {"INPUT": 0, "MARKDOWN": 1, "SQL": 2}
|
|
213
|
+
doc["cells"].sort(key=lambda c: priority.get(c.get("cellType"), 3))
|
|
214
|
+
|
|
215
|
+
with open(OUTPUT_YAML, "w") as f:
|
|
216
|
+
# allow_unicode preserves em-dashes; width=4096 prevents line wrapping.
|
|
217
|
+
# Hex's YAML parser breaks on both if these aren't set.
|
|
218
|
+
yaml.safe_dump(doc, f, allow_unicode=True, width=4096, sort_keys=False)
|
|
219
|
+
|
|
220
|
+
print(f"Wrote {OUTPUT_YAML}")
|
|
221
|
+
print(f" cells total: {len(doc['cells'])}")
|
|
222
|
+
print(f" layout tabs: {len(doc['appLayout']['tabs'])}")
|
|
223
|
+
print(f" cards mapped: {len(card_to_cell)}/{len(CARD_LABEL)}")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
if __name__ == "__main__":
|
|
227
|
+
main()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Template — copy and edit per migration.
|
|
3
|
+
# Creates SQL cells in a Hex project, one per .sql file in CELLS_DIR.
|
|
4
|
+
# Logs each create response to LOG.
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
PROJECT_ID="<paste-from-hex-project-create>"
|
|
8
|
+
CONN_ID="530b70b8-b300-43c9-9b3e-e4b98ded0379" # snowflake analytics
|
|
9
|
+
|
|
10
|
+
CELLS_DIR="/home/alex/pii-migrations/dashboard_<ID>/hex_cells"
|
|
11
|
+
LOG="/home/alex/pii-migrations/dashboard_<ID>/artifacts/cell_create_log.jsonl"
|
|
12
|
+
> "$LOG"
|
|
13
|
+
|
|
14
|
+
# filename → (label, output_dataframe_slug)
|
|
15
|
+
# Label must match what the build_layout.py CARD_LABEL map expects.
|
|
16
|
+
declare -A LABELS=(
|
|
17
|
+
# ["22130_App Shiftlogs Stg.sql"]="App Shiftlogs Stg"
|
|
18
|
+
# ...
|
|
19
|
+
)
|
|
20
|
+
declare -A DFS=(
|
|
21
|
+
# ["22130_App Shiftlogs Stg.sql"]="shift_logs"
|
|
22
|
+
# ...
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
for f in "$CELLS_DIR"/*.sql; do
|
|
26
|
+
base=$(basename "$f")
|
|
27
|
+
label="${LABELS[$base]:-$base}"
|
|
28
|
+
df="${DFS[$base]:-result}"
|
|
29
|
+
echo "→ Creating: $label (df=$df)"
|
|
30
|
+
src=$(cat "$f")
|
|
31
|
+
result=$(hex cell create "$PROJECT_ID" \
|
|
32
|
+
-t sql \
|
|
33
|
+
-s "$src" \
|
|
34
|
+
-l "$label" \
|
|
35
|
+
--data-connection-id "$CONN_ID" \
|
|
36
|
+
--output-dataframe "$df" \
|
|
37
|
+
--json 2>&1)
|
|
38
|
+
echo "$base|$label|$df|$result" >> "$LOG"
|
|
39
|
+
done
|
|
40
|
+
|
|
41
|
+
echo "Done. Log: $LOG"
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Parse a saved Metabase dashboard JSON into a structured inventory.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python3 inventory.py <path/to/dashboard_<id>.json>
|
|
7
|
+
|
|
8
|
+
Prints:
|
|
9
|
+
- Tab list (id, name, position)
|
|
10
|
+
- Dashboard parameters (slug, name, type, default)
|
|
11
|
+
- Per-card metadata (id, name, tab, viz type, query type, source table, parameter slugs)
|
|
12
|
+
- Text/instruction cards with content preview
|
|
13
|
+
|
|
14
|
+
Also writes:
|
|
15
|
+
- query_cards.json — list of dicts, one per query card
|
|
16
|
+
- text_cards.json — list of dicts, one per text card
|
|
17
|
+
"""
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main(path_str):
|
|
24
|
+
path = Path(path_str)
|
|
25
|
+
with open(path) as f:
|
|
26
|
+
d = json.load(f)
|
|
27
|
+
|
|
28
|
+
out_dir = path.parent
|
|
29
|
+
|
|
30
|
+
tabs = {t["id"]: t for t in d.get("tabs", [])}
|
|
31
|
+
print("=== TABS ===")
|
|
32
|
+
if tabs:
|
|
33
|
+
for tid, t in sorted(tabs.items(), key=lambda x: x[1].get("position", 0)):
|
|
34
|
+
print(f" tab {tid}: {t['name']} (pos={t.get('position')})")
|
|
35
|
+
else:
|
|
36
|
+
print(" (single page — no tabs)")
|
|
37
|
+
|
|
38
|
+
print("\n=== DASHBOARD PARAMETERS ===")
|
|
39
|
+
pid2slug = {}
|
|
40
|
+
for p in d.get("parameters", []):
|
|
41
|
+
slug = p.get("slug")
|
|
42
|
+
pid2slug[p["id"]] = slug
|
|
43
|
+
print(f" {slug}: name='{p.get('name')}' type={p.get('type')} default={p.get('default')}")
|
|
44
|
+
|
|
45
|
+
print("\n=== CARDS ===")
|
|
46
|
+
query_cards = []
|
|
47
|
+
text_cards = []
|
|
48
|
+
for dc in d.get("dashcards", []):
|
|
49
|
+
card = dc.get("card") or {}
|
|
50
|
+
cid = card.get("id")
|
|
51
|
+
tab_id = dc.get("dashboard_tab_id")
|
|
52
|
+
tab_name = tabs.get(tab_id, {}).get("name", "(no tabs)")
|
|
53
|
+
param_slugs = [pid2slug.get(m.get("parameter_id"), m.get("parameter_id"))
|
|
54
|
+
for m in (dc.get("parameter_mappings") or [])]
|
|
55
|
+
row, col = dc.get("row") or 0, dc.get("col") or 0
|
|
56
|
+
size_x, size_y = dc.get("size_x"), dc.get("size_y")
|
|
57
|
+
|
|
58
|
+
if not cid:
|
|
59
|
+
text = (dc.get("visualization_settings") or {}).get("text", "")
|
|
60
|
+
text_cards.append({
|
|
61
|
+
"tab_id": tab_id, "tab_name": tab_name,
|
|
62
|
+
"row": row, "col": col, "size_x": size_x, "size_y": size_y,
|
|
63
|
+
"text": text,
|
|
64
|
+
})
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
dq = card.get("dataset_query", {}) or {}
|
|
68
|
+
qtype = dq.get("type")
|
|
69
|
+
native_sql = dq.get("native", {}).get("query") if qtype == "native" else None
|
|
70
|
+
source_table = dq.get("query", {}).get("source-table") if qtype == "query" else None
|
|
71
|
+
|
|
72
|
+
query_cards.append({
|
|
73
|
+
"cid": cid,
|
|
74
|
+
"name": card.get("name"),
|
|
75
|
+
"tab_id": tab_id,
|
|
76
|
+
"tab_name": tab_name,
|
|
77
|
+
"display": card.get("display"),
|
|
78
|
+
"qtype": qtype,
|
|
79
|
+
"native_sql_len": len(native_sql) if native_sql else 0,
|
|
80
|
+
"source_table_id": source_table,
|
|
81
|
+
"param_slugs": param_slugs,
|
|
82
|
+
"row": row, "col": col, "size_x": size_x, "size_y": size_y,
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
# Sort by tab then row/col
|
|
86
|
+
tab_pos = {t["id"]: t.get("position", 0) for t in d.get("tabs", [])}
|
|
87
|
+
query_cards.sort(key=lambda c: (tab_pos.get(c["tab_id"], 0), c["row"], c["col"]))
|
|
88
|
+
text_cards.sort(key=lambda c: (tab_pos.get(c["tab_id"], 0), c["row"], c["col"]))
|
|
89
|
+
|
|
90
|
+
for c in query_cards:
|
|
91
|
+
kind = f"native(len={c['native_sql_len']})" if c["qtype"] == "native" else f"mbql(src_table={c['source_table_id']})"
|
|
92
|
+
display = c["display"] or ""
|
|
93
|
+
print(f" {c['cid']:>6} tab={c['tab_name']:40s} {display:8s} {kind:30s} params={c['param_slugs']} -- {c['name']}")
|
|
94
|
+
for c in text_cards:
|
|
95
|
+
snippet = c["text"].replace("\n", " ")[:80]
|
|
96
|
+
print(f" [TEXT] tab={c['tab_name']:40s} r={c['row']} c={c['col']} -- {snippet}")
|
|
97
|
+
|
|
98
|
+
# Summary
|
|
99
|
+
print("\n=== SUMMARY ===")
|
|
100
|
+
print(f" {len(query_cards)} query cards, {len(text_cards)} text cards across {len(tabs) or 1} tab(s)")
|
|
101
|
+
per_tab = {}
|
|
102
|
+
for c in query_cards + text_cards:
|
|
103
|
+
per_tab.setdefault(c["tab_name"], []).append(c)
|
|
104
|
+
for name, cards in per_tab.items():
|
|
105
|
+
q = sum(1 for c in cards if "cid" in c)
|
|
106
|
+
t = len(cards) - q
|
|
107
|
+
print(f" {name:40s} {q} query + {t} text")
|
|
108
|
+
|
|
109
|
+
with open(out_dir / "query_cards.json", "w") as f:
|
|
110
|
+
json.dump(query_cards, f, indent=2)
|
|
111
|
+
with open(out_dir / "text_cards.json", "w") as f:
|
|
112
|
+
json.dump(text_cards, f, indent=2)
|
|
113
|
+
print(f"\nWrote {out_dir/'query_cards.json'} and {out_dir/'text_cards.json'}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
if len(sys.argv) < 2:
|
|
118
|
+
print(__doc__)
|
|
119
|
+
sys.exit(1)
|
|
120
|
+
main(sys.argv[1])
|