sp2react 0.1.0__py3-none-any.whl

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 (79) hide show
  1. sp2react/__init__.py +18 -0
  2. sp2react/agents/__init__.py +0 -0
  3. sp2react/agents/core.py +146 -0
  4. sp2react/agents/prompts/dbmap.md +28 -0
  5. sp2react/agents/prompts/fix.md +11 -0
  6. sp2react/agents/prompts/handler.md +22 -0
  7. sp2react/agents/prompts/page.md +40 -0
  8. sp2react/agents/prompts/review.md +21 -0
  9. sp2react/agents/prompts/spec.md +79 -0
  10. sp2react/agents/tasks.py +374 -0
  11. sp2react/analysis/__init__.py +0 -0
  12. sp2react/analysis/aspx.py +254 -0
  13. sp2react/analysis/behaviors.py +357 -0
  14. sp2react/analysis/fields.py +152 -0
  15. sp2react/analysis/inventory.py +314 -0
  16. sp2react/analysis/jsscan.py +330 -0
  17. sp2react/checks.py +105 -0
  18. sp2react/cli.py +188 -0
  19. sp2react/config.py +172 -0
  20. sp2react/dbmap.py +245 -0
  21. sp2react/llm/__init__.py +0 -0
  22. sp2react/llm/providers.py +219 -0
  23. sp2react/migrate.py +255 -0
  24. sp2react/net.py +60 -0
  25. sp2react/pipeline.py +583 -0
  26. sp2react/scaffold/__init__.py +0 -0
  27. sp2react/scaffold/generate.py +279 -0
  28. sp2react/scaffold/rule_vectors.json +3826 -0
  29. sp2react/scaffold/templates/backend/app/__init__.py +0 -0
  30. sp2react/scaffold/templates/backend/app/api.py +293 -0
  31. sp2react/scaffold/templates/backend/app/auth.py +33 -0
  32. sp2react/scaffold/templates/backend/app/custom/__init__.py +22 -0
  33. sp2react/scaffold/templates/backend/app/main.py +47 -0
  34. sp2react/scaffold/templates/backend/app/odata.py +185 -0
  35. sp2react/scaffold/templates/backend/app/rules.py +256 -0
  36. sp2react/scaffold/templates/backend/app/sp_repo.py +159 -0
  37. sp2react/scaffold/templates/backend/app/sql_repo.py +437 -0
  38. sp2react/scaffold/templates/backend/app/store.py +224 -0
  39. sp2react/scaffold/templates/backend/requirements.txt +8 -0
  40. sp2react/scaffold/templates/backend/tests/conftest.py +10 -0
  41. sp2react/scaffold/templates/backend/tests/test_rule_vectors.py +38 -0
  42. sp2react/scaffold/templates/frontend/index.html +12 -0
  43. sp2react/scaffold/templates/frontend/package.json +24 -0
  44. sp2react/scaffold/templates/frontend/scripts/check-rules.ts +23 -0
  45. sp2react/scaffold/templates/frontend/src/api.ts +88 -0
  46. sp2react/scaffold/templates/frontend/src/components/sp/Banner.tsx +4 -0
  47. sp2react/scaffold/templates/frontend/src/components/sp/DataTable.tsx +38 -0
  48. sp2react/scaffold/templates/frontend/src/components/sp/Form.tsx +252 -0
  49. sp2react/scaffold/templates/frontend/src/components/sp/GenericPage.tsx +28 -0
  50. sp2react/scaffold/templates/frontend/src/components/sp/Layout.tsx +54 -0
  51. sp2react/scaffold/templates/frontend/src/components/sp/LegacyHtml.tsx +9 -0
  52. sp2react/scaffold/templates/frontend/src/components/sp/ListView.tsx +59 -0
  53. sp2react/scaffold/templates/frontend/src/components/sp/Tiles.tsx +21 -0
  54. sp2react/scaffold/templates/frontend/src/components/sp/fields.tsx +132 -0
  55. sp2react/scaffold/templates/frontend/src/custom/types.ts +10 -0
  56. sp2react/scaffold/templates/frontend/src/main.tsx +14 -0
  57. sp2react/scaffold/templates/frontend/src/runtime/format.ts +36 -0
  58. sp2react/scaffold/templates/frontend/src/runtime/rules.ts +225 -0
  59. sp2react/scaffold/templates/frontend/src/runtime/spec.ts +102 -0
  60. sp2react/scaffold/templates/frontend/src/runtime/user.tsx +21 -0
  61. sp2react/scaffold/templates/frontend/src/screens/LegacyRedirect.tsx +27 -0
  62. sp2react/scaffold/templates/frontend/src/screens/ListFormScreen.tsx +33 -0
  63. sp2react/scaffold/templates/frontend/src/styles/sp.css +95 -0
  64. sp2react/scaffold/templates/frontend/src/vite-env.d.ts +1 -0
  65. sp2react/scaffold/templates/frontend/tsconfig.json +18 -0
  66. sp2react/scaffold/templates/frontend/vite.config.ts +7 -0
  67. sp2react/sharepoint/__init__.py +0 -0
  68. sp2react/sharepoint/client.py +210 -0
  69. sp2react/sharepoint/extract.py +217 -0
  70. sp2react/spec/__init__.py +0 -0
  71. sp2react/spec/base.py +80 -0
  72. sp2react/spec/coverage.py +191 -0
  73. sp2react/spec/model.py +133 -0
  74. sp2react-0.1.0.dist-info/METADATA +238 -0
  75. sp2react-0.1.0.dist-info/RECORD +79 -0
  76. sp2react-0.1.0.dist-info/WHEEL +5 -0
  77. sp2react-0.1.0.dist-info/entry_points.txt +2 -0
  78. sp2react-0.1.0.dist-info/licenses/LICENSE +21 -0
  79. sp2react-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,357 @@
