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
sp2react/agents/tasks.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""The concrete agents: what each one is given, and how its output is checked before it is accepted."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import py_compile
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
|
|
14
|
+
from ..analysis.aspx import load_page
|
|
15
|
+
from ..analysis.inventory import Legacy
|
|
16
|
+
from ..spec.coverage import check
|
|
17
|
+
from ..spec.model import Spec
|
|
18
|
+
from .core import Agent, AgentResult
|
|
19
|
+
|
|
20
|
+
MAX_SOURCE_LINES = 3500
|
|
21
|
+
ALLOWED_PAGE_IMPORTS = {
|
|
22
|
+
"react", "react-router-dom", "../../api", "../../runtime/user", "../../runtime/format", "../../runtime/spec",
|
|
23
|
+
"../../runtime/rules", "../../components/sp/Layout", "../../components/sp/DataTable", "../../components/sp/Tiles",
|
|
24
|
+
"../../components/sp/ListView", "../../components/sp/LegacyHtml", "../../components/sp/Banner",
|
|
25
|
+
}
|
|
26
|
+
FORBIDDEN_CODE = [(r"dangerouslySetInnerHTML", "use <LegacyHtml html=...> instead of dangerouslySetInnerHTML"),
|
|
27
|
+
(r"\bfetch\s*\(", "use the api client instead of fetch()"), (r"XMLHttpRequest", "use the api client"),
|
|
28
|
+
(r"\beval\s*\(", "eval is not allowed"), (r"(?<![\w.])\$\(", "jQuery is not available in the new app"),
|
|
29
|
+
(r"_spPageContextInfo", "use useUser() instead of _spPageContextInfo")]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ----------------------------------------------------------------- helpers
|
|
33
|
+
def numbered(text: str, start: int = 1, only: Optional[set[int]] = None) -> str:
|
|
34
|
+
out, last = [], None
|
|
35
|
+
for i, line in enumerate(text.splitlines(), start):
|
|
36
|
+
if only is not None and i not in only:
|
|
37
|
+
continue
|
|
38
|
+
if only is not None and last is not None and i != last + 1:
|
|
39
|
+
out.append(" ...")
|
|
40
|
+
out.append(f"{i:5d}| {line}")
|
|
41
|
+
last = i
|
|
42
|
+
return "\n".join(out)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def script_sources(legacy: Legacy, inv: dict, budget: int = MAX_SOURCE_LINES) -> str:
|
|
46
|
+
texts: list[tuple[str, str]] = []
|
|
47
|
+
for s in inv["scripts"]["custom"]:
|
|
48
|
+
name = s["path"]
|
|
49
|
+
if "#inline" in name:
|
|
50
|
+
page, n = name.split("#inline")
|
|
51
|
+
parsed = load_page(legacy.root, page)
|
|
52
|
+
code = parsed.inline_scripts[int(n) - 1]["code"] if int(n) - 1 < len(parsed.inline_scripts) else ""
|
|
53
|
+
else:
|
|
54
|
+
code = legacy.file_text(name) or ""
|
|
55
|
+
texts.append((name, code))
|
|
56
|
+
total = sum(t.count("\n") + 1 for _, t in texts)
|
|
57
|
+
lines_by_file: dict[str, set[int]] = {}
|
|
58
|
+
if total > budget: # keep windows around the behaviours only
|
|
59
|
+
for b in inv["behaviors"]:
|
|
60
|
+
if b.get("line"):
|
|
61
|
+
lines_by_file.setdefault(b["file"], set()).update(range(max(1, b["line"] - 12), b["line"] + 13))
|
|
62
|
+
parts = []
|
|
63
|
+
for name, code in texts:
|
|
64
|
+
only = lines_by_file.get(name) if total > budget else None
|
|
65
|
+
modes = next((s.get("modes") for s in inv["scripts"]["custom"] if s["path"] == name), None)
|
|
66
|
+
parts.append(f'<source file="{name}" loaded_on="{",".join(modes or []) or "page"}">\n{numbered(code, only=only)}\n</source>')
|
|
67
|
+
return "\n".join(parts)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def compact_spec(spec: Spec) -> str:
|
|
71
|
+
d = json.loads(spec.model_dump_json(exclude_none=True))
|
|
72
|
+
for l in d["lists"].values():
|
|
73
|
+
l["fields"] = [{k: f[k] for k in ("name", "label", "type", "required", "choices", "lookup", "default", "multi", "max_length", "read_only") if k in f}
|
|
74
|
+
for f in l["fields"]]
|
|
75
|
+
l.pop("views", None)
|
|
76
|
+
for c in d["screen"]["components"]:
|
|
77
|
+
if "legacy_html" in c.get("props", {}):
|
|
78
|
+
c["props"]["legacy_html"] = c["props"]["legacy_html"][:600]
|
|
79
|
+
return json.dumps(d, indent=1)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def compact_behaviors(inv: dict) -> str:
|
|
83
|
+
rows = []
|
|
84
|
+
for b in inv["behaviors"]:
|
|
85
|
+
r = {"id": b["id"], "level": b["level"], "kind": b["kind"], "code": b.get("code")}
|
|
86
|
+
for k in ("function", "message", "condition"):
|
|
87
|
+
if b.get(k):
|
|
88
|
+
r[k] = b[k]
|
|
89
|
+
if b.get("presave"):
|
|
90
|
+
r["in"] = "PreSaveAction"
|
|
91
|
+
if b.get("fields"):
|
|
92
|
+
r["fields"] = [f["name"] if f["list"] == inv["screen"].get("list") else f"{f['list']}.{f['name']}" for f in b["fields"]]
|
|
93
|
+
det = {k: v for k, v in (b.get("detail") or {}).items() if k in ("method", "url", "group", "messages", "presentation", "mode", "event",
|
|
94
|
+
"operation", "spservices", "listName", "CAMLQuery", "relationshipList",
|
|
95
|
+
"relationshipListParentColumn", "parentColumn", "childColumn", "ported", "list", "view_xml", "param")}
|
|
96
|
+
if det:
|
|
97
|
+
r["detail"] = det
|
|
98
|
+
rows.append(json.dumps(r, ensure_ascii=False))
|
|
99
|
+
return "\n".join(rows)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def merge_patch(base: Spec, patch: dict) -> Spec:
|
|
103
|
+
d = json.loads(base.model_dump_json())
|
|
104
|
+
base_ids = {r["id"] for r in d["rules"]}
|
|
105
|
+
for r in patch.get("rules", []):
|
|
106
|
+
if r.get("id") in base_ids:
|
|
107
|
+
raise ValueError(f"rule id {r.get('id')} collides with a base rule; use R1, R2, ...")
|
|
108
|
+
d["rules"] = d["rules"] + list(patch.get("rules", []))
|
|
109
|
+
for key, target in (("components", d["screen"]["components"]), ("endpoints", d["endpoints"])):
|
|
110
|
+
for item in patch.get(key, []):
|
|
111
|
+
idx = next((i for i, x in enumerate(target) if x["id"] == item.get("id")), None)
|
|
112
|
+
if idx is None:
|
|
113
|
+
target.append(item)
|
|
114
|
+
else:
|
|
115
|
+
target[idx] = item
|
|
116
|
+
d["not_carried"] = d.get("not_carried", []) + list(patch.get("not_carried", []))
|
|
117
|
+
d["open_questions"] = d.get("open_questions", []) + list(patch.get("open_questions", []))
|
|
118
|
+
return Spec.model_validate(d)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _pydantic_problems(e: ValidationError) -> list[str]:
|
|
122
|
+
return [f"{'.'.join(str(x) for x in err['loc'])}: {err['msg']}" for err in e.errors()[:40]]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def literal_chunks(message: str) -> list[str]:
|
|
126
|
+
return [c.strip() for c in re.split(r"\{[^{}]*\}", message) if len(c.strip()) >= 8]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# -------------------------------------------------------------- spec agent
|
|
130
|
+
def run_spec_agent(agent: Agent, legacy: Legacy, inv: dict, base: Spec) -> tuple[Spec, AgentResult]:
|
|
131
|
+
holder: dict[str, Spec] = {}
|
|
132
|
+
|
|
133
|
+
def validate(patch: dict) -> list[str]:
|
|
134
|
+
try:
|
|
135
|
+
spec = merge_patch(base, patch)
|
|
136
|
+
except ValidationError as e:
|
|
137
|
+
return _pydantic_problems(e)
|
|
138
|
+
except (ValueError, KeyError, TypeError) as e:
|
|
139
|
+
return [str(e)]
|
|
140
|
+
rep = check(spec, inv)
|
|
141
|
+
if rep.ok:
|
|
142
|
+
holder["spec"] = spec
|
|
143
|
+
return rep.problems
|
|
144
|
+
|
|
145
|
+
sections = {
|
|
146
|
+
"screen_summary": f"Screen {inv['screen']['id']} ({inv['screen']['kind']}), pages: {', '.join(inv['screen']['pages'])}. "
|
|
147
|
+
f"Primary list: {inv['screen'].get('list') or 'none'}. Site: {inv['site'].get('title')}.",
|
|
148
|
+
"base_spec": compact_spec(base),
|
|
149
|
+
"behaviors": compact_behaviors(inv),
|
|
150
|
+
"sources": script_sources(legacy, inv),
|
|
151
|
+
"html_fragments": "\n".join(f'<fragment page="{f["page"]}" mode="{f.get("mode") or ""}" web_part="{f.get("web_part", "")}">\n{f["html"][:3000]}\n</fragment>'
|
|
152
|
+
for f in inv.get("html_fragments", [])),
|
|
153
|
+
"unresolved": "\n".join(f"- {u['kind']}: {u['detail']}" for u in inv.get("unresolved", [])),
|
|
154
|
+
}
|
|
155
|
+
res = agent.run(inv["screen"]["id"], sections, validate)
|
|
156
|
+
return holder["spec"], res
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# -------------------------------------------------------------- page agent
|
|
160
|
+
def ts_problems(out: Path, files: dict[str, str], timeout: int = 120) -> list[str]:
|
|
161
|
+
"""Type-check candidate files inside the generated frontend when its node_modules exist."""
|
|
162
|
+
fe = out / "frontend"
|
|
163
|
+
tsc = fe / "node_modules" / ".bin" / "tsc"
|
|
164
|
+
if not tsc.exists():
|
|
165
|
+
return []
|
|
166
|
+
backups = {}
|
|
167
|
+
try:
|
|
168
|
+
for rel, content in files.items():
|
|
169
|
+
p = out / rel
|
|
170
|
+
backups[p] = p.read_text(encoding="utf-8") if p.exists() else None
|
|
171
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
p.write_text(content, encoding="utf-8")
|
|
173
|
+
r = subprocess.run([str(tsc), "--noEmit", "-p", str(fe)], cwd=fe, capture_output=True, text=True, timeout=timeout)
|
|
174
|
+
errs = []
|
|
175
|
+
for line in r.stdout.splitlines():
|
|
176
|
+
m = re.match(r"(src/[^(]+)\((\d+),(\d+)\): error (TS\d+): (.*)", line)
|
|
177
|
+
if m and f"frontend/{m.group(1)}" in files:
|
|
178
|
+
errs.append(f"{m.group(1)}:{m.group(2)}:{m.group(3)} {m.group(4)} {m.group(5)}")
|
|
179
|
+
return errs[:30]
|
|
180
|
+
finally:
|
|
181
|
+
for p, old in backups.items():
|
|
182
|
+
if old is None:
|
|
183
|
+
p.unlink(missing_ok=True)
|
|
184
|
+
else:
|
|
185
|
+
p.write_text(old, encoding="utf-8")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def page_file_problems(files: dict[str, str], prefix: str, must_texts: list[str]) -> list[str]:
|
|
189
|
+
problems = []
|
|
190
|
+
if not any(p == f"{prefix}Page.tsx" for p in files):
|
|
191
|
+
problems.append(f"Missing {prefix}Page.tsx")
|
|
192
|
+
for path, code in files.items():
|
|
193
|
+
if not path.startswith(prefix) or not path.endswith((".tsx", ".ts")):
|
|
194
|
+
problems.append(f"{path}: files must be .ts/.tsx under {prefix}")
|
|
195
|
+
continue
|
|
196
|
+
for mod in re.findall(r"""from\s+["']([^"']+)["']""", code):
|
|
197
|
+
if mod not in ALLOWED_PAGE_IMPORTS and not mod.startswith("./"):
|
|
198
|
+
problems.append(f"{path}: import '{mod}' is not allowed (allowed: {sorted(ALLOWED_PAGE_IMPORTS)} or ./local files)")
|
|
199
|
+
for pat, why in FORBIDDEN_CODE:
|
|
200
|
+
if re.search(pat, code):
|
|
201
|
+
problems.append(f"{path}: {why}")
|
|
202
|
+
if code.count("{") != code.count("}") or code.count("(") != code.count(")"):
|
|
203
|
+
problems.append(f"{path}: unbalanced braces/parentheses (file looks truncated)")
|
|
204
|
+
page = files.get(f"{prefix}Page.tsx", "")
|
|
205
|
+
if page and not re.search(r"export\s+default\s+function\s+Page\s*\(", page):
|
|
206
|
+
problems.append(f"{prefix}Page.tsx must `export default function Page({{ spec }}: {{ spec: Spec }})`")
|
|
207
|
+
allcode = "\n".join(files.values())
|
|
208
|
+
for msg in must_texts:
|
|
209
|
+
for chunk in literal_chunks(msg):
|
|
210
|
+
if chunk not in allcode:
|
|
211
|
+
problems.append(f"Legacy text missing or reworded: \"{chunk}\" (from \"{msg}\")")
|
|
212
|
+
return problems
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def run_page_agent(agent: Agent, legacy: Legacy, inv: dict, spec: Spec, routes: list[dict], out: Path) -> AgentResult:
|
|
216
|
+
sid = spec.screen.id
|
|
217
|
+
prefix = f"frontend/src/screens/{sid}/"
|
|
218
|
+
dropped = {c for n in spec.not_carried for c in n.covers}
|
|
219
|
+
must_texts = []
|
|
220
|
+
for b in inv["behaviors"]:
|
|
221
|
+
if b["level"] == "must" and b["id"] not in dropped:
|
|
222
|
+
if b.get("message"):
|
|
223
|
+
must_texts.append(b["message"])
|
|
224
|
+
must_texts += (b.get("detail") or {}).get("messages", [])
|
|
225
|
+
|
|
226
|
+
def validate(parsed: dict) -> list[str]:
|
|
227
|
+
files = parsed["files"]
|
|
228
|
+
probs = page_file_problems(files, prefix, must_texts)
|
|
229
|
+
return probs or ts_problems(out, files)
|
|
230
|
+
|
|
231
|
+
css = "\n".join(f"/* {rel} */\n{legacy.file_text(rel) or ''}" for rel in spec.screen.stylesheets)
|
|
232
|
+
existing = out / prefix / "Page.tsx"
|
|
233
|
+
sections = {
|
|
234
|
+
"spec": compact_spec(spec),
|
|
235
|
+
"routes": json.dumps(routes, indent=1),
|
|
236
|
+
"legacy_html": "\n".join(f'<fragment web_part="{f.get("web_part", "")}">\n{f["html"]}\n</fragment>' for f in inv.get("html_fragments", [])),
|
|
237
|
+
"legacy_css": css[:12000],
|
|
238
|
+
"sources": script_sources(legacy, inv),
|
|
239
|
+
"existing_file": existing.read_text(encoding="utf-8") if existing.exists() else "",
|
|
240
|
+
"task": f"Write {prefix}Page.tsx for screen {sid} ('{spec.screen.title}').",
|
|
241
|
+
}
|
|
242
|
+
res = agent.run(sid, sections, validate)
|
|
243
|
+
for rel, content in res.output["files"].items():
|
|
244
|
+
p = out / rel
|
|
245
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
246
|
+
p.write_text(content, encoding="utf-8")
|
|
247
|
+
return res
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ----------------------------------------------------------- handler agent
|
|
251
|
+
def run_handler_agent(agent: Agent, legacy: Legacy, inv: dict, spec: Spec, rule: Any, out: Path) -> AgentResult:
|
|
252
|
+
handler = rule.params["handler"]
|
|
253
|
+
allowed = {f"frontend/src/custom/{handler}.ts", f"backend/app/custom/{handler}.py"}
|
|
254
|
+
|
|
255
|
+
def validate(parsed: dict) -> list[str]:
|
|
256
|
+
probs = []
|
|
257
|
+
for path, code in parsed["files"].items():
|
|
258
|
+
if path not in allowed:
|
|
259
|
+
probs.append(f"{path}: only {sorted(allowed)} may be written")
|
|
260
|
+
elif path.endswith(".py"):
|
|
261
|
+
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as t:
|
|
262
|
+
t.write(code)
|
|
263
|
+
try:
|
|
264
|
+
py_compile.compile(t.name, doraise=True)
|
|
265
|
+
except py_compile.PyCompileError as e:
|
|
266
|
+
probs.append(f"{path}: {e.msg}")
|
|
267
|
+
else:
|
|
268
|
+
for mod in re.findall(r"""from\s+["']([^"']+)["']""", code):
|
|
269
|
+
if mod not in ("../runtime/rules", "../api", "../runtime/spec"):
|
|
270
|
+
probs.append(f"{path}: import '{mod}' is not allowed")
|
|
271
|
+
return probs or ts_problems(out, {p: c for p, c in parsed["files"].items() if p.endswith(".ts")})
|
|
272
|
+
|
|
273
|
+
sections = {"rule": rule.model_dump_json(indent=1, exclude_none=True), "spec": compact_spec(spec),
|
|
274
|
+
"behaviors": compact_behaviors({**inv, "behaviors": [b for b in inv["behaviors"] if b["id"] in rule.covers]}),
|
|
275
|
+
"sources": script_sources(legacy, inv)}
|
|
276
|
+
res = agent.run(f"{spec.screen.id}-{handler}", sections, validate)
|
|
277
|
+
for rel, content in res.output["files"].items():
|
|
278
|
+
(out / rel).parent.mkdir(parents=True, exist_ok=True)
|
|
279
|
+
(out / rel).write_text(content, encoding="utf-8")
|
|
280
|
+
return res
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ------------------------------------------------------------ review agent
|
|
284
|
+
def run_review_agent(agent: Agent, legacy: Legacy, inv: dict, spec: Spec, out: Path) -> AgentResult:
|
|
285
|
+
def validate(r: dict) -> list[str]:
|
|
286
|
+
probs = []
|
|
287
|
+
if r.get("verdict") not in ("pass", "fail"):
|
|
288
|
+
probs.append("verdict must be 'pass' or 'fail'")
|
|
289
|
+
if not isinstance(r.get("findings"), list):
|
|
290
|
+
probs.append("findings must be a list")
|
|
291
|
+
else:
|
|
292
|
+
for i, f in enumerate(r["findings"]):
|
|
293
|
+
if f.get("severity") not in ("blocker", "major", "minor"):
|
|
294
|
+
probs.append(f"findings[{i}].severity must be blocker|major|minor")
|
|
295
|
+
if not f.get("problem"):
|
|
296
|
+
probs.append(f"findings[{i}] needs a problem description")
|
|
297
|
+
if r.get("verdict") == "pass" and any(f.get("severity") in ("blocker", "major") for f in r["findings"]):
|
|
298
|
+
probs.append("verdict cannot be 'pass' with blocker/major findings")
|
|
299
|
+
return probs
|
|
300
|
+
|
|
301
|
+
code = []
|
|
302
|
+
sid = spec.screen.id
|
|
303
|
+
for p in sorted((out / "frontend" / "src" / "screens" / sid).glob("*.ts*")) if (out / "frontend" / "src" / "screens" / sid).exists() else []:
|
|
304
|
+
code.append(f'<file path="{p.relative_to(out).as_posix()}">\n{p.read_text(encoding="utf-8")}\n</file>')
|
|
305
|
+
for r in spec.rules:
|
|
306
|
+
if r.kind == "custom":
|
|
307
|
+
for rel in (f"frontend/src/custom/{r.params['handler']}.ts", f"backend/app/custom/{r.params['handler']}.py"):
|
|
308
|
+
if (out / rel).exists():
|
|
309
|
+
code.append(f'<file path="{rel}">\n{(out / rel).read_text(encoding="utf-8")}\n</file>')
|
|
310
|
+
sections = {"behaviors": compact_behaviors(inv), "spec": spec.model_dump_json(indent=1, exclude_none=True),
|
|
311
|
+
"sources": script_sources(legacy, inv), "generated_code": "\n".join(code),
|
|
312
|
+
"note": "List forms are rendered by the shared form engine directly from the spec rules; review the rules for them."}
|
|
313
|
+
return agent.run(sid, sections, validate)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# --------------------------------------------------------------- fix agent
|
|
317
|
+
def run_fix_agent(agent: Agent, inv: dict, spec: Spec, failures: str, out: Path, task_id: str) -> tuple[AgentResult, Optional[Spec]]:
|
|
318
|
+
sid = spec.screen.id
|
|
319
|
+
editable = {p.relative_to(out).as_posix() for p in (out / "frontend" / "src" / "screens" / sid).glob("*.ts*")} if (out / "frontend" / "src" / "screens" / sid).exists() else set()
|
|
320
|
+
for r in spec.rules:
|
|
321
|
+
if r.kind == "custom":
|
|
322
|
+
editable |= {f"frontend/src/custom/{r.params['handler']}.ts", f"backend/app/custom/{r.params['handler']}.py"}
|
|
323
|
+
holder: dict = {}
|
|
324
|
+
|
|
325
|
+
def validate(parsed: dict) -> list[str]:
|
|
326
|
+
probs = [f"{p}: not an editable file (editable: {sorted(editable)})" for p in parsed["files"] if p not in editable]
|
|
327
|
+
if parsed.get("spec_patch"):
|
|
328
|
+
patch = parsed["spec_patch"]
|
|
329
|
+
d = json.loads(spec.model_dump_json())
|
|
330
|
+
by_id = {r["id"]: i for i, r in enumerate(d["rules"])}
|
|
331
|
+
for r in patch.get("rules", []):
|
|
332
|
+
if r.get("id") in by_id:
|
|
333
|
+
d["rules"][by_id[r["id"]]] = r
|
|
334
|
+
else:
|
|
335
|
+
d["rules"].append(r)
|
|
336
|
+
try:
|
|
337
|
+
new = Spec.model_validate(d)
|
|
338
|
+
except ValidationError as e:
|
|
339
|
+
return probs + _pydantic_problems(e)
|
|
340
|
+
rep = check(new, inv)
|
|
341
|
+
probs += rep.problems
|
|
342
|
+
holder["spec"] = new
|
|
343
|
+
if not probs and parsed["files"]:
|
|
344
|
+
probs += ts_problems(out, {p: c for p, c in parsed["files"].items() if p.endswith((".ts", ".tsx"))})
|
|
345
|
+
return probs
|
|
346
|
+
|
|
347
|
+
files = "\n".join(f'<file path="{p}">\n{(out / p).read_text(encoding="utf-8")}\n</file>' for p in sorted(editable) if (out / p).exists())
|
|
348
|
+
sections = {"failures": failures[-12000:], "files": files, "spec": compact_spec(spec)}
|
|
349
|
+
res = agent.run(task_id, sections, validate)
|
|
350
|
+
for rel, content in res.output["files"].items():
|
|
351
|
+
(out / rel).write_text(content, encoding="utf-8")
|
|
352
|
+
return res, holder.get("spec")
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# ------------------------------------------------------------- dbmap agent
|
|
356
|
+
def run_dbmap_agent(agent: Agent, lists: dict, db: dict, draft_mapping: dict, notes: list[str]) -> tuple[dict, list[str], AgentResult]:
|
|
357
|
+
from ..dbmap import validate as validate_mapping
|
|
358
|
+
holder: dict = {}
|
|
359
|
+
|
|
360
|
+
def validate(out: dict) -> list[str]:
|
|
361
|
+
m = out.get("mapping")
|
|
362
|
+
if not isinstance(m, dict) or "lists" not in m:
|
|
363
|
+
return ['Return {"mapping": {...}, "notes": [...]} with mapping.lists']
|
|
364
|
+
problems, _ = validate_mapping(m, lists, db)
|
|
365
|
+
if not problems:
|
|
366
|
+
holder["mapping"] = m
|
|
367
|
+
return problems
|
|
368
|
+
|
|
369
|
+
compact_lists = {k: {"title": v["title"], "fields": [{x: f.get(x) for x in ("name", "label", "type", "required", "multi", "lookup") if f.get(x) is not None}
|
|
370
|
+
for f in v["fields"]]} for k, v in lists.items()}
|
|
371
|
+
sections = {"sharepoint_lists": json.dumps(compact_lists, indent=1), "database_schema": json.dumps(db["tables"], indent=1),
|
|
372
|
+
"draft_mapping": json.dumps(draft_mapping, indent=1), "unresolved": "\n".join(f"- {n}" for n in notes)}
|
|
373
|
+
res = agent.run("db-mapping", sections, validate)
|
|
374
|
+
return holder["mapping"], list(res.output.get("notes") or []), res
|
|
File without changes
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Parse classic SharePoint .aspx pages and web part definitions.
|
|
2
|
+
|
|
3
|
+
Handles the three places page behaviour lives:
|
|
4
|
+
* web parts embedded in the .aspx (SharePoint Designer / site definition forms),
|
|
5
|
+
* web parts added in the browser (exported via the web part manager -> *.webparts.json),
|
|
6
|
+
* script/link tags in the page itself and inside web part HTML.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import html
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
TAG_VALUE = r"<(?:\w+:)?{tag}\b[^>]*>(.*?)</(?:\w+:)?{tag}>"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _tag(xml: str, tag: str) -> Optional[str]:
|
|
21
|
+
m = re.search(TAG_VALUE.format(tag=tag), xml, re.S | re.I)
|
|
22
|
+
if not m:
|
|
23
|
+
return None
|
|
24
|
+
val = m.group(1).strip()
|
|
25
|
+
if val.startswith("<![CDATA["):
|
|
26
|
+
val = val[9:-3]
|
|
27
|
+
return html.unescape(val)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _attrs(tag_text: str) -> dict[str, str]:
|
|
31
|
+
out = {}
|
|
32
|
+
for m in re.finditer(r'([\w:\-]+)\s*=\s*"([^"]*)"', tag_text, re.S):
|
|
33
|
+
out[m.group(1)] = html.unescape(m.group(2))
|
|
34
|
+
return out
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class WebPart:
|
|
39
|
+
id: str
|
|
40
|
+
type: str # ListForm | ScriptEditor | ContentEditor | ListView | Other:<name>
|
|
41
|
+
title: str
|
|
42
|
+
zone: Optional[str] = None
|
|
43
|
+
order: int = 0
|
|
44
|
+
source: str = "aspx" # aspx | webpartmanager
|
|
45
|
+
control_mode: Optional[str] = None # New | Edit | Display (ListForm)
|
|
46
|
+
list_id: Optional[str] = None
|
|
47
|
+
list_url: Optional[str] = None
|
|
48
|
+
content: Optional[str] = None # inline HTML/script
|
|
49
|
+
content_link: Optional[str] = None # CEWP link to an .html file
|
|
50
|
+
jslink: Optional[str] = None
|
|
51
|
+
view_xml: Optional[str] = None
|
|
52
|
+
hidden: bool = False
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict:
|
|
55
|
+
return {k: v for k, v in self.__dict__.items() if v not in (None, "", False) or k in ("id", "type", "title")}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class ParsedPage:
|
|
60
|
+
path: str
|
|
61
|
+
master: Optional[str] = None
|
|
62
|
+
title: Optional[str] = None
|
|
63
|
+
web_parts: list[WebPart] = field(default_factory=list)
|
|
64
|
+
stylesheets: list[str] = field(default_factory=list)
|
|
65
|
+
scripts: list[str] = field(default_factory=list) # src URLs in page order
|
|
66
|
+
inline_scripts: list[dict] = field(default_factory=list) # {where, code}
|
|
67
|
+
html_fragments: list[dict] = field(default_factory=list) # {web_part, html} non-script markup authored in web parts
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def form_mode(self) -> Optional[str]:
|
|
71
|
+
for wp in self.web_parts:
|
|
72
|
+
if wp.type == "ListForm" and wp.control_mode:
|
|
73
|
+
return wp.control_mode
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def list_id(self) -> Optional[str]:
|
|
78
|
+
for wp in self.web_parts:
|
|
79
|
+
if wp.type in ("ListForm",) and wp.list_id:
|
|
80
|
+
return wp.list_id
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
def to_dict(self) -> dict:
|
|
84
|
+
return {
|
|
85
|
+
"path": self.path, "master": self.master, "title": self.title, "form_mode": self.form_mode,
|
|
86
|
+
"list_id": self.list_id, "web_parts": [w.to_dict() for w in self.web_parts],
|
|
87
|
+
"stylesheets": self.stylesheets, "scripts": self.scripts,
|
|
88
|
+
"inline_scripts": [{"where": s["where"], "lines": s["code"].count("\n") + 1} for s in self.inline_scripts],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------------ markup
|
|
93
|
+
SCRIPT_SRC = re.compile(r"<script\b[^>]*\bsrc\s*=\s*[\"']([^\"']+)[\"'][^>]*>", re.I)
|
|
94
|
+
SCRIPT_INLINE = re.compile(r"<script\b(?![^>]*\bsrc\s*=)[^>]*>(.*?)</script>", re.I | re.S)
|
|
95
|
+
LINK_CSS = re.compile(r"<link\b[^>]*\bhref\s*=\s*[\"']([^\"']+\.css[^\"']*)[\"'][^>]*>", re.I)
|
|
96
|
+
CSS_REG = re.compile(r"<SharePoint:CssRegistration\b[^>]*\bName\s*=\s*\"([^\"]+)\"", re.I)
|
|
97
|
+
SCRIPT_LINK = re.compile(r"<SharePoint:ScriptLink\b[^>]*\bName\s*=\s*\"([^\"]+)\"", re.I)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def scan_html(markup: str) -> tuple[list[str], list[str], list[str], str]:
|
|
101
|
+
"""-> (script srcs, inline scripts, stylesheets, markup without scripts)."""
|
|
102
|
+
srcs = [html.unescape(s) for s in SCRIPT_SRC.findall(markup)]
|
|
103
|
+
inline = [c for c in SCRIPT_INLINE.findall(markup) if c.strip()]
|
|
104
|
+
css = [html.unescape(s) for s in LINK_CSS.findall(markup)] + CSS_REG.findall(markup)
|
|
105
|
+
stripped = re.sub(r"<script\b.*?</script>", "", markup, flags=re.I | re.S).strip()
|
|
106
|
+
return srcs, inline, css, stripped
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------- web part parsing
|
|
110
|
+
WP_BLOCK = re.compile(r"<WebPartPages:(\w+)\b([^>]*?)(/>|>(.*?)</WebPartPages:\1>)", re.S | re.I)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _wp_from_markup(kind: str, attr_text: str, body: str, order: int) -> WebPart:
|
|
114
|
+
attrs = _attrs(attr_text)
|
|
115
|
+
body = body or ""
|
|
116
|
+
wid = attrs.get("__WebPartId") or _tag(body, "ID") or attrs.get("ID") or f"wp{order}"
|
|
117
|
+
title = attrs.get("Title") or _tag(body, "Title") or kind
|
|
118
|
+
k = kind.lower()
|
|
119
|
+
if k == "listformwebpart":
|
|
120
|
+
return WebPart(id=wid, type="ListForm", title=title, order=order, control_mode=_tag(body, "ControlMode"),
|
|
121
|
+
list_id=(_tag(body, "ListName") or _tag(body, "ListId") or "").strip("{}").lower() or None,
|
|
122
|
+
jslink=_tag(body, "JSLink"), hidden=(_tag(body, "IsVisible") or "true").lower() == "false")
|
|
123
|
+
if k == "scripteditorwebpart":
|
|
124
|
+
return WebPart(id=wid, type="ScriptEditor", title=title, order=order, content=attrs.get("Content") or _tag(body, "Content"))
|
|
125
|
+
if k == "contenteditorwebpart":
|
|
126
|
+
return WebPart(id=wid, type="ContentEditor", title=title, order=order,
|
|
127
|
+
content=_tag(body, "Content") or attrs.get("Content"),
|
|
128
|
+
content_link=_tag(body, "ContentLink") or attrs.get("ContentLink"))
|
|
129
|
+
if k in ("xsltlistviewwebpart", "listviewwebpart"):
|
|
130
|
+
return WebPart(id=wid, type="ListView", title=title, order=order,
|
|
131
|
+
list_id=(attrs.get("ListName") or attrs.get("ListId") or _tag(body, "ListName") or "").strip("{}").lower() or None,
|
|
132
|
+
list_url=attrs.get("ListUrl") or _tag(body, "ListUrl"),
|
|
133
|
+
view_xml=attrs.get("XmlDefinition") or _tag(body, "XmlDefinition"),
|
|
134
|
+
jslink=attrs.get("JSLink") or _tag(body, "JSLink"))
|
|
135
|
+
if k in ("webpartzone",):
|
|
136
|
+
raise ValueError("zone")
|
|
137
|
+
return WebPart(id=wid, type=f"Other:{kind}", title=title, order=order, content=attrs.get("Content"))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def web_parts_from_aspx(source: str) -> list[WebPart]:
|
|
141
|
+
out: list[WebPart] = []
|
|
142
|
+
# zones contain web parts; iterate zones to record the zone id
|
|
143
|
+
zones = list(re.finditer(r"<WebPartPages:WebPartZone\b([^>]*)>(.*?)</WebPartPages:WebPartZone>", source, re.S | re.I))
|
|
144
|
+
regions = [(_attrs(z.group(1)).get("ID"), z.group(2)) for z in zones] or [(None, source)]
|
|
145
|
+
order = 0
|
|
146
|
+
for zone_id, region in regions:
|
|
147
|
+
for m in WP_BLOCK.finditer(region):
|
|
148
|
+
if m.group(1).lower() == "webpartzone":
|
|
149
|
+
continue
|
|
150
|
+
order += 1
|
|
151
|
+
try:
|
|
152
|
+
wp = _wp_from_markup(m.group(1), m.group(2), m.group(4) or "", order)
|
|
153
|
+
except ValueError:
|
|
154
|
+
continue
|
|
155
|
+
wp.zone = zone_id
|
|
156
|
+
out.append(wp)
|
|
157
|
+
return out
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def web_parts_from_manager(items: list[dict]) -> list[WebPart]:
|
|
161
|
+
"""Classify REST web part manager output (properties only; SharePoint gives no type name)."""
|
|
162
|
+
out = []
|
|
163
|
+
for i, d in enumerate(items):
|
|
164
|
+
wpd = d.get("WebPart") or {}
|
|
165
|
+
props = wpd.get("Properties") or {}
|
|
166
|
+
if isinstance(props, dict) and "results" in props: # some versions wrap it
|
|
167
|
+
props = {p.get("Key"): p.get("Value") for p in props["results"]}
|
|
168
|
+
title = props.get("Title") or wpd.get("Title") or f"Web part {i + 1}"
|
|
169
|
+
common = dict(id=str(d.get("Id")), title=title, zone=d.get("ZoneId"), order=wpd.get("ZoneIndex", i),
|
|
170
|
+
source="webpartmanager", hidden=bool(wpd.get("Hidden") or wpd.get("IsClosed")))
|
|
171
|
+
if props.get("XmlDefinition") or props.get("ListUrl") or props.get("ViewGuid"):
|
|
172
|
+
wp = WebPart(type="ListView", list_id=(props.get("ListName") or props.get("ListId") or "").strip("{}").lower() or None,
|
|
173
|
+
list_url=props.get("ListUrl"), view_xml=props.get("XmlDefinition"), jslink=props.get("JSLink") or None, **common)
|
|
174
|
+
elif props.get("ContentLink"):
|
|
175
|
+
wp = WebPart(type="ContentEditor", content_link=props.get("ContentLink"), content=props.get("Content"), **common)
|
|
176
|
+
elif props.get("ControlMode") or props.get("FormType"):
|
|
177
|
+
wp = WebPart(type="ListForm", control_mode=props.get("ControlMode"),
|
|
178
|
+
list_id=(props.get("ListName") or props.get("ListId") or "").strip("{}").lower() or None,
|
|
179
|
+
jslink=props.get("JSLink") or None, **common)
|
|
180
|
+
elif props.get("Content") is not None:
|
|
181
|
+
# Script Editor and inline Content Editor are indistinguishable over REST; both are "authored HTML"
|
|
182
|
+
wp = WebPart(type="ScriptEditor", content=props.get("Content"), **common)
|
|
183
|
+
else:
|
|
184
|
+
wp = WebPart(type="Other", **common)
|
|
185
|
+
out.append(wp)
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def parse_page(path: str, source: str, webparts_json: Optional[list[dict]] = None) -> ParsedPage:
|
|
190
|
+
page = ParsedPage(path=path)
|
|
191
|
+
m = re.search(r'MasterPageFile\s*=\s*"([^"]+)"', source)
|
|
192
|
+
page.master = m.group(1) if m else None
|
|
193
|
+
t = re.search(r'ContentPlaceHolderId="PlaceHolderPageTitle"[^>]*>(.*?)</asp:Content>', source, re.S)
|
|
194
|
+
if t:
|
|
195
|
+
page.title = re.sub(r"<[^>]+>", "", t.group(1)).strip() or None
|
|
196
|
+
|
|
197
|
+
wps = web_parts_from_aspx(source)
|
|
198
|
+
if webparts_json:
|
|
199
|
+
seen = {w.id.strip("{}").lower() for w in wps}
|
|
200
|
+
for w in web_parts_from_manager(webparts_json):
|
|
201
|
+
if w.id.strip("{}").lower() not in seen:
|
|
202
|
+
wps.append(w)
|
|
203
|
+
page.web_parts = wps
|
|
204
|
+
|
|
205
|
+
# page-level tags (outside web parts)
|
|
206
|
+
outside = WP_BLOCK.sub("", source)
|
|
207
|
+
srcs, inline, css, _ = scan_html(outside)
|
|
208
|
+
page.scripts += srcs + SCRIPT_LINK.findall(outside)
|
|
209
|
+
page.stylesheets += css
|
|
210
|
+
page.inline_scripts += [{"where": "page", "code": c} for c in inline]
|
|
211
|
+
|
|
212
|
+
for w in wps:
|
|
213
|
+
if w.jslink:
|
|
214
|
+
page.scripts += [j.strip() for j in w.jslink.split("|") if j.strip()]
|
|
215
|
+
if w.content:
|
|
216
|
+
srcs, inline, css, stripped = scan_html(w.content)
|
|
217
|
+
page.scripts += srcs
|
|
218
|
+
page.stylesheets += css
|
|
219
|
+
page.inline_scripts += [{"where": f"webpart:{w.title}", "code": c} for c in inline]
|
|
220
|
+
if stripped:
|
|
221
|
+
page.html_fragments.append({"web_part": w.title, "html": stripped})
|
|
222
|
+
return page
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def asset_refs(page: ParsedPage) -> list[str]:
|
|
226
|
+
refs = list(page.scripts) + list(page.stylesheets)
|
|
227
|
+
refs += [w.content_link for w in page.web_parts if w.content_link]
|
|
228
|
+
seen, out = set(), []
|
|
229
|
+
for r in refs:
|
|
230
|
+
r = resolve_tokens(r)
|
|
231
|
+
if r and r not in seen:
|
|
232
|
+
seen.add(r)
|
|
233
|
+
out.append(r)
|
|
234
|
+
return out
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def resolve_tokens(url: str, site_rel: str = "") -> str:
|
|
238
|
+
"""Expand ~site/~sitecollection tokens used in JSLink/ScriptLink."""
|
|
239
|
+
url = url.strip()
|
|
240
|
+
for tok in ("~sitecollection", "~site", "~layouts"):
|
|
241
|
+
if url.lower().startswith(tok):
|
|
242
|
+
rest = url[len(tok):]
|
|
243
|
+
return ("/_layouts/15" + rest) if tok == "~layouts" else (site_rel + rest)
|
|
244
|
+
return url
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def load_page(legacy_dir: Path, rel_path: str) -> ParsedPage:
|
|
248
|
+
src = (legacy_dir / "pages" / rel_path).read_text(encoding="utf-8", errors="replace")
|
|
249
|
+
wpf = legacy_dir / "pages" / (rel_path + ".webparts.json")
|
|
250
|
+
wpj = None
|
|
251
|
+
if wpf.exists():
|
|
252
|
+
data = json.loads(wpf.read_text(encoding="utf-8"))
|
|
253
|
+
wpj = data.get("d", {}).get("results", data) if isinstance(data, dict) else data
|
|
254
|
+
return parse_page(rel_path, src, wpj)
|