@clipboard-health/ai-rules 2.29.2 → 2.29.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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])