1
+ """Find what custom JavaScript does to a SharePoint form/page, with source line references.
2
+
3
+ Every finding is a Behavior. Behaviors with level "must" have to be accounted for in the spec
4
+ (covered by a rule/endpoint/component, or explicitly listed as not carried, with a reason);
5
+ "info" findings are context for the agents.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import asdict, dataclass, field
11
+ from typing import Optional
12
+ from urllib.parse import unquote
13
+
14
+ from .jsscan import Scan, concat_expressions, enclosing_call, if_condition, object_props, scan, template
15
+
16
+ MUST = "must"
17
+ INFO = "info"
18
+
19
+ LIBRARY_HINTS = re.compile(
20
+ r"(jquery|spservices|bootstrap|moment|datatables|knockout|underscore|lodash|angular|select2|chosen|handlebars|"
21
+ r"require|es5-shim|json2|popper|tether|fontawesome|font-awesome|chart(\.min)?\.js|d3(\.min)?\.js|vue|react)[\w.\-]*\.js$", re.I)
22
+ LIBRARY_HEADER = re.compile(r"^\s*/\*[!*]?\s*(jQuery|SPServices|Bootstrap|Moment|DataTables|Knockout|Underscore|lodash|AngularJS|Select2|@license)", re.I | re.M)
23
+ SHAREPOINT_BUILTIN = re.compile(r"(^|/)(_layouts|_vti_bin)/|scriptresource\.axd|webresource\.axd|^(sp|init|core|clienttemplates|clientforms|clientpeoplepicker|autofill|sp\.runtime|sp\.core|sp\.ui\.\w+|callout|inplview|menu|strings|initstrings|blank|mquery|sp\.init|ms\.rte|SP\.UI\.Dialog)\.js$", re.I)
24
+
25
+
26
+ def classify_script(url: str, text: Optional[str] = None) -> str:
27
+ """sharepoint | library | external | custom"""
28
+ path = url.split("?")[0]
29
+ name = path.rsplit("/", 1)[-1]
30
+ if SHAREPOINT_BUILTIN.search(path) or SHAREPOINT_BUILTIN.search(name):
31
+ return "sharepoint"
32
+ if LIBRARY_HINTS.search(name) or (text and LIBRARY_HEADER.search(text[:600])):
33
+ return "library"
34
+ if path.startswith(("http://", "https://", "//")):
35
+ return "external"
36
+ return "custom"
37
+
38
+
39
+ @dataclass
40
+ class FieldRef:
41
+ list: str
42
+ name: str
43
+ label: str
44
+
45
+
46
+ @dataclass
47
+ class Behavior:
48
+ id: str
49
+ kind: str
50
+ level: str
51
+ file: str
52
+ line: int
53
+ code: str
54
+ function: Optional[str] = None
55
+ presave: bool = False
56
+ fields: list[FieldRef] = field(default_factory=list)
57
+ message: Optional[str] = None
58
+ condition: Optional[str] = None
59
+ detail: dict = field(default_factory=dict)
60
+
61
+ def to_dict(self) -> dict:
62
+ d = asdict(self)
63
+ return {k: v for k, v in d.items() if v not in (None, [], {}, False) or k in ("id", "kind", "level", "file", "line")}
64
+
65
+
66
+ class FieldIndex:
67
+ """Resolve string literals to list fields by display name or internal name."""
68
+
69
+ def __init__(self):
70
+ self.by_label: dict[str, list[FieldRef]] = {}
71
+ self.by_name: dict[str, list[FieldRef]] = {}
72
+ self.list_titles: dict[str, str] = {} # list title -> list key
73
+
74
+ def add_list(self, key: str, title: str, fields: list[dict]) -> None:
75
+ self.list_titles[title.lower()] = key
76
+ for f in fields:
77
+ ref = FieldRef(key, f["name"], f["label"])
78
+ self.by_label.setdefault(f["label"].lower(), []).append(ref)
79
+ self.by_name.setdefault(f["name"].lower(), []).append(ref)
80
+
81
+ def resolve(self, text: str, prefer: Optional[str] = None, internal_ok: bool = False) -> list[FieldRef]:
82
+ t = re.sub(r"\s+Required Field$", "", text.strip(), flags=re.I).lower()
83
+ refs = self.by_label.get(t) or (self.by_name.get(t) if internal_ok else None) or []
84
+ if prefer and len(refs) > 1:
85
+ pref = [r for r in refs if r.list == prefer]
86
+ refs = pref or refs
87
+ return refs[:1] if prefer and refs and refs[0].list == prefer else refs[:1]
88
+
89
+
90
+ SELECTOR_FIELD = re.compile(r"""\[\s*title\s*[*^$]?=\s*['"]?([^'"\]]+)['"]?\s*\]|:contains\(\s*['"]?([^'")]+)['"]?\s*\)""", re.I)
91
+ EVENT = re.compile(r"\.(change|blur|click|keyup|keydown|focusout|input|dblclick|submit)\s*\(|\.on\(\s*__S(\d+)__")
92
+ VISIBILITY = re.compile(r"\.(toggle|hide|show|fadeIn|fadeOut|slideUp|slideDown|slideToggle)\s*\(|\.css\(\s*__S\d+__\s*,\s*__S\d+__")
93
+ READONLY = re.compile(r"\.(prop|attr)\(\s*__S(\d+)__\s*,|\b[\w.$]*(lock|disable|readonly|readOnly|makeReadOnly)[\w$]*\s*\(", re.I)
94
+ SETVAL = re.compile(r"\.val\(\s*(?!\))")
95
+ MESSAGE_CALL = re.compile(r"\b(alert|confirm)\s*\(")
96
+ NAVIGATE = re.compile(r"\b(?:window\.)?location(?:\.href)?\s*=(?!=)|\blocation\.(?:replace|assign)\s*\(|\bwindow\.open\s*\(|\bSTSNavigate\s*\(|\bSP\.UI\.ModalDialog\.\w+\s*\(")
97
+ QUERY_PARAM = re.compile(r"\bGetUrlKeyValue\(\s*__S(\d+)__")
98
+ SPSERVICES = re.compile(r"\$\(\)\.SPServices(?:\.(\w+))?\s*\(")
99
+ CONTEXT = re.compile(r"_spPageContextInfo\.(\w+)")
100
+
101
+
102
+ def _labels_in_selectors(scan_: Scan, line_code: str) -> list[str]:
103
+ out = []
104
+ for lit in scan_.literals_in(line_code):
105
+ for m in SELECTOR_FIELD.finditer(lit.value):
106
+ out.append(m.group(1) or m.group(2))
107
+ return out
108
+
109
+
110
+ def analyze_script(name: str, source: str, index: FieldIndex, prefer_list: Optional[str] = None) -> tuple[list[Behavior], list[dict]]:
111
+ """-> (behaviors, calls). Calls are REST/SPServices invocations with parsed detail."""
112
+ s = scan(source)
113
+ behaviors: list[Behavior] = []
114
+ ids: dict[str, int] = {}
115
+
116
+ def add(kind: str, level: str, line: int, offset: int, **kw) -> Behavior:
117
+ base = f"{name}#L{line}:{kind}"
118
+ ids[base] = ids.get(base, 0) + 1
119
+ bid = base if ids[base] == 1 else f"{base}.{ids[base]}"
120
+ encl = s.enclosing(offset)
121
+ b = Behavior(id=bid, kind=kind, level=level, file=name, line=line,
122
+ code=(s.lines[line - 1].strip() if line - 1 < len(s.lines) else "")[:220],
123
+ function=encl[-1].name if encl else None,
124
+ presave=any(f.name in ("PreSaveAction", "PreSaveItem") for f in encl), **kw)
125
+ behaviors.append(b)
126
+ return b
127
+
128
+ # variables that hold a field row/control, e.g. `var row = VR.getFieldRow("High Risk Justification")`
129
+ var_fields: dict[str, list[FieldRef]] = {}
130
+ for var, (expr, _) in s.assignments.items():
131
+ refs = []
132
+ for lit in s.literals_in(expr):
133
+ refs += index.resolve(lit.value, prefer_list)
134
+ for label in _labels_in_selectors(s, expr):
135
+ refs += index.resolve(label, prefer_list)
136
+ if refs:
137
+ var_fields[var] = refs
138
+
139
+ def fields_on(line_code: str) -> list[FieldRef]:
140
+ refs: list[FieldRef] = []
141
+ for lit in s.literals_in(line_code):
142
+ refs += index.resolve(lit.value, prefer_list)
143
+ for label in _labels_in_selectors(s, line_code):
144
+ refs += index.resolve(label, prefer_list)
145
+ for var, vrefs in var_fields.items():
146
+ if re.search(rf"(?<![\w$.]){re.escape(var)}(?![\w$])", line_code) and not re.search(rf"\b(var|let|const)\s+{re.escape(var)}\b", line_code):
147
+ refs += vrefs
148
+ seen, out = set(), []
149
+ for r in refs:
150
+ if (r.list, r.name) not in seen:
151
+ seen.add((r.list, r.name))
152
+ out.append(r)
153
+ return out
154
+
155
+ offsets = [0]
156
+ for ln in s.code_lines:
157
+ offsets.append(offsets[-1] + len(ln) + 1)
158
+
159
+ helper_funcs = {f.name for f in s.functions}
160
+
161
+ for i, line_code in enumerate(s.code_lines, start=1):
162
+ if not line_code.strip():
163
+ continue
164
+ off = offsets[i - 1]
165
+ flds = fields_on(line_code)
166
+
167
+ # function definitions of known SharePoint hooks
168
+ if re.search(r"\bfunction\s+(PreSaveAction|PreSaveItem)\s*\(", line_code):
169
+ add("presave_hook", INFO, i, off, detail={"hook": "PreSaveAction"})
170
+
171
+ m = EVENT.search(line_code)
172
+ if m and flds:
173
+ ev = m.group(1) or (s.literals[int(m.group(2))].value if m.group(2) else "on")
174
+ add("event", INFO, i, off, fields=flds, detail={"event": ev})
175
+
176
+ if VISIBILITY.search(line_code):
177
+ add("visibility", MUST if flds else INFO, i, off, fields=flds,
178
+ condition=if_condition(s, off + len(line_code) - len(line_code.lstrip())))
179
+
180
+ rm = READONLY.search(line_code)
181
+ if rm:
182
+ prop = s.literals[int(rm.group(2))].value.lower() if rm.group(2) else ""
183
+ if rm.group(3) or prop in ("disabled", "readonly", "contenteditable"):
184
+ # a helper called with a field literal is the behaviour; the helper's own body is "info"
185
+ add("readonly", MUST if flds else INFO, i, off, fields=flds)
186
+
187
+ if SETVAL.search(line_code) and flds and not re.search(r"\.val\(\s*\)", line_code):
188
+ if re.search(r"\$\(", line_code):
189
+ add("set_value", MUST, i, off, fields=flds, condition=if_condition(s, off + 1),
190
+ detail={"value": s.restore(line_code.split(".val(", 1)[1].rsplit(")", 1)[0]).strip()})
191
+
192
+ mm = MESSAGE_CALL.search(line_code)
193
+ if mm:
194
+ call = enclosing_call(s, off + mm.end(), r"\b" + mm.group(1))
195
+ text = template(s, call[2]) if call else None
196
+ options = []
197
+ if call and ("?" in call[2] or "||" in call[2]):
198
+ options = [l.value for l in s.literals_in(call[2]) if len(l.value) > 10]
199
+ text = None if options else text
200
+ kind = "save_validation" if (s.enclosing(off) and any(f.name in ("PreSaveAction", "PreSaveItem") for f in s.enclosing(off))
201
+ and mm.group(1) == "alert") else ("confirm" if mm.group(1) == "confirm" else "message")
202
+ add(kind, MUST, i, off + mm.start(), fields=flds, message=text, condition=if_condition(s, off + mm.start()),
203
+ detail={"presentation": mm.group(1), **({"messages": options} if options else {})})
204
+
205
+ for lit in s.literals_in(line_code):
206
+ if "ms-formvalidation" in lit.value or "ms-error" in lit.value:
207
+ pos = off + line_code.find(f"__S{lit.index}__")
208
+ cands = [(en - st, c) for (st, en, c) in concat_expressions(s) if st <= pos < en and not re.match(r"[\w$.]*\$\(", c)]
209
+ expr = min(cands)[1] if cands else None
210
+ txt = re.sub(r"<[^>]+>", "", template(s, expr)) if expr else re.sub(r"<[^>]+>", "", lit.value)
211
+ add("message", MUST, i, off, fields=flds, message=txt.strip(), condition=if_condition(s, off + 1),
212
+ detail={"presentation": "inline"})
213
+ break
214
+ tm = re.search(r"\.(text|html)\(\s*(__S\d+__(?:\s*\+[^)]*)?)\)", line_code)
215
+ if tm and not flds:
216
+ txt = re.sub(r"<[^>]+>", "", template(s, tm.group(2))).strip()
217
+ if len(txt) > 15 and " " in txt and not txt.lower().startswith(("loading", "<")):
218
+ add("message", MUST, i, off, message=txt, condition=if_condition(s, off + 1), detail={"presentation": "banner"})
219
+
220
+ if NAVIGATE.search(line_code):
221
+ add("navigate", MUST, i, off, condition=if_condition(s, off + 1), detail={"target": s.restore(line_code.strip())[:200]})
222
+
223
+ for q in QUERY_PARAM.finditer(line_code):
224
+ add("query_param", INFO, i, off, detail={"param": s.literals[int(q.group(1))].value})
225
+
226
+ for lit in s.literals_in(line_code):
227
+ g = re.search(r"Group\[Name=['\"]?([^'\"\]]+)", lit.value)
228
+ if g:
229
+ add("role_check", MUST, i, off, detail={"group": g.group(1)})
230
+ if re.search(r"(new|edit|disp)form\.aspx", lit.value, re.I):
231
+ add("mode_check", INFO, i, off, detail={"form": lit.value})
232
+
233
+ for c in CONTEXT.finditer(line_code):
234
+ add("context", INFO, i, off, detail={"property": c.group(1)})
235
+
236
+ # ---------------------------------------------------------------- calls
237
+ calls: list[dict] = []
238
+ seen_urls = set()
239
+ for st, en, expr in concat_expressions(s):
240
+ url = template(s, expr)
241
+ if "/_api/" not in url and "_vti_bin" not in url:
242
+ continue
243
+ line = s.line_of(st)
244
+ parsed = parse_rest_url(url)
245
+ if parsed.get("resource") in ("list",) and not parsed.get("item") and parsed.get("op") is None and not parsed.get("query"):
246
+ continue # base URL definition such as `listUrl = site + "/_api/web/lists/getbytitle('X')"`
247
+ fn = (s.enclosing(st)[-1].name if s.enclosing(st) else None)
248
+ key = (url, fn)
249
+ if key in seen_urls:
250
+ continue
251
+ seen_urls.add(key)
252
+ method, body = "GET", {}
253
+ ajax = enclosing_call(s, st, r"(?:\$|jQuery)\.ajax")
254
+ if ajax:
255
+ props = {k: s.restore(v) for k, v in object_props(s, ajax[2]).items()}
256
+ t = props.get("type") or props.get("method")
257
+ if t:
258
+ method = t.strip("'\" ").upper()
259
+ hdrs = props.get("headers", "")
260
+ xm = re.search(r"X-HTTP-Method['\"]?\s*:\s*['\"](\w+)", hdrs, re.I)
261
+ if xm:
262
+ method = xm.group(1).upper()
263
+ if "IF-MATCH" in hdrs.upper():
264
+ body["if_match"] = True
265
+ data_masked = object_props(s, ajax[2]).get("data", "")
266
+ obj = re.search(r"\{.*\}", data_masked, re.S)
267
+ body["fields"] = [k for k in (object_props(s, obj.group(0)).keys() if obj else []) if k not in ("__metadata", "type")]
268
+ level_kind = "rest_call"
269
+ b = add(level_kind, MUST, line, st, fields=_fields_from_query(parsed, index, body.get("fields", [])),
270
+ detail={"method": method, "url": url, **parsed, **({"body": body} if body.get("fields") else {}),
271
+ **({"if_match": True} if body.get("if_match") else {})})
272
+ calls.append({"behavior": b.id, "type": "rest", "method": method, "url": url, **parsed, "line": line, "function": fn,
273
+ **({"body_fields": body["fields"]} if body.get("fields") else {})})
274
+
275
+ for m in SPSERVICES.finditer(s.code):
276
+ open_p = m.end() - 1
277
+ from .jsscan import match_brace
278
+ close_p = match_brace(s.code, open_p)
279
+ args = s.code[open_p + 1:close_p]
280
+ props = {k: (s.lit(v).value if s.lit(v) else s.restore(v)) for k, v in object_props(s, args).items()} if args.strip().startswith("{") else {}
281
+ sub = m.group(1) or "call"
282
+ op = props.get("operation") or sub
283
+ line = s.line_of(m.start())
284
+ if sub in ("SPGetCurrentUser",) or op == "SPGetCurrentUser":
285
+ continue
286
+ kind = {"SPCascadeDropdowns": "cascade", "SPFindPeoplePicker": "default_value"}.get(sub, "spservices_call")
287
+ flds = []
288
+ for key in ("parentColumn", "childColumn", "peoplePickerDisplayName", "columnName"):
289
+ if key in props:
290
+ flds += index.resolve(props[key], prefer_list)
291
+ for key in ("CAMLQuery", "CAMLViewFields"):
292
+ if key in props:
293
+ for fr in re.findall(r"FieldRef\s+Name=['\"](\w+)['\"]", props[key]):
294
+ lst = index.list_titles.get((props.get("listName") or "").lower())
295
+ flds += index.resolve(fr, lst, internal_ok=True)
296
+ b = add(kind, MUST, line, m.start(), fields=flds, detail={"spservices": sub, "operation": op, **{k: v for k, v in props.items() if k not in ("completefunc",)}})
297
+ calls.append({"behavior": b.id, "type": "spservices", "operation": op, "function": sub, "line": line,
298
+ **{k: v for k, v in props.items() if k in ("listName", "CAMLQuery", "CAMLViewFields", "CAMLRowLimit", "webURL",
299
+ "relationshipList", "parentColumn", "childColumn")}})
300
+ behaviors.sort(key=lambda b: (b.line, b.id))
301
+ return behaviors, calls
302
+
303
+
304
+ def _fields_from_query(parsed: dict, index: FieldIndex, body_fields: list[str]) -> list[FieldRef]:
305
+ lst = index.list_titles.get((parsed.get("list") or "").lower())
306
+ names = set(body_fields)
307
+ q = parsed.get("query", {})
308
+ for k in ("$select", "$filter", "$orderby", "$expand"):
309
+ names |= set(re.findall(r"\b([A-Za-z_]\w*)", q.get(k, "")))
310
+ out, seen = [], set()
311
+ for n in names:
312
+ base = re.sub(r"Id$", "", n)
313
+ for cand in (n, base):
314
+ refs = index.resolve(cand, lst, internal_ok=True)
315
+ if refs and refs[0].list == lst and (refs[0].list, refs[0].name) not in seen:
316
+ seen.add((refs[0].list, refs[0].name))
317
+ out.append(refs[0])
318
+ break
319
+ return sorted(out, key=lambda r: r.name)
320
+
321
+
322
+ def parse_rest_url(url: str) -> dict:
323
+ out: dict = {}
324
+ path, _, qs = url.partition("?")
325
+ m = re.search(r"getbytitle\(\s*'((?:[^']|'')+)'\s*\)", path, re.I)
326
+ if m:
327
+ out["resource"] = "list"
328
+ out["list"] = m.group(1).replace("''", "'")
329
+ m2 = re.search(r"lists\(\s*guid'([^']+)'\s*\)", path, re.I)
330
+ if m2:
331
+ out["resource"] = "list"
332
+ out["list_id"] = m2.group(1)
333
+ if re.search(r"/_api/web/(currentuser|siteusers|sitegroups)", path, re.I):
334
+ out["resource"] = "user"
335
+ if re.search(r"/_api/(SP\.UserProfiles|sp\.userprofiles)", path, re.I):
336
+ out["resource"] = "userprofile"
337
+ if re.search(r"/_api/search/", path, re.I):
338
+ out["resource"] = "search"
339
+ if "_vti_bin" in path:
340
+ out["resource"] = "soap"
341
+ mi = re.search(r"/items\(\s*([^)]*)\)", path)
342
+ if mi:
343
+ out["item"] = mi.group(1)
344
+ elif re.search(r"/items/?$", path):
345
+ out["op"] = "items"
346
+ tail = re.search(r"/items\([^)]*\)/(\w+)", path)
347
+ if tail:
348
+ out["op"] = tail.group(1) # AttachmentFiles, FieldValuesAsText...
349
+ if re.search(r"/items/?$", path) and "item" not in out:
350
+ out["op"] = "items"
351
+ if qs:
352
+ q = {}
353
+ for part in re.split(r"&(?=\$)", qs):
354
+ k, _, v = part.partition("=")
355
+ q[k] = unquote(v)
356
+ out["query"] = q
357
+ return out
@@ -0,0 +1,152 @@
1
+ """Map SharePoint field definitions (REST /fields) to spec fields, and port simple validation formulas."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from typing import Optional
6
+
7
+ from ..sharepoint.client import unwrap
8
+
9
+ SYSTEM_FIELDS = {
10
+ "ID", "ContentType", "ContentTypeId", "Modified", "Created", "Author", "Editor", "_UIVersionString", "_UIVersion",
11
+ "_ModerationStatus", "_ModerationComments", "FileLeafRef", "FileRef", "FileDirRef", "LinkTitle", "LinkTitleNoMenu",
12
+ "Edit", "DocIcon", "ItemChildCount", "FolderChildCount", "AppAuthor", "AppEditor", "ComplianceAssetId",
13
+ "_ComplianceFlags", "_ComplianceTag", "_ComplianceTagWrittenTime", "_ComplianceTagUserId", "_IsRecord",
14
+ "SelectTitle", "GUID", "WorkflowVersion", "WorkflowInstanceID", "Order", "owshiddenversion", "_HasCopyDestinations",
15
+ "_CopySource", "_Level", "_IsCurrentVersion", "InstanceID", "MetaInfo", "ProgId", "ScopeId", "HTML_x0020_File_x0020_Type",
16
+ "_EditMenuTableStart", "_EditMenuTableStart2", "_EditMenuTableEnd", "LinkFilenameNoMenu", "LinkFilename", "LinkFilename2",
17
+ "BaseName", "UniqueId", "FSObjType", "SortBehavior", "PermMask", "FileSizeDisplay", "SMTotalSize", "SMLastModifiedDate",
18
+ "SMTotalFileStreamSize", "SMTotalFileCount", "_VirusStatus", "_VirusVendorID", "_VirusInfo", "Restricted", "OriginatorId",
19
+ "NoExecute", "ContentVersion", "_CommentFlags", "_CommentCount", "Attachments", "AccessPolicy", "_ColorTag",
20
+ }
21
+
22
+ TYPE_MAP = {
23
+ "Text": "text", "Note": "note", "Choice": "choice", "MultiChoice": "multichoice", "Lookup": "lookup",
24
+ "LookupMulti": "lookup", "User": "user", "UserMulti": "user", "DateTime": "date", "Number": "number",
25
+ "Currency": "currency", "Boolean": "boolean", "URL": "url", "Calculated": "calculated", "Integer": "number",
26
+ "Counter": "number", "TaxonomyFieldType": "taxonomy", "TaxonomyFieldTypeMulti": "taxonomy",
27
+ }
28
+
29
+
30
+ def _choices(f: dict) -> list[str]:
31
+ c = f.get("Choices")
32
+ return list(unwrap(c) or []) if c else []
33
+
34
+
35
+ def map_field(f: dict, list_by_id: dict[str, str]) -> Optional[dict]:
36
+ name = f["InternalName"]
37
+ if f.get("Hidden") or name in SYSTEM_FIELDS:
38
+ return None
39
+ if f.get("ReadOnlyField") and f.get("TypeAsString") not in ("Calculated",):
40
+ return None
41
+ t = f.get("TypeAsString", "Text")
42
+ spec: dict = {"name": name, "label": f.get("Title", name), "type": TYPE_MAP.get(t, "text"), "sp_type": t,
43
+ "required": bool(f.get("Required"))}
44
+ if f.get("Description"):
45
+ spec["description"] = f["Description"]
46
+ if t == "Text" and f.get("MaxLength"):
47
+ spec["max_length"] = f["MaxLength"]
48
+ if t in ("Choice", "MultiChoice"):
49
+ spec["choices"] = _choices(f)
50
+ spec["format"] = {0: "dropdown", 1: "radio"}.get(f.get("EditFormat", 0), "dropdown") if t == "Choice" else "checkboxes"
51
+ if f.get("FillInChoice"):
52
+ spec["fill_in"] = True
53
+ if t.startswith("Lookup"):
54
+ lid = (f.get("LookupList") or "").strip("{}").lower()
55
+ spec["lookup"] = {"list": list_by_id.get(lid, lid), "field": f.get("LookupField") or "Title"}
56
+ spec["multi"] = bool(f.get("AllowMultipleValues")) or t == "LookupMulti"
57
+ if t.startswith("User"):
58
+ spec["multi"] = bool(f.get("AllowMultipleValues")) or t == "UserMulti"
59
+ spec["groups_allowed"] = f.get("SelectionMode", 0) == 1
60
+ if t == "DateTime":
61
+ spec["type"] = "date" if f.get("DisplayFormat", 0) == 0 else "datetime"
62
+ if t in ("Number", "Currency"):
63
+ for k, sk in (("MinimumValue", "min"), ("MaximumValue", "max")):
64
+ v = f.get(k)
65
+ if v is not None and abs(float(v)) < 1e300:
66
+ spec[sk] = v
67
+ if t == "Number" and f.get("ShowAsPercentage"):
68
+ spec["percent"] = True
69
+ if t == "Note":
70
+ spec["rich_text"] = bool(f.get("RichText"))
71
+ spec["lines"] = f.get("NumberOfLines", 6)
72
+ if t == "Calculated":
73
+ spec["read_only"] = True
74
+ spec["formula"] = f.get("Formula")
75
+ if f.get("EnforceUniqueValues"):
76
+ spec["unique"] = True
77
+ d = f.get("DefaultValue")
78
+ if d not in (None, ""):
79
+ spec["default"] = {"[today]": "@today", "[me]": "@me"}.get(str(d).lower(), _coerce_default(d, spec["type"]))
80
+ return spec
81
+
82
+
83
+ def _coerce_default(v, typ):
84
+ if typ == "boolean":
85
+ return str(v) in ("1", "true", "True")
86
+ if typ in ("number", "currency"):
87
+ try:
88
+ return float(v)
89
+ except ValueError:
90
+ return v
91
+ return v
92
+
93
+
94
+ def map_fields(fields_json: dict, list_by_id: dict[str, str]) -> tuple[list[dict], list[dict]]:
95
+ """-> (spec fields in schema order, column validation formulas)."""
96
+ out, formulas = [], []
97
+ for f in unwrap(fields_json):
98
+ sf = map_field(f, list_by_id)
99
+ if not sf:
100
+ continue
101
+ out.append(sf)
102
+ if f.get("ValidationFormula"):
103
+ formulas.append({"field": sf["name"], "formula": f["ValidationFormula"], "message": f.get("ValidationMessage") or ""})
104
+ # Title first as SharePoint renders it (it is first in the default content type)
105
+ out.sort(key=lambda x: 0 if x["name"] == "Title" else 1)
106
+ return out, formulas
107
+
108
+
109
+ # --------------------------------------------------------------- formulas
110
+ FIELD = r"\[([^\]]+)\]"
111
+
112
+
113
+ def translate_formula(formula: str, field_names: dict[str, str]) -> Optional[dict]:
114
+ """Port the common SharePoint validation formula shapes to a spec rule body; None when not recognised.
115
+
116
+ field_names maps display name / internal name -> internal name (formulas use display names).
117
+ """
118
+ f = re.sub(r"\s+", "", formula.lstrip("="))
119
+
120
+ def fld(token: str) -> Optional[str]:
121
+ return field_names.get(token) or field_names.get(token.lower())
122
+
123
+ m = re.fullmatch(rf"LEN\({FIELD}\)(=|<=|>=|<|>)(\d+)", f, re.I)
124
+ if m and fld(m.group(1)):
125
+ op = {"=": "eq", "<=": "lte", ">=": "gte", "<": "lt", ">": "gt"}[m.group(2)]
126
+ return {"kind": "length", "field": fld(m.group(1)), "params": {"op": op, "value": int(m.group(3))}}
127
+ m = re.fullmatch(rf"OR\(ISBLANK\({FIELD}\),LEN\(\1\)(=|<=|>=)(\d+)\)", f, re.I)
128
+ if m and fld(m.group(1)):
129
+ op = {"=": "eq", "<=": "lte", ">=": "gte"}[m.group(2)]
130
+ return {"kind": "length", "field": fld(m.group(1)), "params": {"op": op, "value": int(m.group(3)), "allow_empty": True}}
131
+ m = re.fullmatch(rf"{FIELD}(>=|<=|>|<){FIELD}", f)
132
+ if m and fld(m.group(1)) and fld(m.group(3)):
133
+ op = {">=": "gte", "<=": "lte", ">": "gt", "<": "lt"}[m.group(2)]
134
+ return {"kind": "compare", "field": fld(m.group(1)), "params": {"op": op, "other": fld(m.group(3))}}
135
+ m = re.fullmatch(rf"OR\(ISBLANK\({FIELD}\),(?:ISBLANK\({FIELD}\),)?\1(>=|<=|>|<){FIELD}\)", f)
136
+ if m and fld(m.group(1)) and fld(m.group(4)):
137
+ op = {">=": "gte", "<=": "lte", ">": "gt", "<": "lt"}[m.group(3)]
138
+ return {"kind": "compare", "field": fld(m.group(1)), "params": {"op": op, "other": fld(m.group(4)), "allow_empty": True}}
139
+ m = re.fullmatch(rf"{FIELD}(>=|<=|>|<)(-?\d+(?:\.\d+)?)", f)
140
+ if m and fld(m.group(1)):
141
+ op = {">=": "gte", "<=": "lte", ">": "gt", "<": "lt"}[m.group(2)]
142
+ return {"kind": "range", "field": fld(m.group(1)), "params": {"op": op, "value": float(m.group(3))}}
143
+ # required B when A is true: OR([A]=FALSE,NOT(ISBLANK([B]))) / IF([A]=TRUE,NOT(ISBLANK([B])),TRUE)
144
+ m = re.fullmatch(rf"OR\({FIELD}=FALSE,NOT\(ISBLANK\({FIELD}\)\)\)", f, re.I) or \
145
+ re.fullmatch(rf"IF\({FIELD}=TRUE,NOT\(ISBLANK\({FIELD}\)\),TRUE\)", f, re.I)
146
+ if m and fld(m.group(1)) and fld(m.group(2)):
147
+ return {"kind": "required", "field": fld(m.group(2)), "when": {"field": fld(m.group(1)), "eq": True}}
148
+ # required B when A = "x": OR([A]<>"x",NOT(ISBLANK([B])))
149
+ m = re.fullmatch(rf'OR\({FIELD}<>"([^"]*)",NOT\(ISBLANK\({FIELD}\)\)\)', f, re.I)
150
+ if m and fld(m.group(1)) and fld(m.group(3)):
151
+ return {"kind": "required", "field": fld(m.group(3)), "when": {"field": fld(m.group(1)), "eq": m.group(2)}}
152
+ return None