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,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
|
+
}
|
|
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)
|