@sitar_fiercer4c/skills 0.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 (50) hide show
  1. package/LICENSE +5 -0
  2. package/README.md +75 -0
  3. package/bin/install.js +45 -0
  4. package/package.json +29 -0
  5. package/skills/architecture-walkthrough/SKILL.md +223 -0
  6. package/skills/architecture-walkthrough/references/sections.md +29 -0
  7. package/skills/architecture-walkthrough/scripts/check_structure.py +200 -0
  8. package/skills/autotest-webapp-ui/SKILL.md +58 -0
  9. package/skills/backend-code-review/SKILL.md +386 -0
  10. package/skills/backend-code-review/references/report-format.md +333 -0
  11. package/skills/backend-code-review/scripts/list_routes.py +269 -0
  12. package/skills/backend-code-review/scripts/sweep.py +550 -0
  13. package/skills/backend-code-review/scripts/verify_citations.py +201 -0
  14. package/skills/be-brief/SKILL.md +18 -0
  15. package/skills/clarke-list-excel/SKILL.md +51 -0
  16. package/skills/clarke-list-excel/references/output-schema.md +125 -0
  17. package/skills/clarke-list-excel/scripts/clarke_common.py +251 -0
  18. package/skills/clarke-list-excel/scripts/clarke_extract.py +487 -0
  19. package/skills/clarke-list-excel/scripts/load_clarke.py +322 -0
  20. package/skills/clarke-list-excel/scripts/run_all.py +63 -0
  21. package/skills/datalab-api/SKILL.md +163 -0
  22. package/skills/datalab-api/references/parameters-and-payload.md +121 -0
  23. package/skills/datalab-api/references/table-selection.md +35 -0
  24. package/skills/datalab-api/scripts/datalab_tables.py +365 -0
  25. package/skills/find-test-seam/SKILL.md +41 -0
  26. package/skills/frontend-code-review/SKILL.md +247 -0
  27. package/skills/frontend-code-review-2/SKILL.md +192 -0
  28. package/skills/frontend-code-review-2/scripts/fetch_pr_comments.py +65 -0
  29. package/skills/frontend-code-review-2/scripts/render_report.py +139 -0
  30. package/skills/murtaza-breif/SKILL.md +143 -0
  31. package/skills/murtaza-breif/scripts/save_brief.py +128 -0
  32. package/skills/pdf-to-json/SKILL.md +42 -0
  33. package/skills/pdf-to-json/references/output-schema.md +168 -0
  34. package/skills/pdf-to-json/scripts/extract_figures.py +319 -0
  35. package/skills/pdf-to-json/scripts/load_mongo.py +287 -0
  36. package/skills/pdf-to-json/scripts/pdf_extract.py +1313 -0
  37. package/skills/record-api-traffic/SKILL.md +434 -0
  38. package/skills/record-api-traffic/references/reading-recordings.md +224 -0
  39. package/skills/record-api-traffic/scripts/check-schema.mjs +184 -0
  40. package/skills/record-api-traffic/scripts/dump-quotation.mjs +67 -0
  41. package/skills/record-api-traffic/scripts/dump-source-excel.mjs +75 -0
  42. package/skills/record-api-traffic/scripts/lib/repo.mjs +109 -0
  43. package/skills/record-api-traffic/scripts/preflight.py +528 -0
  44. package/skills/record-api-traffic/scripts/record-api-traffic.py +720 -0
  45. package/skills/refac-wrt-business-goal/SKILL.md +305 -0
  46. package/skills/refac-wrt-business-goal/references/critic.md +170 -0
  47. package/skills/system-resource-triage/SKILL.md +180 -0
  48. package/skills/system-resource-triage/scripts/reap.sh +116 -0
  49. package/skills/system-resource-triage/scripts/triage.sh +111 -0
  50. package/skills/using-git-worktrees/SKILL.md +167 -0
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env python3
2
+ """Resolve every file:LINE citation in a review against the source tree.
3
+
4
+ Prints the real source slice back for each citation so the claim riding on it can be
5
+ checked, not just the line number, and flags three failure modes:
6
+
7
+ NOT-FOUND path does not resolve, or the line is past end of file
8
+ AMBIGUOUS a bare filename matches more than one file in the tree
9
+ TRUNCATED-CONSTRUCT a cited range ends mid-construct (braces unbalanced across it),
10
+ so anything the finding claims is absent may sit just past it
11
+
12
+ Usage:
13
+ python3 verify_citations.py REPORT.md [REPORT.md ...] --root <backend>
14
+ python3 verify_citations.py REPORT.md --root <backend> --problems # flagged rows only
15
+ """
16
+
17
+ import argparse
18
+ import os
19
+ import re
20
+ import sys
21
+ from collections import defaultdict
22
+
23
+ CITATION = re.compile(
24
+ r"(?<![\w/.])"
25
+ r"((?:[A-Za-z0-9_.\-]+/)*[A-Za-z0-9_.\-]+\.(?:js|cjs|mjs|jsx|ts|tsx|json|env|yml|yaml))"
26
+ r":(\d+)(?:\s*[-–—]\s*(\d+))?"
27
+ )
28
+
29
+ SKIP_DIRS = {"node_modules", ".git", "dist", "build", "coverage", "__pycache__", ".next"}
30
+ CODE_EXT = (".js", ".cjs", ".mjs", ".jsx", ".ts", ".tsx")
31
+
32
+ # A finding that asserts something is ABSENT is only checkable over a complete construct: if
33
+ # the cited range stops mid-function, the thing claimed missing may sit on the next line. On a
34
+ # positive claim, citing a fragment is normal, so the same imbalance is not worth reporting.
35
+ NEGATION = re.compile(
36
+ r"\b(?:no|never|lacks?|lacking|missing|absent|without|none|nothing|un(?:guarded|validated"
37
+ r"|checked|handled|caught)|do(?:es)?\s+not|doesn't|is\s+not|are\s+not|fails?\s+to"
38
+ r"|neither|nor|omits?|omitted|skips?|bypasses)\b",
39
+ re.I,
40
+ )
41
+
42
+
43
+ def index_tree(root):
44
+ """Map every relative path, and every path suffix, to the files it can name."""
45
+ by_suffix = defaultdict(set)
46
+ for dirpath, dirnames, filenames in os.walk(root):
47
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
48
+ for fn in filenames:
49
+ full = os.path.join(dirpath, fn)
50
+ relpath = os.path.relpath(full, root)
51
+ parts = relpath.split(os.sep)
52
+ for i in range(len(parts)):
53
+ by_suffix["/".join(parts[i:])].add(relpath)
54
+ return by_suffix
55
+
56
+
57
+ def resolve(cited, by_suffix):
58
+ """Return (relpath, None) or (None, reason). Citations are usually partial paths."""
59
+ key = cited.lstrip("./")
60
+ hits = by_suffix.get(key)
61
+ if not hits:
62
+ return None, "NOT-FOUND"
63
+ if len(hits) > 1:
64
+ # An exact whole-path match beats suffix matches.
65
+ exact = {h for h in hits if h.replace(os.sep, "/") == key}
66
+ if len(exact) == 1:
67
+ return exact.pop(), None
68
+ return None, "AMBIGUOUS (" + ", ".join(sorted(hits)) + ")"
69
+ return next(iter(hits)), None
70
+
71
+
72
+ def strip_noncode(text):
73
+ """Blank out strings, template literals and comments so brace counting means something."""
74
+ out = []
75
+ i, n = 0, len(text)
76
+ while i < n:
77
+ c = text[i]
78
+ if c == "/" and i + 1 < n and text[i + 1] == "/":
79
+ while i < n and text[i] != "\n":
80
+ i += 1
81
+ elif c == "/" and i + 1 < n and text[i + 1] == "*":
82
+ i += 2
83
+ while i + 1 < n and not (text[i] == "*" and text[i + 1] == "/"):
84
+ if text[i] == "\n":
85
+ out.append("\n")
86
+ i += 1
87
+ i += 2
88
+ elif c in "\"'`":
89
+ quote = c
90
+ i += 1
91
+ while i < n and text[i] != quote:
92
+ if text[i] == "\\":
93
+ i += 1
94
+ elif text[i] == "\n":
95
+ out.append("\n")
96
+ i += 1
97
+ i += 1
98
+ else:
99
+ out.append(c)
100
+ i += 1
101
+ return "".join(out)
102
+
103
+
104
+ def brace_delta(slice_text):
105
+ code = strip_noncode(slice_text)
106
+ return (
107
+ code.count("{") - code.count("}"),
108
+ code.count("(") - code.count(")"),
109
+ code.count("[") - code.count("]"),
110
+ )
111
+
112
+
113
+ def check(report, root, by_suffix, problems_only):
114
+ with open(report, encoding="utf-8", errors="replace") as fh:
115
+ report_lines = fh.readlines()
116
+
117
+ seen = set()
118
+ rows = []
119
+ for lineno, text in enumerate(report_lines, 1):
120
+ for m in CITATION.finditer(text):
121
+ cited, start, end = m.group(1), int(m.group(2)), m.group(3)
122
+ end = int(end) if end else start
123
+ key = (cited, start, end)
124
+ if key in seen:
125
+ continue
126
+ seen.add(key)
127
+ rows.append((lineno, cited, start, end, bool(NEGATION.search(text))))
128
+
129
+ results = []
130
+ for report_line, cited, start, end, negated in rows:
131
+ relpath, reason = resolve(cited, by_suffix)
132
+ if reason:
133
+ results.append((report_line, cited, start, end, relpath, [], [reason]))
134
+ continue
135
+
136
+ with open(os.path.join(root, relpath), encoding="utf-8", errors="replace") as fh:
137
+ src = fh.readlines()
138
+
139
+ flags = []
140
+ if start < 1 or start > len(src):
141
+ flags.append(f"NOT-FOUND (file has {len(src)} lines)")
142
+ results.append((report_line, cited, start, end, relpath, [], flags))
143
+ continue
144
+ if end > len(src):
145
+ flags.append(f"RANGE-PAST-EOF (file has {len(src)} lines)")
146
+
147
+ body = src[start - 1:min(end, len(src))]
148
+ if negated and end > start and relpath.endswith(CODE_EXT):
149
+ unbal = [f"{n}{d:+d}" for n, d in zip(("{}", "()", "[]"), brace_delta("".join(body))) if d]
150
+ if unbal:
151
+ flags.append(
152
+ "TRUNCATED-CONSTRUCT (" + ", ".join(unbal) + ") — this row claims an "
153
+ "absence, so the range must span the whole construct"
154
+ )
155
+
156
+ results.append((report_line, cited, start, end, relpath, body, flags))
157
+
158
+ return results, seen
159
+
160
+
161
+ def render(report, results, problems_only):
162
+ print(f"\n=== {os.path.basename(report)} ===")
163
+ bad = 0
164
+ for report_line, cited, start, end, relpath, body, flags in results:
165
+ if flags:
166
+ bad += 1
167
+ elif problems_only:
168
+ continue
169
+
170
+ span = f"{start}" if start == end else f"{start}-{end}"
171
+ target = f" -> {relpath}" if relpath else ""
172
+ print(f"\n[{'FLAG' if flags else 'OK'}] {cited}:{span}{target} (report line {report_line})")
173
+ for f in flags:
174
+ print(f" !! {f}")
175
+ for i, src_line in enumerate(body, start):
176
+ print(f" {i:>5} | {src_line.rstrip()}")
177
+ return bad
178
+
179
+
180
+ def main():
181
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
182
+ ap.add_argument("reports", nargs="+")
183
+ ap.add_argument("--root", required=True, help="backend root the citations are relative to")
184
+ ap.add_argument("--problems", action="store_true", help="print only flagged citations")
185
+ args = ap.parse_args()
186
+
187
+ root = os.path.abspath(args.root)
188
+ by_suffix = index_tree(root)
189
+
190
+ total_bad = total = 0
191
+ for report in args.reports:
192
+ results, seen = check(report, root, by_suffix, args.problems)
193
+ total_bad += render(report, results, args.problems)
194
+ total += len(seen)
195
+
196
+ print(f"\n{'=' * 60}\n{total} citations checked, {total_bad} flagged.")
197
+ return 1 if total_bad else 0
198
+
199
+
200
+ if __name__ == "__main__":
201
+ sys.exit(main())
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: be-brief
3
+ description: User invoked only
4
+ ---
5
+
6
+ # Intent
7
+ User is trying to understand OR problem solve something on a high level
8
+
9
+ # Brief
10
+ To make an answer brief, write it as a 30-60 word sentence. For technical things, use 10-15 word bullet points.
11
+
12
+ # User tech stack
13
+ ## Good proficiency
14
+ Python, SQL, Linux, relational database, NoSQL database, System design, data flow, ETL pipeline
15
+ ## Medium proficiency
16
+ Spark, Java, Bash, Infrastructure as Code, AWS, Claude code, CLI
17
+ ## Bad proficiency
18
+ Frontend, Full stack, JavaScript, Node, NPM, Vercel, Render
@@ -0,0 +1,51 @@
1
+ ---
2
+ name: clarke-list-excel
3
+ description: Use and invoke
4
+ ---
5
+
6
+ # Clarke List Excel Skill
7
+
8
+ ## Goal
9
+
10
+ Extract Clarke XLSX → JSON/Markdown → map images to Part No → upload to S3 → upsert MongoDB `clarke`.
11
+
12
+ ## Use When
13
+
14
+ Use for Clarke price-list extraction, re-import, refresh, product/image extraction, S3 upload, or MongoDB loading.
15
+
16
+ ## Workflow
17
+
18
+ 1. Read values/images with openpyxl and table/image spans with Datalab.
19
+ 2. Detect products by valid Part No; identify category/sub-category headings from formatting.
20
+ 3. Map each image to every product row covered by its Datalab `Picture` bbox.
21
+ 4. Stop spans at headings; for overlapping spans, nearest anchor above wins.
22
+ 5. Use nearest-row matching only within 3 rows when no span match exists.
23
+ 6. Upload valid images first, then upsert products into MongoDB.
24
+ 7. Validate the database write and report all important counts/issues.
25
+
26
+ ## Commands
27
+
28
+ ```bash
29
+ python scripts/run_all.py "<xlsx>" --out-dir /home/dev/workspaces/murtaza-workspaces/clarke-products
30
+ python scripts/clarke_extract.py "<xlsx>" --out-dir <dir>
31
+ python scripts/load_clarke.py <dir>/clarke-products.json
32
+ ```
33
+
34
+ ## Rules
35
+
36
+ * Datalab = image coordinates/spans; openpyxl = values and original image bytes.
37
+ * Read columns by position, never by printed header names.
38
+ * Store images at `products/catalog/clarke/<part-no>.<ext>` and upsert by unique `partNo`.
39
+ * Preserve `supplier: "Clarke"` and `source`; run `--check` before writes.
40
+ * Never use grid indexes/equal row bands, treat headings as products, or silently drop rows/issues.
41
+ * Keep duplicates, missing prices, unmatched images, held-back rows and unsupported formats visible.
42
+ * EMF uploads require `--upload-unrenderable`; re-runs must be idempotent.
43
+ * Never overwrite another catalogue on collection clashes.
44
+
45
+ ## Validation
46
+
47
+ Report product/duplicate/held-back counts, image coverage, span vs nearest matches, overridden/unmatched images, truncated spans, missing prices, unsupported formats, and Datalab/openpyxl anchor agreement.
48
+
49
+ ## Wrong Tool
50
+
51
+ UKEN PDF → `uken-data-export`; other PDFs → `pdf-to-json`; unknown/general spreadsheets → `datalab-api`.
@@ -0,0 +1,125 @@
1
+ # What the files hold
2
+
3
+ Read this when you need a specific field — what it means, when it is null, and
4
+ which numbers are safe to quote. Figures are from the 2023 Clarke list.
5
+
6
+ ## `clarke-products.json`
7
+
8
+ ```
9
+ source where it came from
10
+ summary counts, and everything that did not come through
11
+ products one entry per product row, in sheet order
12
+ rowsHeldBack rows that looked like products and were not
13
+ ```
14
+
15
+ ### `source`
16
+
17
+ | field | example | note |
18
+ |---|---|---|
19
+ | `file` | `CLARKE PRICE LIST-2023.xlsx` | |
20
+ | `path` | absolute path | what was actually read |
21
+ | `sheet` | `Sheet1` | first worksheet unless `--sheet` said otherwise |
22
+ | `headerRow` | `8` | the row printing `PART NO`; rows above are a title block |
23
+ | `tableRange` | `A5:G2035` | Datalab's view of the table's extent, null with `--no-datalab` |
24
+ | `rows`, `columns` | `2035`, `8` | the sheet's extent, not the table's |
25
+
26
+ ### `summary`
27
+
28
+ | field | 2023 file | what it tells you |
29
+ |---|---|---|
30
+ | `products` | 1558 | rows that parsed as products. **Not** the document count — see `duplicate_part_numbers` |
31
+ | `categories` | 26 | merged 14pt headings |
32
+ | `subCategories` | 193 | unmerged 10pt headings |
33
+ | `image_placements` | 249 | photos in the sheet, including the logo |
34
+ | `products_with_image` | 986 | every product a photo's span covers, not just the first |
35
+ | `products_without_image` | 572 | the document, not a failure |
36
+ | `distinct_images` | 204 | far fewer than 986: one photo illustrates a whole group |
37
+ | `media_files_in_workbook` | 216 | everything in `xl/media`, used or not |
38
+ | `images_matched_by_span` | 986 | confident: the photo's own extent covered the row |
39
+ | `images_matched_by_nearest` | 0 | inferred: it covered no product row. Any rise is a warning |
40
+ | `images_spanning_multiple_products` | 177 | placements attached to more than one product |
41
+ | `products_sharing_a_spanned_image` | 926 | products carrying a group photo rather than their own |
42
+ | `spans_truncated_at_heading` | 32 | spans cut at a sub-category row instead of running into the next group |
43
+ | `images_overridden` | 11 | placements every row of which a nearer-anchored photo won |
44
+ | `images_unmatched` | 1 | attached to nothing. One is expected — the row-1 logo |
45
+ | `unsupported_image_formats` | `["emf"]` | present in the workbook; not uploaded by default |
46
+ | `products_without_price` | 1 | kept with a null price |
47
+ | `rows_held_back` | 2 | see `rowsHeldBack` |
48
+ | `duplicate_part_numbers` | `{HS20C: [420, 1150], HS24C: [421, 1153]}` | part number → the rows printing it. These collapse on upsert |
49
+ | `cross_check` | see below | the completeness check |
50
+
51
+ ### `summary.cross_check`
52
+
53
+ Datalab and openpyxl each report where every photo is anchored, by different
54
+ code paths. This is where they are compared.
55
+
56
+ | field | 2023 file | |
57
+ |---|---|---|
58
+ | `ran` | `true` | false under `--no-datalab`, and then nothing below exists |
59
+ | `datalab_pictures` | 249 | |
60
+ | `openpyxl_placements` | 249 | |
61
+ | `anchor_rows_agree` | `true` | **the number to quote.** False means photos may be on the wrong products |
62
+ | `rows_only_in_datalab` | `[]` | photos openpyxl could not read — likely a format it drops |
63
+ | `rows_only_in_openpyxl` | `[]` | photos Datalab did not report |
64
+ | `table_range`, `table_blocks` | `A5:G2035`, 1 | more than one table block means the sheet segmented unexpectedly |
65
+
66
+ ### `products[]`
67
+
68
+ | field | example | null when |
69
+ |---|---|---|
70
+ | `partNo` | `CP6CL` | never — it is the key |
71
+ | `partNoRaw` | `CP6CL` | never; differs from `partNo` only where the sheet printed odd spacing |
72
+ | `description` | `COMBINATION PLIERS 6"` | never — a row without one is held back |
73
+ | `category` | `PLIERS` | products appearing before any heading |
74
+ | `subCategory` | `COMBINATION PLIER` | the category has no sub-headings |
75
+ | `pktQty`, `ctnQty` | `6`, `60` | the cell is blank or not numeric |
76
+ | `price` | `8.0` | the sheet printed no price (1 row) |
77
+ | `priceLabel` | `TRADER Price` | never — the tier name, kept so the number is not bare |
78
+ | `currency` | `AED` | never |
79
+ | `supplier` | `Clarke` | never — this is what separates the two catalogues sharing the collection |
80
+ | `sheetRow` | `14` | never — the row in the *sheet*, not in Datalab's grid |
81
+ | `imageSha256` | `760cd2a2…` | the product has no photo (572 of them) |
82
+ | `imageFormat` | `jpeg` | as above |
83
+ | `imageMatch` | `span` \| `nearest` | as above. `nearest` is inference — see SKILL.md |
84
+ | `imageAnchorRow` | `14` | as above. The top of the photo, which may be many rows above `sheetRow` |
85
+ | `imageSpanRows` | `[166, 185]` | as above. The rows the photo covers; every product in them carries it |
86
+ | `imageShared` | `true` | absent unless the photo covers more than one product — a group photo |
87
+
88
+ ### `rowsHeldBack[]`
89
+
90
+ `{row, reason, partNo, description}`. Two reasons occur: `missing part number
91
+ or description`, and `part number is not code-like` — prose in column A, which
92
+ is a heading that lost its formatting. These are listed rather than dropped
93
+ because the count of what did not load is the number worth seeing.
94
+
95
+ ## `images/`
96
+
97
+ One file per distinct photo, named `<sha256>.<format>`, written beside the
98
+ JSON. These are the **original bytes out of the xlsx zip**, not Datalab's
99
+ re-encodings, so they are reproducible across runs. `load_clarke.py` reads this
100
+ directory; stage one rewrites it, so re-run the two together.
101
+
102
+ ## `clarke-products.md`
103
+
104
+ The same data rendered to read: a heading per category breadcrumb, then a table
105
+ of part number, description, pkt, ctn, price and the first 8 characters of the
106
+ photo hash. For eyeballing whether the grouping and the photo mapping came out
107
+ right — the JSON is what anything downstream should read.
108
+
109
+ ## The MongoDB document
110
+
111
+ `clarke`, upserted on `partNo` (unique index). Same fields as
112
+ `products[]` minus the image internals, plus:
113
+
114
+ | field | example | |
115
+ |---|---|---|
116
+ | `source` | `CLARKE PRICE LIST-2023.xlsx` | which file this came from |
117
+ | `imageKey` | `products/catalog/clarke/CP6CL.jpeg` | S3 key, no leading slash; null when the product has no photo |
118
+
119
+ | `imageShared` | `true` | the photo covers several products; this is the group's picture, not this part's alone |
120
+
121
+ `imageSha256`, `imageFormat`, `imageAnchorRow` and `imageSpanRows` stay in the
122
+ JSON — they are extraction diagnostics, not product data. `imageMatch` and
123
+ `imageShared` are carried through, because a downstream reader should be able
124
+ to tell an inferred photo from a confident one, and a group photo from a
125
+ product's own.
@@ -0,0 +1,251 @@
1
+ #!/usr/bin/env python3
2
+ """Shared plumbing for the Clarke export: credentials, the Datalab call, the workbook.
3
+
4
+ Two readers, deliberately. Datalab segments the sheet and reports where the
5
+ pictures are anchored; openpyxl reads the cells. Neither is redundant:
6
+
7
+ Datalab's table HTML drops empty rows, so its grid is 1,783 rows against the
8
+ sheet's 2,031. Grid index is therefore *not* sheet row, and anything that maps
9
+ pictures onto products through the grid drifts by hundreds of rows by the
10
+ bottom of the file. openpyxl addresses cells by their real coordinates, so it
11
+ is what the parser reads values from.
12
+
13
+ What Datalab gives that openpyxl cannot is an independent opinion. Its Picture
14
+ bboxes are cell coordinates on spreadsheets, and `floor(y0)` is the anchoring
15
+ row — the same number openpyxl reports from the drawing anchor, arrived at
16
+ through an entirely different code path. Agreement between the two is the
17
+ completeness check this skill quotes, and it is worth more than either reader's
18
+ own confidence.
19
+
20
+ The credential search is a fixed default rather than a walk up from the input
21
+ file, for the same reason as the sibling uken-data-export skill: the price list
22
+ sits in the workspace root and the .env is in Backend/ of a different repo, so
23
+ walking up finds nothing. Guessing a path and half-working is worse than saying
24
+ plainly which file was read.
25
+ """
26
+
27
+ import hashlib
28
+ import json
29
+ import os
30
+ import time
31
+ import zipfile
32
+ from pathlib import Path
33
+
34
+ DEFAULT_BASE_URL = "https://www.datalab.to/api/v1"
35
+ POLL_SECONDS = 2
36
+ POLL_ATTEMPTS = 300 # 10 minutes; a one-sheet workbook lands in well under one
37
+
38
+ DEFAULT_ENV = Path(
39
+ "/home/dev/workspaces/murtaza-workspaces/murtaza-hotel-project/Backend/.env")
40
+
41
+ XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
42
+ MIMES = {
43
+ ".xlsx": XLSX_MIME,
44
+ ".xlsm": "application/vnd.ms-excel.sheet.macroEnabled.12",
45
+ ".xls": "application/vnd.ms-excel",
46
+ ".ods": "application/vnd.oasis.opendocument.spreadsheet",
47
+ ".csv": "text/csv",
48
+ }
49
+
50
+
51
+ # ------------------------------------------------------------ credentials
52
+
53
+ def load_env(explicit: str | None = None) -> tuple[dict, Path | None]:
54
+ """Return (values, path_read). Real environment variables always win.
55
+
56
+ Returning the path matters: every stage prints which .env it used, because
57
+ "1,558 products loaded" is not a useful sentence until you know which
58
+ cluster it wrote them to.
59
+ """
60
+ candidates = []
61
+ if explicit:
62
+ candidates.append(Path(explicit).expanduser())
63
+ elif os.environ.get("CLARKE_ENV_FILE"):
64
+ candidates.append(Path(os.environ["CLARKE_ENV_FILE"]).expanduser())
65
+ else:
66
+ candidates.append(DEFAULT_ENV)
67
+
68
+ values, used = {}, None
69
+ for path in candidates:
70
+ if not path.is_file():
71
+ continue
72
+ used = path
73
+ for line in path.read_text(errors="replace").splitlines():
74
+ line = line.strip()
75
+ if not line or line.startswith("#") or "=" not in line:
76
+ continue
77
+ key, value = line.split("=", 1)
78
+ values[key.strip()] = value.strip().strip('"').strip("'")
79
+ break
80
+
81
+ if explicit and used is None:
82
+ raise SystemExit(f"--env {explicit} does not exist")
83
+ return values, used
84
+
85
+
86
+ def credential(values: dict, *keys: str) -> str | None:
87
+ """First of `keys` set in the real environment, else in the .env."""
88
+ for key in keys:
89
+ if os.environ.get(key):
90
+ return os.environ[key]
91
+ for key in keys:
92
+ if values.get(key):
93
+ return values[key]
94
+ return None
95
+
96
+
97
+ def require(values: dict, *keys: str) -> str:
98
+ got = credential(values, *keys)
99
+ if not got:
100
+ raise SystemExit(
101
+ f"{keys[0]} not found. Set it in the environment or in the .env "
102
+ f"(default {DEFAULT_ENV}); point elsewhere with --env.")
103
+ return got
104
+
105
+
106
+ # --------------------------------------------------------------- datalab
107
+
108
+ def strip_images(node):
109
+ """Drop base64 payloads before caching.
110
+
111
+ On this workbook they are ~98% of the response and none of them are used:
112
+ the pictures uploaded to S3 are the original media out of the xlsx zip, not
113
+ Datalab's re-encodings. The `Picture` blocks themselves are kept, because
114
+ their bboxes are the cross-check.
115
+ """
116
+ if isinstance(node, dict):
117
+ node.pop("images", None)
118
+ for value in node.values():
119
+ strip_images(value)
120
+ elif isinstance(node, list):
121
+ for value in node:
122
+ strip_images(value)
123
+
124
+
125
+ def convert(path: Path, key: str, cache_dir: Path, mode: str = "fast",
126
+ refresh: bool = False, base: str = DEFAULT_BASE_URL) -> dict:
127
+ """Submit the workbook to Datalab and poll until it finishes.
128
+
129
+ `mode` is `fast` because on spreadsheets it changes nothing — fast,
130
+ balanced and accurate return identical block trees at identical cost. The
131
+ sibling datalab-api skill documents that experiment.
132
+
133
+ Cached on the resolved path plus the options that change the request, so
134
+ re-running the parser is free. The conversion is the slow, paid and
135
+ non-deterministic part; the parse is none of those.
136
+ """
137
+ import requests
138
+
139
+ cache_dir.mkdir(parents=True, exist_ok=True)
140
+ digest = hashlib.sha256(
141
+ f"{path.resolve()}|{path.stat().st_size}|{mode}".encode()).hexdigest()[:10]
142
+ cached = cache_dir / f"{path.stem}.{mode}.{digest}.json"
143
+ if cached.is_file() and not refresh:
144
+ return json.loads(cached.read_text())
145
+
146
+ mime = MIMES.get(path.suffix.lower(), XLSX_MIME)
147
+ with path.open("rb") as fh:
148
+ response = requests.post(
149
+ f"{base}/convert",
150
+ headers={"X-API-Key": key},
151
+ files={"file": (path.name, fh, mime)},
152
+ data={"output_format": "json", "mode": mode},
153
+ timeout=300)
154
+ if response.status_code != 200:
155
+ raise SystemExit(f"Datalab rejected the upload: "
156
+ f"{response.status_code} {response.text[:400]}")
157
+ check_url = response.json().get("request_check_url")
158
+ if not check_url:
159
+ raise SystemExit(f"no request_check_url in response: {response.text[:400]}")
160
+
161
+ for _ in range(POLL_ATTEMPTS):
162
+ time.sleep(POLL_SECONDS)
163
+ poll = requests.get(check_url, headers={"X-API-Key": key}, timeout=120).json()
164
+ if poll.get("status") == "complete":
165
+ if not poll.get("success", True):
166
+ raise SystemExit(f"Datalab failed: {poll.get('error')}")
167
+ strip_images(poll)
168
+ cached.write_text(json.dumps(poll))
169
+ return poll
170
+ raise SystemExit("Datalab did not finish within the polling window")
171
+
172
+
173
+ def walk(node, out=None):
174
+ """Every block in the response, in reading order."""
175
+ out = [] if out is None else out
176
+ if isinstance(node, dict):
177
+ if "block_type" in node:
178
+ out.append(node)
179
+ for child in node.get("children") or []:
180
+ walk(child, out)
181
+ elif isinstance(node, list):
182
+ for child in node:
183
+ walk(child, out)
184
+ return out
185
+
186
+
187
+ def picture_rows(response: dict) -> list[dict]:
188
+ """Datalab's opinion of where each picture sits, as 1-indexed sheet rows.
189
+
190
+ On spreadsheets a Picture bbox is [col0, row0, col1, row1] in *cell*
191
+ coordinates, where the integer part is the 1-indexed cell and the fraction
192
+ is the offset within it. `[1.74, 14.48, 2.69, 16.17]` is a photo anchored
193
+ in B14 hanging down into row 16.
194
+
195
+ Carrying the PDF convention here instead — pixels — puts every coordinate
196
+ out by three orders of magnitude, and the failure is quiet because the
197
+ numbers still look like plausible row indices.
198
+ """
199
+ out = []
200
+ for block in walk(response.get("json") or response):
201
+ if block.get("block_type") != "Picture":
202
+ continue
203
+ bbox = block.get("bbox") or []
204
+ if len(bbox) < 4:
205
+ continue
206
+ html = block.get("html") or ""
207
+ src = None
208
+ if 'src="' in html:
209
+ src = html.split('src="', 1)[1].split('"', 1)[0]
210
+ out.append({
211
+ "row_from": int(bbox[1]),
212
+ "row_to": int(bbox[3]),
213
+ "col": int(bbox[0]),
214
+ "src": src,
215
+ })
216
+ return out
217
+
218
+
219
+ def table_blocks(response: dict) -> list[dict]:
220
+ """Table blocks with their cell range. Datalab sometimes types a plain
221
+ lead-in table as TableOfContents, so match that too."""
222
+ return [b for b in walk(response.get("json") or response)
223
+ if b.get("block_type") in ("Table", "TableOfContents")]
224
+
225
+
226
+ def excel_range(bbox) -> str:
227
+ """[1, 5, 7, 2035] -> 'A5:G2035'. 1-indexed and inclusive."""
228
+ def column(index: int) -> str:
229
+ name = ""
230
+ while index > 0:
231
+ index, remainder = divmod(index - 1, 26)
232
+ name = chr(65 + remainder) + name
233
+ return name or "A"
234
+ c0, r0, c1, r1 = (int(v) for v in bbox[:4])
235
+ return f"{column(c0)}{r0}:{column(c1)}{r1}"
236
+
237
+
238
+ # ----------------------------------------------------------- the workbook
239
+
240
+ def media_index(path: Path) -> dict:
241
+ """Every file under xl/media, archive name -> bytes.
242
+
243
+ Read from the zip rather than through openpyxl because openpyxl silently
244
+ drops formats it cannot decode — this workbook holds one EMF, and openpyxl
245
+ warns and moves on. A picture missing from the upload because a library
246
+ could not parse it should be reported, not lost.
247
+ """
248
+ with zipfile.ZipFile(path) as archive:
249
+ return {name: archive.read(name)
250
+ for name in archive.namelist()
251
+ if name.startswith("xl/media/")}