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.
- sp2react/__init__.py +18 -0
- sp2react/agents/__init__.py +0 -0
- sp2react/agents/core.py +146 -0
- sp2react/agents/prompts/dbmap.md +28 -0
- sp2react/agents/prompts/fix.md +11 -0
- sp2react/agents/prompts/handler.md +22 -0
- sp2react/agents/prompts/page.md +40 -0
- sp2react/agents/prompts/review.md +21 -0
- sp2react/agents/prompts/spec.md +79 -0
- sp2react/agents/tasks.py +374 -0
- sp2react/analysis/__init__.py +0 -0
- sp2react/analysis/aspx.py +254 -0
- sp2react/analysis/behaviors.py +357 -0
- sp2react/analysis/fields.py +152 -0
- sp2react/analysis/inventory.py +314 -0
- sp2react/analysis/jsscan.py +330 -0
- sp2react/checks.py +105 -0
- sp2react/cli.py +188 -0
- sp2react/config.py +172 -0
- sp2react/dbmap.py +245 -0
- sp2react/llm/__init__.py +0 -0
- sp2react/llm/providers.py +219 -0
- sp2react/migrate.py +255 -0
- sp2react/net.py +60 -0
- sp2react/pipeline.py +583 -0
- sp2react/scaffold/__init__.py +0 -0
- sp2react/scaffold/generate.py +279 -0
- sp2react/scaffold/rule_vectors.json +3826 -0
- sp2react/scaffold/templates/backend/app/__init__.py +0 -0
- sp2react/scaffold/templates/backend/app/api.py +293 -0
- sp2react/scaffold/templates/backend/app/auth.py +33 -0
- sp2react/scaffold/templates/backend/app/custom/__init__.py +22 -0
- sp2react/scaffold/templates/backend/app/main.py +47 -0
- sp2react/scaffold/templates/backend/app/odata.py +185 -0
- sp2react/scaffold/templates/backend/app/rules.py +256 -0
- sp2react/scaffold/templates/backend/app/sp_repo.py +159 -0
- sp2react/scaffold/templates/backend/app/sql_repo.py +437 -0
- sp2react/scaffold/templates/backend/app/store.py +224 -0
- sp2react/scaffold/templates/backend/requirements.txt +8 -0
- sp2react/scaffold/templates/backend/tests/conftest.py +10 -0
- sp2react/scaffold/templates/backend/tests/test_rule_vectors.py +38 -0
- sp2react/scaffold/templates/frontend/index.html +12 -0
- sp2react/scaffold/templates/frontend/package.json +24 -0
- sp2react/scaffold/templates/frontend/scripts/check-rules.ts +23 -0
- sp2react/scaffold/templates/frontend/src/api.ts +88 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Banner.tsx +4 -0
- sp2react/scaffold/templates/frontend/src/components/sp/DataTable.tsx +38 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Form.tsx +252 -0
- sp2react/scaffold/templates/frontend/src/components/sp/GenericPage.tsx +28 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Layout.tsx +54 -0
- sp2react/scaffold/templates/frontend/src/components/sp/LegacyHtml.tsx +9 -0
- sp2react/scaffold/templates/frontend/src/components/sp/ListView.tsx +59 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Tiles.tsx +21 -0
- sp2react/scaffold/templates/frontend/src/components/sp/fields.tsx +132 -0
- sp2react/scaffold/templates/frontend/src/custom/types.ts +10 -0
- sp2react/scaffold/templates/frontend/src/main.tsx +14 -0
- sp2react/scaffold/templates/frontend/src/runtime/format.ts +36 -0
- sp2react/scaffold/templates/frontend/src/runtime/rules.ts +225 -0
- sp2react/scaffold/templates/frontend/src/runtime/spec.ts +102 -0
- sp2react/scaffold/templates/frontend/src/runtime/user.tsx +21 -0
- sp2react/scaffold/templates/frontend/src/screens/LegacyRedirect.tsx +27 -0
- sp2react/scaffold/templates/frontend/src/screens/ListFormScreen.tsx +33 -0
- sp2react/scaffold/templates/frontend/src/styles/sp.css +95 -0
- sp2react/scaffold/templates/frontend/src/vite-env.d.ts +1 -0
- sp2react/scaffold/templates/frontend/tsconfig.json +18 -0
- sp2react/scaffold/templates/frontend/vite.config.ts +7 -0
- sp2react/sharepoint/__init__.py +0 -0
- sp2react/sharepoint/client.py +210 -0
- sp2react/sharepoint/extract.py +217 -0
- sp2react/spec/__init__.py +0 -0
- sp2react/spec/base.py +80 -0
- sp2react/spec/coverage.py +191 -0
- sp2react/spec/model.py +133 -0
- sp2react-0.1.0.dist-info/METADATA +238 -0
- sp2react-0.1.0.dist-info/RECORD +79 -0
- sp2react-0.1.0.dist-info/WHEEL +5 -0
- sp2react-0.1.0.dist-info/entry_points.txt +2 -0
- sp2react-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|