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,95 @@
1
+ /* SharePoint 2013/2016 look (corev15). Legacy site CSS is appended in legacy.css. */
2
+ :root {
3
+ --sp-blue: #0072c6;
4
+ --sp-text: #444;
5
+ --sp-heading: #262626;
6
+ --sp-border: #ababab;
7
+ --sp-subtle: #c6c6c6;
8
+ --sp-error: #bf0000;
9
+ --sp-accent: #0072c6;
10
+ }
11
+ * { box-sizing: border-box; }
12
+ body { margin: 0; font-family: "Segoe UI", "Segoe", Tahoma, Helvetica, Arial, sans-serif; font-size: 13px; color: var(--sp-text); background: #fff; }
13
+ a { color: var(--sp-blue); text-decoration: none; }
14
+ a:hover { text-decoration: underline; }
15
+
16
+ .sp-suitebar { height: 30px; background: var(--sp-blue); color: #fff; display: flex; align-items: center; justify-content: space-between; padding: 0 12px; }
17
+ .sp-brand { font-size: 17px; font-weight: 300; }
18
+ .sp-suite-right { display: flex; gap: 16px; align-items: center; font-size: 13px; }
19
+ .sp-devuser select { font: inherit; font-size: 12px; margin-left: 4px; }
20
+ .sp-titlerow { padding: 18px 20px 10px 200px; }
21
+ .sp-sitetitle a { color: var(--sp-blue); font-size: 12px; }
22
+ .sp-pagetitle { font-size: 2em; font-weight: 300; color: var(--sp-heading); margin: 2px 0 0; }
23
+ .sp-body { display: flex; }
24
+ .sp-quicklaunch { width: 180px; flex: 0 0 180px; padding: 6px 0 0 20px; }
25
+ .sp-quicklaunch ul { list-style: none; margin: 0; padding: 0; }
26
+ .sp-quicklaunch li a { display: block; padding: 6px 8px; font-size: 15px; color: var(--sp-text); }
27
+ .sp-quicklaunch li.selected a { background: #e5e5e5; }
28
+ .sp-content { flex: 1; min-width: 0; padding: 4px 20px 40px; overflow-x: auto; }
29
+ @media (max-width: 760px) {
30
+ .sp-titlerow { padding-left: 16px; }
31
+ .sp-quicklaunch { display: none; }
32
+ .sp-content { padding: 4px 16px 32px; }
33
+ .ms-formtable td.ms-formlabel { width: auto !important; display: block; }
34
+ .ms-formtable td.ms-formbody { display: block; width: auto; }
35
+ .ms-formtable, .ms-formtable > tbody, .ms-formtable > tbody > tr { display: block; width: 100%; }
36
+ .ms-formtable td { padding-right: 0; }
37
+ }
38
+
39
+ /* list forms */
40
+ .ms-formtable { border-collapse: collapse; margin: 8px 0 12px; }
41
+ .ms-formtable td { vertical-align: top; padding: 6px 8px 8px 0; }
42
+ .ms-formlabel { width: 165px; }
43
+ .ms-standardheader { font-size: 13px; font-weight: 400; color: var(--sp-heading); margin: 2px 0; }
44
+ .ms-accentText { color: var(--sp-error); }
45
+ .ms-formbody { width: 450px; }
46
+ .ms-long { width: 386px; max-width: 100%; }
47
+ .ms-formbody input[type="text"], .ms-formbody input[type="date"], .ms-formbody input[type="datetime-local"], .ms-formbody select, .ms-formbody textarea {
48
+ font: inherit; color: var(--sp-text); border: 1px solid var(--sp-border); padding: 3px 4px; background: #fff;
49
+ }
50
+ .ms-formbody input:disabled, .ms-formbody select:disabled, .ms-formbody textarea:disabled { background: #fdfdfd; color: #b1b1b1; border-color: #e1e1e1; }
51
+ .ms-formbody select { min-width: 120px; }
52
+ .ms-metadata { color: #777; font-size: 12px; margin-top: 2px; }
53
+ .ms-formvalidation { color: var(--sp-error); font-size: 12px; }
54
+ .sp-currency { margin-right: 3px; }
55
+ .sp-radios label, .sp-checks label { display: block; }
56
+ .sp-toolbar { display: flex; gap: 8px; justify-content: flex-end; width: 615px; max-width: 100%; margin: 6px 0; }
57
+ .ms-ButtonHeightWidth { font: inherit; min-width: 80px; height: 30px; padding: 0 12px; border: 1px solid var(--sp-border); background: #fdfdfd; color: var(--sp-text); cursor: pointer; }
58
+ .ms-ButtonHeightWidth:hover { border-color: #92c0e0; background: #e6f2fa; }
59
+ .ms-ButtonHeightWidth:disabled { color: #b1b1b1; cursor: default; }
60
+ .sp-itemmeta { color: #777; font-size: 12px; margin: 10px 0; }
61
+ .sp-link { background: none; border: 0; color: var(--sp-blue); cursor: pointer; font: inherit; margin-left: 8px; }
62
+ .sp-loading { color: #777; padding: 12px 0; }
63
+
64
+ /* people picker */
65
+ .sp-peoplepicker { position: relative; border: 1px solid var(--sp-border); min-height: 26px; width: 386px; max-width: 100%; padding: 2px 4px; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; background: #fff; }
66
+ .sp-peoplepicker.is-disabled { background: #fdfdfd; border-color: #e1e1e1; }
67
+ .sp-peoplepicker input { border: 0 !important; outline: none; flex: 1; min-width: 120px; padding: 2px; }
68
+ .ms-entity-resolved { background: #f4f4f4; padding: 1px 4px; color: var(--sp-heading); }
69
+ .ms-entity-resolved button { border: 0; background: none; cursor: pointer; color: #777; margin-left: 2px; }
70
+ .sp-picker-hits { position: absolute; top: 100%; left: -1px; right: -1px; z-index: 5; list-style: none; margin: 0; padding: 0; background: #fff; border: 1px solid var(--sp-border); }
71
+ .sp-picker-hits li { padding: 6px 8px; cursor: pointer; }
72
+ .sp-picker-hits li:hover { background: #e6f2fa; }
73
+ .sp-muted { color: #777; }
74
+
75
+ /* banners */
76
+ .sp-banner { padding: 8px 12px; margin: 0 0 12px; font-weight: 600; }
77
+ .sp-banner-error { background: #fde7e9; border: 1px solid #a80000; color: #a80000; }
78
+ .sp-banner-info { background: #f4f4f4; border-left: 4px solid var(--sp-blue); font-weight: 400; }
79
+
80
+ /* list views */
81
+ .ms-webpart-titleText { font-size: 1.46em; font-weight: 300; color: var(--sp-heading); margin: 16px 0 8px; }
82
+ .sp-newitem { margin: 8px 0; font-size: 15px; }
83
+ .ms-listviewtable { border-collapse: collapse; width: 100%; }
84
+ .ms-listviewtable th { text-align: left; font-weight: 400; color: #777; border-bottom: 1px solid var(--sp-subtle); padding: 6px 8px; white-space: nowrap; }
85
+ .ms-listviewtable td { padding: 6px 8px; border-bottom: 1px solid #eaeaea; }
86
+ .nobr { white-space: nowrap; }
87
+
88
+ /* Narrow screens: stack legacy layout tables, wrap tile rows, let wide tables scroll inside their own box */
89
+ @media (max-width: 760px) {
90
+ .sp-content table[width="100%"] > tbody > tr > td { display: block; width: auto !important; }
91
+ .db-tiles { flex-wrap: wrap; }
92
+ .db-tile { min-width: calc(50% - 6px); }
93
+ .db-table, .ms-listviewtable { display: block; overflow-x: auto; }
94
+ .sp-content { padding-right: max(16px, env(safe-area-inset-right, 0px)); }
95
+ }
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "noUnusedLocals": false,
10
+ "noUnusedParameters": false,
11
+ "resolveJsonModule": true,
12
+ "isolatedModules": true,
13
+ "skipLibCheck": true,
14
+ "allowImportingTsExtensions": false,
15
+ "noEmit": true
16
+ },
17
+ "include": ["src"]
18
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ server: { port: 5173, proxy: { "/api": "http://localhost:8000" } },
7
+ });
File without changes
@@ -0,0 +1,210 @@
1
+ """Read-only SharePoint REST client (SharePoint Server 2013/2016/2019/SE and SharePoint Online).
2
+
3
+ Only GET requests are ever issued: the extractor must never change the source site.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ from typing import Any, Optional
9
+ from urllib.parse import quote, urlsplit
10
+
11
+ import requests
12
+
13
+ from ..config import SharePointConfig, secret
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ class SharePointError(RuntimeError):
19
+ def __init__(self, message: str, status: Optional[int] = None):
20
+ super().__init__(message)
21
+ self.status = status
22
+
23
+
24
+ def odata_quote(value: str) -> str:
25
+ """Escape a value for use inside '...' in a SharePoint REST path."""
26
+ return quote(value.replace("'", "''"), safe="/:")
27
+
28
+
29
+ def unwrap(data: Any) -> Any:
30
+ """Strip odata=verbose wrappers: {"d": {...}} and {"results": [...]}."""
31
+ if isinstance(data, dict) and "d" in data and len(data) == 1:
32
+ data = data["d"]
33
+ if isinstance(data, dict) and "results" in data and isinstance(data["results"], list):
34
+ return data["results"]
35
+ return data
36
+
37
+
38
+ class ItemReadBlocked(SharePointError):
39
+ pass
40
+
41
+
42
+ class SharePointClient:
43
+ """`policy` limits which hosts may be contacted; `item_reads=False` forbids reading list items, attachments,
44
+ user profiles and rendered pages (anything that carries business data), leaving only code and schema."""
45
+
46
+ def __init__(self, cfg: SharePointConfig, session: Optional[requests.Session] = None, policy=None, item_reads: bool = True):
47
+ self.cfg = cfg
48
+ self.item_reads = item_reads
49
+ self.site_url = cfg.site_url
50
+ parts = urlsplit(cfg.site_url)
51
+ self.origin = f"{parts.scheme}://{parts.netloc}"
52
+ self.server_relative = parts.path.rstrip("/") or "/"
53
+ self.session = session or requests.Session()
54
+ self.session.verify = cfg.verify_ssl
55
+ self.session.headers.update({"Accept": "application/json;odata=verbose", "User-Agent": "sp2react/0.1 (read-only)"})
56
+ self.session.headers.update(cfg.extra_headers)
57
+ if policy is not None:
58
+ policy.guard(self.session)
59
+ send = self.session.send
60
+
61
+ def get_only(request, **kwargs): # the extractor must never change the source site
62
+ if request.method not in ("GET", "HEAD"):
63
+ raise SharePointError(f"sp2react only reads from SharePoint; refused {request.method} {request.url}")
64
+ return send(request, **kwargs)
65
+
66
+ self.session.send = get_only # type: ignore[method-assign]
67
+ self._configure_auth()
68
+
69
+ # ------------------------------------------------------------------ auth
70
+ def _configure_auth(self) -> None:
71
+ mode = self.cfg.auth
72
+ if mode == "ntlm":
73
+ try:
74
+ from requests_ntlm import HttpNtlmAuth # type: ignore
75
+ except ImportError as e: # pragma: no cover - depends on optional extra
76
+ raise SharePointError("NTLM auth needs the extra: pip install 'sp2react[ntlm]'") from e
77
+ self.session.auth = HttpNtlmAuth(
78
+ secret(self.cfg.username_env, "SharePoint user name (DOMAIN\\user)"),
79
+ secret(self.cfg.password_env, "SharePoint password"),
80
+ )
81
+ elif mode == "cookie":
82
+ self.session.headers["Cookie"] = secret(self.cfg.cookie_env, "SharePoint session cookie")
83
+ elif mode == "bearer":
84
+ self.session.headers["Authorization"] = "Bearer " + secret(self.cfg.token_env, "SharePoint bearer token")
85
+
86
+ # --------------------------------------------------------------- helpers
87
+ def absolute(self, url_or_path: str) -> str:
88
+ if url_or_path.startswith(("http://", "https://")):
89
+ return url_or_path
90
+ if url_or_path.startswith("/"):
91
+ return self.origin + url_or_path
92
+ return f"{self.site_url}/{url_or_path}"
93
+
94
+ def site_relative(self, server_relative_url: str) -> str:
95
+ """'/sites/x/Lists/A/NewForm.aspx' -> 'Lists/A/NewForm.aspx'."""
96
+ prefix = self.server_relative.rstrip("/") + "/"
97
+ if server_relative_url.lower().startswith(prefix.lower()):
98
+ return server_relative_url[len(prefix):]
99
+ return server_relative_url.lstrip("/")
100
+
101
+ def server_relative_url(self, path: str) -> str:
102
+ if path.startswith(("http://", "https://")):
103
+ return urlsplit(path).path
104
+ if path.startswith("/"):
105
+ return path
106
+ return self.server_relative.rstrip("/") + "/" + path
107
+
108
+ def _data(self, what: str) -> None:
109
+ if not self.item_reads:
110
+ raise ItemReadBlocked(f"Reading {what} from SharePoint is disabled (data_source=database / security.sharepoint_item_reads=false).")
111
+
112
+ def _get(self, url: str, accept: Optional[str] = None, params: Optional[dict] = None) -> requests.Response:
113
+ headers = {"Accept": accept} if accept else None
114
+ try:
115
+ r = self.session.get(self.absolute(url), headers=headers, params=params, timeout=self.cfg.timeout)
116
+ except requests.RequestException as e:
117
+ raise SharePointError(f"GET {url} failed: {e}") from e
118
+ if r.status_code >= 400:
119
+ raise SharePointError(f"GET {url} -> HTTP {r.status_code}: {r.text[:300]}", r.status_code)
120
+ return r
121
+
122
+ def api(self, path: str, params: Optional[dict] = None) -> Any:
123
+ r = self._get(f"{self.site_url}/_api/{path.lstrip('/')}", params=params)
124
+ return unwrap(r.json())
125
+
126
+ def text(self, url: str, accept: str = "*/*") -> str:
127
+ r = self._get(url, accept=accept)
128
+ r.encoding = r.encoding or "utf-8"
129
+ return r.text
130
+
131
+ # ------------------------------------------------------------ endpoints
132
+ def web(self) -> dict:
133
+ return self.api("web", {"$select": "Title,Url,ServerRelativeUrl,UIVersion,Language"})
134
+
135
+ def lists(self) -> list[dict]:
136
+ return self.api("web/lists", {
137
+ "$select": "Id,Title,EntityTypeName,BaseTemplate,BaseType,Hidden,EnableAttachments,EnableVersioning,ItemCount,"
138
+ "ValidationFormula,ValidationMessage,RootFolder/ServerRelativeUrl",
139
+ "$expand": "RootFolder",
140
+ "$filter": "Hidden eq false",
141
+ })
142
+
143
+ def list_forms(self, list_id: str) -> list[dict]:
144
+ return self.api(f"web/lists(guid'{list_id.strip('{}')}')/forms", {"$select": "ServerRelativeUrl,FormType"})
145
+
146
+ def list_views(self, list_id: str) -> list[dict]:
147
+ return self.api(f"web/lists(guid'{list_id.strip('{}')}')/views", {
148
+ "$select": "Title,ServerRelativeUrl,DefaultView,ViewQuery,RowLimit,Hidden",
149
+ "$expand": "ViewFields",
150
+ })
151
+
152
+ def fields(self, list_id: str) -> list[dict]:
153
+ return self.api(f"web/lists(guid'{list_id.strip('{}')}')/fields")
154
+
155
+ def items(self, list_id: str, top: int) -> list[dict]:
156
+ self._data("list items")
157
+ return self.api(f"web/lists(guid'{list_id.strip('{}')}')/items", {"$top": str(top)})
158
+
159
+ def quick_launch(self) -> list[dict]:
160
+ return self.api("web/navigation/quicklaunch", {"$select": "Title,Url"})
161
+
162
+ def all_items(self, list_id: str, page_size: int = 1000):
163
+ """Every item of a list, following SharePoint's __next paging links (works past the 5,000 view threshold
164
+ because it pages by ID). Yields raw odata=verbose items, attachments listed under AttachmentFiles."""
165
+ self._data("list items")
166
+ url = f"{self.site_url}/_api/web/lists(guid'{list_id.strip('{}')}')/items"
167
+ params: Optional[dict] = {"$top": str(page_size), "$orderby": "ID", "$expand": "AttachmentFiles"}
168
+ while url:
169
+ r = self._get(url, params=params)
170
+ data = r.json().get("d", {})
171
+ for it in data.get("results", []):
172
+ yield it
173
+ url, params = data.get("__next"), None
174
+
175
+ def file_bytes(self, server_relative_url: str) -> bytes:
176
+ self._data("attachments")
177
+ url = self.server_relative_url(server_relative_url)
178
+ return self._get(f"{self.site_url}/_api/web/getfilebyserverrelativeurl('{odata_quote(url)}')/$value", accept="*/*").content
179
+
180
+ def site_users(self) -> list[dict]:
181
+ self._data("site users")
182
+ return self.api("web/siteusers", {"$select": "Id,LoginName,Title,Email,PrincipalType"})
183
+
184
+ def site_groups(self) -> list[dict]:
185
+ if not self.item_reads: # group names only; membership (people) stays in SharePoint
186
+ return self.api("web/sitegroups", {"$select": "Id,Title"})
187
+ return self.api("web/sitegroups", {"$select": "Id,Title,Users/LoginName,Users/Title", "$expand": "Users"})
188
+
189
+ def file_text(self, server_relative_url: str) -> str:
190
+ """Raw stored content of a file (the .aspx source, not the rendered page)."""
191
+ url = self.server_relative_url(server_relative_url)
192
+ return self.text(f"{self.site_url}/_api/web/getfilebyserverrelativeurl('{odata_quote(url)}')/$value")
193
+
194
+ def web_parts(self, server_relative_url: str) -> list[dict]:
195
+ """Web parts added through the browser live in the content DB, not the .aspx; this is how to see them."""
196
+ url = self.server_relative_url(server_relative_url)
197
+ return self.api(
198
+ f"web/getfilebyserverrelativeurl('{odata_quote(url)}')/getlimitedwebpartmanager(1)/webparts",
199
+ {"$expand": "WebPart/Properties"},
200
+ )
201
+
202
+ def folder_files(self, server_relative_folder: str) -> list[dict]:
203
+ url = self.server_relative_url(server_relative_folder)
204
+ return self.api(f"web/getfolderbyserverrelativeurl('{odata_quote(url)}')/files", {"$select": "Name,ServerRelativeUrl,TimeLastModified"})
205
+
206
+ def rendered(self, server_relative_url: str) -> str:
207
+ """The page as a browser receives it (shows runtime-injected scripts). Can contain list data, so it is
208
+ only fetched when item reads are allowed."""
209
+ self._data("rendered pages")
210
+ return self.text(self.server_relative_url(server_relative_url), accept="text/html")
@@ -0,0 +1,217 @@
1
+ """Stage 2 (scan and crawl): pull everything the conversion needs from the live site, read-only.
2
+
3
+ Workspace layout written under <workspace>/legacy (identical to tests/fixtures/legacy-site):
4
+ site.json, siteusers.json
5
+ lists/<ListUrlName>/fields.json, items.json
6
+ pages/<site-relative path>.aspx raw stored source
7
+ pages/<site-relative path>.aspx.webparts.json browser-added web parts (web part manager)
8
+ rendered/<site-relative path without .aspx>.html what the browser receives
9
+ files/<site-relative path> custom scripts, css, CEWP html (SiteAssets, Style Library...)
10
+ manifest.json what was fetched and what failed
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import re
17
+ from pathlib import Path
18
+ from typing import Iterable, Optional
19
+
20
+ from ..analysis.aspx import asset_refs, parse_page, resolve_tokens
21
+ from .client import ItemReadBlocked, SharePointClient, SharePointError, unwrap
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+ FORM_TYPES = {8: "New", 6: "Edit", 4: "Display"}
26
+ SKIP_PREFIXES = ("/_layouts/", "/_vti_bin/", "/_catalogs/masterpage/", "/scriptresource.axd", "/webresource.axd")
27
+
28
+
29
+ def _write(path: Path, content) -> None:
30
+ path.parent.mkdir(parents=True, exist_ok=True)
31
+ if isinstance(content, (dict, list)):
32
+ path.write_text(json.dumps(content, indent=2, ensure_ascii=False), encoding="utf-8")
33
+ else:
34
+ path.write_text(content, encoding="utf-8")
35
+
36
+
37
+ def _list_folder(list_entry: dict) -> str:
38
+ return list_entry["url"].rstrip("/").split("/")[-1]
39
+
40
+
41
+ class Extractor:
42
+ def __init__(self, client: SharePointClient, workspace: Path, progress=None):
43
+ self.sp = client
44
+ self.root = workspace / "legacy"
45
+ self.progress = progress or (lambda msg: log.info(msg))
46
+ self.manifest: dict = {"site": client.site_url, "pages": [], "lists": [], "files": [], "errors": []}
47
+
48
+ def _err(self, what: str, e: Exception) -> None:
49
+ self.manifest["errors"].append({"what": what, "error": str(e)[:400]})
50
+ log.warning("%s: %s", what, e)
51
+
52
+ # ----------------------------------------------------------- site/lists
53
+ def site(self) -> dict:
54
+ web = self.sp.web()
55
+ site = {"url": self.sp.site_url, "serverRelativeUrl": web.get("ServerRelativeUrl", self.sp.server_relative),
56
+ "title": web.get("Title"), "uiVersion": web.get("UIVersion"), "lists": [], "groups": [], "users": []}
57
+ for l in self.sp.lists():
58
+ if l.get("BaseType", 0) not in (0, None) or l.get("BaseTemplate") in (101, 109, 119, 851, 700):
59
+ continue
60
+ root = (l.get("RootFolder") or {}).get("ServerRelativeUrl", "")
61
+ entry = {"title": l["Title"], "url": self.sp.site_relative(root), "id": "{" + l["Id"].strip("{}") + "}",
62
+ "entityTypeName": l.get("EntityTypeName"), "enableAttachments": l.get("EnableAttachments", False),
63
+ "enableVersioning": l.get("EnableVersioning", False), "itemCount": l.get("ItemCount"),
64
+ "validationFormula": l.get("ValidationFormula") or None, "validationMessage": l.get("ValidationMessage") or None,
65
+ "forms": {}, "views": {}}
66
+ try:
67
+ for f in self.sp.list_forms(l["Id"]):
68
+ mode = FORM_TYPES.get(f.get("FormType"))
69
+ if mode and mode not in entry["forms"]:
70
+ entry["forms"][mode] = f["ServerRelativeUrl"].split("/")[-1]
71
+ except SharePointError as e:
72
+ self._err(f"forms of {l['Title']}", e)
73
+ try:
74
+ for v in self.sp.list_views(l["Id"]):
75
+ if v.get("Hidden"):
76
+ continue
77
+ vf = v.get("ViewFields") or {}
78
+ fields = unwrap(vf.get("Items")) or []
79
+ name = (v.get("ServerRelativeUrl") or v.get("Title", "View")).split("/")[-1].replace(".aspx", "")
80
+ entry["views"][name] = {"title": v.get("Title"), "fields": fields, "query": v.get("ViewQuery"),
81
+ "rowLimit": v.get("RowLimit"), "default": v.get("DefaultView", False)}
82
+ except SharePointError as e:
83
+ self._err(f"views of {l['Title']}", e)
84
+ site["lists"].append(entry)
85
+ return site
86
+
87
+ def list_data(self, site: dict, sample_items: int) -> None:
88
+ for entry in site["lists"]:
89
+ folder = self.root / "lists" / _list_folder(entry)
90
+ self.progress(f"list {entry['title']}: fields and sample items")
91
+ try:
92
+ _write(folder / "fields.json", {"d": {"results": self.sp.fields(entry["id"])}})
93
+ except SharePointError as e:
94
+ self._err(f"fields of {entry['title']}", e)
95
+ continue
96
+ if not self.sp.item_reads:
97
+ _write(folder / "items.json", {"d": {"results": []}, "note": "list items are not read: data_source=database"})
98
+ else:
99
+ try:
100
+ _write(folder / "items.json", {"d": {"results": self.sp.items(entry["id"], sample_items)}})
101
+ except SharePointError as e:
102
+ self._err(f"items of {entry['title']}", e)
103
+ self.manifest["lists"].append(entry["title"])
104
+
105
+ def principals(self, site: dict) -> None:
106
+ if not self.sp.item_reads:
107
+ site["users"] = []
108
+ try:
109
+ users = self.sp.site_users() if self.sp.item_reads else []
110
+ _write(self.root / "siteusers.json", {"d": {"results": users}})
111
+ site["users"] = [{"login": re.sub(r"^i:0#\.\w\|", "", u.get("LoginName", "")), "title": u.get("Title"), "email": u.get("Email")}
112
+ for u in users if u.get("PrincipalType", 1) == 1]
113
+ except SharePointError as e:
114
+ self._err("site users", e)
115
+ try:
116
+ site["groups"] = [{"name": g["Title"], "members": [re.sub(r"^i:0#\.\w\|", "", u.get("LoginName", "")) for u in (unwrap(g.get("Users")) or [])] if isinstance(g.get("Users"), dict) else []}
117
+ for g in self.sp.site_groups()]
118
+ except SharePointError as e:
119
+ self._err("site groups (needs permission to enumerate groups)", e)
120
+
121
+ # ---------------------------------------------------------------- pages
122
+ def discover_pages(self, site: dict) -> list[str]:
123
+ pages = []
124
+ for entry in site["lists"]:
125
+ for form in entry.get("forms", {}).values():
126
+ pages.append(f"{entry['url']}/{form}")
127
+ try:
128
+ for f in self.sp.folder_files("SitePages"):
129
+ if f["Name"].lower().endswith(".aspx"):
130
+ pages.append(self.sp.site_relative(f["ServerRelativeUrl"]))
131
+ except SharePointError as e:
132
+ self._err("SitePages library", e)
133
+ return pages
134
+
135
+ def page(self, page: str) -> Optional[str]:
136
+ rel = self.sp.site_relative(self.sp.server_relative_url(page))
137
+ self.progress(f"page {rel}")
138
+ try:
139
+ source = self.sp.file_text(rel)
140
+ except SharePointError as e:
141
+ self._err(f"source of {rel}", e)
142
+ return None
143
+ _write(self.root / "pages" / rel, source)
144
+ wps = None
145
+ try:
146
+ wps = self.sp.web_parts(rel)
147
+ _write(self.root / "pages" / (rel + ".webparts.json"), {"d": {"results": wps}})
148
+ except SharePointError as e:
149
+ self._err(f"web parts of {rel}", e)
150
+ try:
151
+ if self.sp.item_reads:
152
+ _write(self.root / "rendered" / (re.sub(r"\.aspx$", "", rel, flags=re.I) + ".html"), self.sp.rendered(rel))
153
+ except SharePointError as e:
154
+ self._err(f"rendered {rel}", e)
155
+ self.manifest["pages"].append(rel)
156
+ return rel
157
+
158
+ # --------------------------------------------------------------- assets
159
+ def assets(self, pages: Iterable[str]) -> None:
160
+ queue: list[str] = []
161
+ for rel in pages:
162
+ src = (self.root / "pages" / rel).read_text(encoding="utf-8", errors="replace")
163
+ wpf = self.root / "pages" / (rel + ".webparts.json")
164
+ wpj = json.loads(wpf.read_text(encoding="utf-8"))["d"]["results"] if wpf.exists() else None
165
+ queue += asset_refs(parse_page(rel, src, wpj))
166
+ rendered = self.root / "rendered" / (re.sub(r"\.aspx$", "", rel, flags=re.I) + ".html")
167
+ if rendered.exists():
168
+ from ..analysis.aspx import scan_html
169
+ s, _, css, _ = scan_html(rendered.read_text(encoding="utf-8", errors="replace"))
170
+ queue += s + css
171
+ done = set()
172
+ while queue:
173
+ ref = resolve_tokens(queue.pop(0), self.sp.server_relative)
174
+ url = ref.split("?")[0].split("#")[0]
175
+ if url.startswith(("http://", "https://")):
176
+ if not url.lower().startswith(self.sp.origin.lower()):
177
+ continue # CDN / external: recorded by the inventory, not downloaded
178
+ url = url[len(self.sp.origin):]
179
+ if not url.startswith("/"):
180
+ url = self.sp.server_relative_url(url)
181
+ if url.lower().startswith(SKIP_PREFIXES) or url in done:
182
+ continue
183
+ if not url.lower().startswith(self.sp.server_relative.lower().rstrip("/") + "/"):
184
+ continue # outside this site (another site collection): not ours to export
185
+ done.add(url)
186
+ rel = self.sp.site_relative(url)
187
+ try:
188
+ text = self.sp.file_text(url)
189
+ except SharePointError as e:
190
+ self._err(f"asset {rel}", e)
191
+ continue
192
+ _write(self.root / "files" / rel, text)
193
+ self.manifest["files"].append(rel)
194
+ if rel.lower().endswith((".html", ".htm", ".txt")): # CEWP content links can pull in more scripts
195
+ from ..analysis.aspx import scan_html
196
+ s, _, css, _ = scan_html(text)
197
+ queue += s + css
198
+
199
+ # ------------------------------------------------------------------ run
200
+ def run(self, pages: Optional[list[str]] = None, sample_items: int = 200) -> dict:
201
+ self.root.mkdir(parents=True, exist_ok=True)
202
+ self.progress("site and lists")
203
+ site = self.site()
204
+ self.principals(site)
205
+ try:
206
+ site["quickLaunch"] = [{"title": n.get("Title"), "url": n.get("Url")} for n in self.sp.quick_launch()]
207
+ except SharePointError as e:
208
+ self._err("quick launch navigation", e)
209
+ self.list_data(site, sample_items)
210
+ _write(self.root / "site.json", site)
211
+ targets = pages or self.discover_pages(site)
212
+ fetched = [p for p in (self.page(t) for t in targets) if p]
213
+ self.assets(fetched)
214
+ self.manifest["summary"] = {"lists": len(self.manifest["lists"]), "pages": len(fetched),
215
+ "files": len(self.manifest["files"]), "errors": len(self.manifest["errors"])}
216
+ _write(self.root / "manifest.json", self.manifest)
217
+ return self.manifest
File without changes
sp2react/spec/base.py ADDED
@@ -0,0 +1,80 @@
1
+ """Deterministic part of the spec: everything that can be derived without judgement.
2
+
3
+ The spec agent receives this base and adds the rules/components that need interpretation
4
+ (what a jQuery handler *means*). Keeping this part deterministic means it is never hallucinated.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+
10
+ from ..analysis.inventory import kebab
11
+ from .model import Component, Endpoint, ListSpec, Rule, Screen, Spec
12
+
13
+ MODES_ORDER = ["new", "edit", "display"]
14
+
15
+
16
+ def _src(b: dict) -> list[str]:
17
+ return [f"{b['file']}:{b['line']}"] if b.get("line") else [b["file"]]
18
+
19
+
20
+ def base_spec(inv: dict) -> Spec:
21
+ scr = inv["screen"]
22
+ primary = scr.get("list")
23
+ modes = [m for m in MODES_ORDER if any(p.get("mode") == m for p in inv["pages"])]
24
+ site_prefix = re.sub(r"^https?://[^/]+", "", inv["site"].get("url") or "").rstrip("/")
25
+ screen = Screen(
26
+ id=scr["id"], kind=scr["kind"], title=scr["title"], route=scr["route"], list=primary, modes=modes,
27
+ legacy_urls=[f"{site_prefix}/{p['path']}" for p in inv["pages"]],
28
+ stylesheets=inv.get("stylesheets", []),
29
+ )
30
+ rules: list[Rule] = []
31
+ by_kind: dict[str, list[dict]] = {}
32
+ for b in inv["behaviors"]:
33
+ by_kind.setdefault(b["kind"], []).append(b)
34
+
35
+ if scr["kind"] == "list-form":
36
+ form = Component(id="form", kind="form", title=scr["title"],
37
+ props={"list": primary, "modes": modes, "toolbar": {"new": ["Save", "Cancel"], "edit": ["Save", "Cancel"], "display": ["Edit Item", "Close"]}},
38
+ covers=[b["id"] for b in by_kind.get("attachments", [])])
39
+ screen.components.append(form)
40
+ # schema validations that translate mechanically
41
+ for b in by_kind.get("column_validation", []) + by_kind.get("list_validation", []):
42
+ ported = (b.get("detail") or {}).get("ported")
43
+ if not ported:
44
+ continue
45
+ n = len(rules) + 1
46
+ rules.append(Rule(id=f"S{n}", origin="schema", message=b.get("message"), presentation="inline",
47
+ covers=[b["id"]], src=_src(b), list=primary, **ported))
48
+ else:
49
+ for i, frag in enumerate(inv.get("html_fragments", []), 1):
50
+ screen.components.append(Component(id=f"fragment{i}", kind="html", title=frag.get("web_part"), order=i,
51
+ props={"legacy_html": frag["html"][:4000], "page": frag["page"]}))
52
+ for b in by_kind.get("list_view", []):
53
+ d = b.get("detail") or {}
54
+ screen.components.append(Component(id=f"listview-{kebab(b['id'].split(':')[-2])}", kind="list_view",
55
+ title=b["code"].split("'")[1] if "'" in b["code"] else None,
56
+ region=d.get("zone"), props={"list": d.get("list"), "view_xml": d.get("view_xml")},
57
+ covers=[b["id"]], src=_src(b)))
58
+
59
+ # endpoints proposed from the data calls, merged by method+path
60
+ endpoints: dict[tuple, Endpoint] = {}
61
+ for c in inv["calls"]:
62
+ p = c.get("proposed") or {}
63
+ if not p.get("path") or p.get("method") not in ("GET", "POST", "PATCH", "DELETE"):
64
+ continue
65
+ key = (p["method"], p["path"])
66
+ if key not in endpoints:
67
+ m = re.match(r"/api/lists/(\w+)", p["path"])
68
+ eid = f"E{len(endpoints) + 1}"
69
+ endpoints[key] = Endpoint(id=eid, method=p["method"], path=p["path"], purpose=p.get("purpose", ""),
70
+ list=m.group(1) if m else None, body=p.get("body", []) or [])
71
+ e = endpoints[key]
72
+ e.covers.append(c["behavior"])
73
+ e.src.append(f"{c['behavior'].split('#L')[0]}:{c.get('line')}")
74
+ if p.get("query"):
75
+ e.query.setdefault("usages", []).append({k: v for k, v in p["query"].items() if v})
76
+ lists = {k: ListSpec(title=v["title"], primary=(v["role"] == "primary"), fields=v["fields"],
77
+ attachments=v.get("attachments", False), views=v.get("views", {}))
78
+ for k, v in inv["lists"].items()}
79
+ roles = sorted({r["group"] for r in inv.get("roles", []) if r.get("referenced")})
80
+ return Spec(screen=screen, lists=lists, rules=rules, endpoints=list(endpoints.values()), roles=roles)