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,10 @@
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ ROOT = Path(__file__).resolve().parents[1]
6
+ sys.path.insert(0, str(ROOT))
7
+ os.environ.setdefault("DB_PATH", str(ROOT / "data" / "test.db"))
8
+ db = Path(os.environ["DB_PATH"])
9
+ if db.exists():
10
+ db.unlink()
@@ -0,0 +1,38 @@
1
+ """The server rule engine must agree with the browser engine on every shared vector."""
2
+ import json
3
+ from datetime import date
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from app import rules as R
9
+
10
+ VECTORS = json.loads((Path(__file__).resolve().parents[2] / "shared" / "rule_vectors.json").read_text())
11
+
12
+
13
+ @pytest.mark.parametrize("v", VECTORS, ids=[v["name"] for v in VECTORS])
14
+ def test_vector(v):
15
+ t = v["type"]
16
+ if t == "evaluate":
17
+ got = R.evaluate(v["cond"], v["ctx"])
18
+ elif t == "validate":
19
+ got = [[e["field"], e["message"], e["rule"]] for e in R.validate(v["fields"], v["rules"], v["ctx"])]
20
+ elif t == "field_state":
21
+ s = R.field_state(v["field"], v["rules"], v["ctx"])
22
+ got = {"visible": s["visible"], "readonly": s["readonly"], "required": s["required"]}
23
+ elif t == "lock":
24
+ r = R.lock(v["rules"], v["ctx"])
25
+ got = r["id"] if r else None
26
+ if r and v.get("message"):
27
+ assert R.fill_template(r["message"], v["ctx"]["values"]) == v["message"]
28
+ elif t == "set_on_save":
29
+ got = R.set_on_save(v["rules"], v["ctx"])
30
+ elif t == "banners":
31
+ got = [R.fill_template(b["message"], v["ctx"]["values"]) for b in R.banners(v["rules"], v["ctx"])]
32
+ elif t == "defaults":
33
+ got = R.defaults(v["fields"], v["rules"], v["user"], date.fromisoformat(v["today"]))
34
+ elif t == "fill":
35
+ got = R.fill_template(v["text"], v["values"])
36
+ elif t == "clear_hidden":
37
+ got = R.clear_hidden(v["fields"], v["rules"], v["ctx"])
38
+ assert got == v["expected"]
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>__SITE_TITLE__</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "converted-sharepoint-app",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc --noEmit && vite build",
9
+ "typecheck": "tsc --noEmit",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "react": "^18.3.1",
14
+ "react-dom": "^18.3.1",
15
+ "react-router-dom": "^6.28.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/react": "^18.3.12",
19
+ "@types/react-dom": "^18.3.1",
20
+ "@vitejs/plugin-react": "^4.3.4",
21
+ "typescript": "^5.6.3",
22
+ "vite": "^5.4.11"
23
+ }
24
+ }
@@ -0,0 +1,23 @@
1
+ // Runs shared/rule_vectors.json against the browser rule engine (node >= 22.18 strips the types).
2
+ // The backend runs the same vectors in backend/tests/test_rule_vectors.py.
3
+ import { readFileSync } from "node:fs";
4
+ import * as R from "../src/runtime/rules.ts";
5
+
6
+ const vectors = JSON.parse(readFileSync(new URL("../../shared/rule_vectors.json", import.meta.url), "utf8"));
7
+ let failed = 0;
8
+ const eq = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
9
+ for (const v of vectors) {
10
+ let got: unknown;
11
+ if (v.type === "evaluate") got = R.evaluate(v.cond, v.ctx);
12
+ else if (v.type === "validate") got = R.validate(v.fields, v.rules, v.ctx).map((e) => [e.field, e.message, e.rule]);
13
+ else if (v.type === "field_state") { const s = R.fieldState(v.field, v.rules, v.ctx); got = { visible: s.visible, readonly: s.readonly, required: s.required }; }
14
+ else if (v.type === "lock") { const r = R.lock(v.rules, v.ctx); got = r ? r.id : null; if (r && v.message && R.fillTemplate(r.message ?? "", v.ctx.values) !== v.message) got = "bad message"; }
15
+ else if (v.type === "set_on_save") got = R.setOnSave(v.rules, v.ctx);
16
+ else if (v.type === "banners") got = R.banners(v.rules, v.ctx).map((b) => R.fillTemplate(b.message ?? "", v.ctx.values));
17
+ else if (v.type === "defaults") got = R.defaults(v.fields, v.rules, v.user, v.today);
18
+ else if (v.type === "fill") got = R.fillTemplate(v.text, v.values);
19
+ else if (v.type === "clear_hidden") got = R.clearHidden(v.fields, v.rules, v.ctx);
20
+ if (!eq(got, v.expected)) { failed++; console.error(`FAIL ${v.name}: expected ${JSON.stringify(v.expected)} got ${JSON.stringify(got)}`); }
21
+ }
22
+ console.log(`${vectors.length - failed}/${vectors.length} rule vectors pass (TypeScript engine)`);
23
+ process.exit(failed ? 1 : 0);
@@ -0,0 +1,88 @@
1
+ // Client for the generated FastAPI backend. The OData-style query options are the same ones the
2
+ // legacy jQuery sent to /_api, so legacy queries carry over almost verbatim.
3
+ import type { User, Values } from "./runtime/spec";
4
+
5
+ export class ApiError extends Error {
6
+ constructor(public status: number, public body: any) {
7
+ super(body?.message ?? body?.detail ?? `HTTP ${status}`);
8
+ }
9
+ }
10
+
11
+ export interface QueryOptions {
12
+ filter?: string;
13
+ orderby?: string;
14
+ top?: number;
15
+ skip?: number;
16
+ select?: string;
17
+ }
18
+
19
+ const DEV_USER_KEY = "sp2react.devUser";
20
+
21
+ export function devUser(): string | null {
22
+ try {
23
+ return localStorage.getItem(DEV_USER_KEY);
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ export function setDevUser(login: string | null) {
30
+ try {
31
+ if (login) localStorage.setItem(DEV_USER_KEY, login);
32
+ else localStorage.removeItem(DEV_USER_KEY);
33
+ } catch {
34
+ /* storage unavailable */
35
+ }
36
+ }
37
+
38
+ async function request<T>(method: string, url: string, body?: unknown, headers: Record<string, string> = {}): Promise<T> {
39
+ const h: Record<string, string> = { Accept: "application/json", ...headers };
40
+ if (body !== undefined) h["Content-Type"] = "application/json";
41
+ const du = devUser();
42
+ if (du) h["X-User"] = du;
43
+ const r = await fetch(url, { method, headers: h, body: body === undefined ? undefined : JSON.stringify(body), credentials: "include" });
44
+ const text = await r.text();
45
+ const data = text ? JSON.parse(text) : null;
46
+ if (!r.ok) throw new ApiError(r.status, data);
47
+ return data as T;
48
+ }
49
+
50
+ const enc = encodeURIComponent;
51
+
52
+ export const api = {
53
+ me: () => request<User>("GET", "/api/me"),
54
+ users: (search: string) => request<{ id: number; login: string; title: string; email?: string }[]>("GET", `/api/users?search=${enc(search)}`),
55
+ spec: (screenId: string) => request<any>("GET", `/api/specs/${enc(screenId)}`),
56
+
57
+ query: (list: string, o: QueryOptions = {}) => {
58
+ const qs = Object.entries(o)
59
+ .filter(([, v]) => v !== undefined && v !== null && v !== "")
60
+ .map(([k, v]) => `$${k}=${enc(String(v))}`)
61
+ .join("&");
62
+ return request<{ value: Values[]; count: number }>("GET", `/api/lists/${list}/items${qs ? "?" + qs : ""}`);
63
+ },
64
+ get: (list: string, id: number | string) => request<Values>("GET", `/api/lists/${list}/items/${id}`),
65
+ state: (list: string, id: number | string, mode = "edit") =>
66
+ request<{ locked: boolean; message: string | null; redirect: string | null; banners: string[] }>("GET", `/api/lists/${list}/items/${id}/state?mode=${mode}`),
67
+ create: (list: string, values: Values) => request<Values>("POST", `/api/lists/${list}/items`, { values }),
68
+ update: (list: string, id: number | string, values: Values, etag?: string) =>
69
+ request<Values>("PATCH", `/api/lists/${list}/items/${id}`, { values }, etag ? { "If-Match": etag } : {}),
70
+ remove: (list: string, id: number | string) => request<null>("DELETE", `/api/lists/${list}/items/${id}`),
71
+ lookup: (list: string, field: string, parent?: number | null, q = "") =>
72
+ request<{ id: number; title: string; [k: string]: any }[]>(
73
+ "GET",
74
+ `/api/lists/${list}/lookup/${field}?q=${enc(q)}${parent !== undefined && parent !== null ? `&parent=${parent}` : ""}`
75
+ ),
76
+ unique: (list: string, field: string, value: string, exclude = 0) =>
77
+ request<{ duplicate_id: number | null }>("GET", `/api/lists/${list}/unique/${field}?value=${enc(value)}&exclude=${exclude}`),
78
+ attachmentUrl: (list: string, id: number | string, name: string) => `/api/lists/${list}/items/${id}/attachments/${enc(name)}`,
79
+ uploadAttachment: async (list: string, id: number | string, file: File) => {
80
+ const buf = new Uint8Array(await file.arrayBuffer());
81
+ let bin = "";
82
+ for (let i = 0; i < buf.length; i += 0x8000) bin += String.fromCharCode(...buf.subarray(i, i + 0x8000));
83
+ return request("POST", `/api/lists/${list}/items/${id}/attachments`, { name: file.name, content_base64: btoa(bin) });
84
+ },
85
+ deleteAttachment: (list: string, id: number | string, name: string) => request("DELETE", `/api/lists/${list}/items/${id}/attachments/${enc(name)}`),
86
+ };
87
+
88
+ export type Api = typeof api;
@@ -0,0 +1,4 @@
1
+ export function Banner({ text, tone = "error" }: { text: string; tone?: "error" | "info" }) {
2
+ if (!text) return null;
3
+ return <div className={tone === "error" ? "sp-banner sp-banner-error" : "sp-banner sp-banner-info"} role="status">{text}</div>;
4
+ }
@@ -0,0 +1,38 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export interface Column<T> {
4
+ key: string;
5
+ header: ReactNode;
6
+ render?: (row: T) => ReactNode;
7
+ className?: string;
8
+ }
9
+
10
+ export function DataTable<T extends Record<string, any>>({ columns, rows, empty, className = "db-table", id, loading }: {
11
+ columns: Column<T>[];
12
+ rows: T[] | null;
13
+ empty: ReactNode;
14
+ className?: string;
15
+ id?: string;
16
+ loading?: ReactNode;
17
+ }) {
18
+ return (
19
+ <table className={className} id={id}>
20
+ <thead>
21
+ <tr>{columns.map((c) => <th key={c.key} className={c.className}>{c.header}</th>)}</tr>
22
+ </thead>
23
+ <tbody>
24
+ {rows === null ? (
25
+ <tr><td colSpan={columns.length}>{loading ?? "Loading..."}</td></tr>
26
+ ) : rows.length === 0 ? (
27
+ <tr><td colSpan={columns.length}>{empty}</td></tr>
28
+ ) : (
29
+ rows.map((r, i) => (
30
+ <tr key={(r.ID as number) ?? i}>
31
+ {columns.map((c) => <td key={c.key} className={c.className}>{c.render ? c.render(r) : String(r[c.key] ?? "")}</td>)}
32
+ </tr>
33
+ ))
34
+ )}
35
+ </tbody>
36
+ </table>
37
+ );
38
+ }
@@ -0,0 +1,252 @@
1
+ // Generic SharePoint list form (NewForm / EditForm / DispForm) driven by the screen spec.
2
+ // All behaviour that used to live in jQuery comes from spec rules evaluated by runtime/rules.ts;
3
+ // the API enforces the same rules again on save.
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
+ import { useNavigate, useSearchParams } from "react-router-dom";
6
+ import { api, ApiError } from "../../api";
7
+ import { HANDLERS } from "../../custom";
8
+ import { formatValue } from "../../runtime/format";
9
+ import * as R from "../../runtime/rules";
10
+ import { rulesForList, type Field, type Mode, type Spec, type Values } from "../../runtime/spec";
11
+ import { useUser } from "../../runtime/user";
12
+ import { Banner } from "./Banner";
13
+ import { FieldControl, type Option } from "./fields";
14
+ import { LegacyHtml } from "./LegacyHtml";
15
+
16
+ interface Props {
17
+ spec: Spec;
18
+ mode: Mode;
19
+ id?: string;
20
+ }
21
+
22
+ export function ListForm({ spec, mode, id }: Props) {
23
+ const list = spec.screen.list as string;
24
+ const lst = spec.lists[list];
25
+ const fields: Field[] = lst.fields;
26
+ const rules = useMemo(() => rulesForList(spec, list), [spec, list]);
27
+ const { user } = useUser();
28
+ const nav = useNavigate();
29
+ const [params] = useSearchParams();
30
+ const source = params.get("Source");
31
+ const [values, setValues] = useState<Values>({});
32
+ const [original, setOriginal] = useState<Values>({});
33
+ const [options, setOptions] = useState<Record<string, Option[]>>({});
34
+ const [errors, setErrors] = useState<Record<string, string>>({});
35
+ const [busy, setBusy] = useState(false);
36
+ const [loaded, setLoaded] = useState(false);
37
+ const [files, setFiles] = useState<File[]>([]);
38
+ const etag = useRef<string | undefined>(undefined);
39
+ const route = spec.screen.route;
40
+
41
+ const ctx: R.Ctx = { mode, values, original, user };
42
+ const states = useMemo(() => Object.fromEntries(fields.map((f) => [f.name, R.fieldState(f, rules, ctx)])), [fields, rules, values, original, user, mode]);
43
+ const banners = useMemo(() => R.banners(rules, ctx), [rules, values, original, user, mode]);
44
+
45
+ const goBack = useCallback(() => {
46
+ if (source && source.startsWith("/")) nav(source);
47
+ else nav(route);
48
+ }, [nav, source, route]);
49
+
50
+ const loadOptions = useCallback(async (field: Field, current: Values) => {
51
+ const cascade = rules.find((r) => r.kind === "cascade" && r.field === field.name);
52
+ const parentVal = cascade ? current[cascade.params!.parent] : undefined;
53
+ const parentId = parentVal && typeof parentVal === "object" ? parentVal.id : parentVal;
54
+ if (cascade && !parentId) return [];
55
+ return api.lookup(list, field.name, cascade ? parentId : undefined);
56
+ }, [rules, list]);
57
+
58
+ // ------------------------------------------------------------------ load
59
+ useEffect(() => {
60
+ if (!user) return;
61
+ let cancelled = false;
62
+ (async () => {
63
+ let v: Values;
64
+ if (mode === "new") {
65
+ v = R.defaults(fields, rules, user);
66
+ } else {
67
+ const item = await api.get(list, id as string);
68
+ etag.current = item._etag;
69
+ setOriginal(item);
70
+ v = item;
71
+ if (mode === "edit") {
72
+ const locked = R.lock(rules, { mode, values: item, original: item, user });
73
+ if (locked) {
74
+ window.alert(R.fillTemplate(locked.message ?? "", item));
75
+ const to = (locked.params?.redirect ?? "display") === "display" ? `${route}/display/${id}` : route;
76
+ nav(`${to}${source ? `?Source=${encodeURIComponent(source)}` : ""}`, { replace: true });
77
+ return;
78
+ }
79
+ }
80
+ }
81
+ for (const h of Object.values(HANDLERS)) h.onLoad?.({ mode, values: v, original: mode === "new" ? {} : v, user }, api);
82
+ if (cancelled) return;
83
+ setValues(v);
84
+ if (mode !== "display") {
85
+ const opts: Record<string, Option[]> = {};
86
+ for (const f of fields.filter((x) => x.type === "lookup")) opts[f.name] = await loadOptions(f, v);
87
+ if (!cancelled) setOptions(opts);
88
+ }
89
+ setLoaded(true);
90
+ })().catch((e) => window.alert(e.message));
91
+ return () => { cancelled = true; };
92
+ }, [user, mode, id]);
93
+
94
+ // --------------------------------------------------------------- change
95
+ const change = async (field: Field, v: any) => {
96
+ let next = { ...values, [field.name]: v };
97
+ setErrors((e) => ({ ...e, [field.name]: "" }));
98
+ // cascades: children of this field get new options and lose a value that no longer fits
99
+ for (const r of rules.filter((x) => x.kind === "cascade" && x.params?.parent === field.name)) {
100
+ const child = fields.find((f) => f.name === r.field)!;
101
+ const opts = await loadOptions(child, next);
102
+ setOptions((o) => ({ ...o, [child.name]: opts }));
103
+ const cur = next[child.name];
104
+ if (cur && !opts.some((o) => o.id === (typeof cur === "object" ? cur.id : cur))) next = { ...next, [child.name]: null };
105
+ }
106
+ // derived values: copy a column from the selected lookup item
107
+ for (const r of R.rulesFor(rules, "derive", null, mode).filter((x) => x.params?.from_field === field.name)) {
108
+ const opt = (options[field.name] ?? []).find((o) => o.id === (v && typeof v === "object" ? v.id : v));
109
+ next = { ...next, [r.field as string]: opt ? opt[r.params!.column] ?? null : null };
110
+ }
111
+ next = { ...next, ...R.clearHidden(fields, rules, { mode, values: next, original, user }) };
112
+ for (const h of Object.values(HANDLERS)) {
113
+ const patch = h.onChange?.(field.name, { mode, values: next, original, user });
114
+ if (patch) next = { ...next, ...patch };
115
+ }
116
+ setValues(next);
117
+ };
118
+
119
+ const blur = async (field: Field) => {
120
+ const r = R.rulesFor(rules, "unique", field.name, mode)[0];
121
+ const v = values[field.name];
122
+ if (!r || R.isEmpty(v)) return;
123
+ const res = await api.unique(list, field.name, String(v), mode === "edit" ? Number(id) : 0);
124
+ setErrors((e) => ({ ...e, [field.name]: res.duplicate_id ? R.fillTemplate(r.message ?? "", { ...values, duplicate_id: res.duplicate_id, id: res.duplicate_id }) : "" }));
125
+ };
126
+
127
+ // ----------------------------------------------------------------- save
128
+ const showErrors = (list_: R.RuleError[]) => {
129
+ const alertErr = list_.find((e) => e.presentation === "alert");
130
+ if (alertErr) { window.alert(alertErr.message); return; }
131
+ const inline: Record<string, string> = {};
132
+ for (const e of list_) if (e.field) inline[e.field] = inline[e.field] || e.message;
133
+ setErrors(inline);
134
+ const firstMsg = list_.find((e) => !e.field);
135
+ if (firstMsg) window.alert(firstMsg.message);
136
+ };
137
+
138
+ const save = async () => {
139
+ const pending = Object.entries(errors).find(([, m]) => m);
140
+ const errs = R.validate(fields, rules, ctx);
141
+ for (const h of Object.values(HANDLERS)) errs.push(...(h.validate?.(ctx) ?? []));
142
+ if (pending) {
143
+ const r = R.rulesFor(rules, "unique", pending[0], mode)[0];
144
+ if (r) errs.unshift({ field: pending[0], message: pending[1], presentation: r.presentation ?? "inline", rule: r.id });
145
+ }
146
+ if (errs.length) return showErrors(errs);
147
+ for (const c of R.rulesFor(rules, "confirm", null, mode).filter((x) => (x.params?.action ?? "save") === "save" && R.evaluate(x.when, ctx)))
148
+ if (!window.confirm(R.fillTemplate(c.message ?? "", values))) return;
149
+ const payload: Values = { ...values, ...R.setOnSave(rules, ctx) };
150
+ for (const k of ["ID", "Created", "Modified", "Author", "Editor", "_etag", "_version", "Attachments"]) delete payload[k];
151
+ setBusy(true);
152
+ try {
153
+ const saved = mode === "new" ? await api.create(list, payload) : await api.update(list, id as string, payload, etag.current);
154
+ for (const f of files) await api.uploadAttachment(list, saved.ID, f);
155
+ goBack();
156
+ } catch (e) {
157
+ if (e instanceof ApiError && e.status === 422) showErrors(e.body.errors);
158
+ else if (e instanceof ApiError && e.status === 423) { window.alert(e.body.message); nav(`${route}/display/${id}`); }
159
+ else window.alert((e as Error).message);
160
+ } finally {
161
+ setBusy(false);
162
+ }
163
+ };
164
+
165
+ // --------------------------------------------------------------- render
166
+ const around = (where: "before" | "after") =>
167
+ spec.screen.components
168
+ .filter((c) => c.kind !== "form" && (c.region ?? "before") === where && (!c.props?.modes || c.props.modes.includes(mode)))
169
+ .filter((c) => !c.roles?.length || c.roles.some((g) => user?.groups.includes(g)))
170
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
171
+ .map((c) => c.kind === "banner"
172
+ ? <Banner key={c.id} text={c.props?.text ?? ""} tone={c.props?.tone ?? "info"} />
173
+ : c.kind === "html" ? <LegacyHtml key={c.id} html={c.props?.legacy_html ?? ""} /> : null);
174
+
175
+ const toolbar = mode === "display" ? (
176
+ <div className="sp-toolbar">
177
+ <button type="button" className="ms-ButtonHeightWidth" onClick={() => nav(`${route}/edit/${id}${source ? `?Source=${encodeURIComponent(source)}` : ""}`)}>Edit Item</button>
178
+ <button type="button" className="ms-ButtonHeightWidth" onClick={goBack}>Close</button>
179
+ </div>
180
+ ) : (
181
+ <div className="sp-toolbar">
182
+ <button type="button" className="ms-ButtonHeightWidth" disabled={busy || !loaded} onClick={save}>Save</button>
183
+ <button type="button" className="ms-ButtonHeightWidth" disabled={busy} onClick={goBack}>Cancel</button>
184
+ </div>
185
+ );
186
+
187
+ if (!loaded) return <div className="sp-loading">Loading...</div>;
188
+ return (
189
+ <div className="sp-listform">
190
+ {around("before")}
191
+ {banners.map((b) => <Banner key={b.id} text={R.fillTemplate(b.message ?? "", values)} />)}
192
+ <table className="ms-formtable" role="presentation">
193
+ <tbody>
194
+ {fields.map((f) => {
195
+ const st = states[f.name];
196
+ if (!st.visible) return null;
197
+ return (
198
+ <tr key={f.name} id={`row-${f.name}`}>
199
+ <td className="ms-formlabel">
200
+ <h3 className="ms-standardheader">
201
+ <span className="nobr">{f.label}{st.required && mode !== "display" && <span className="ms-accentText" title="This is a required field."> *</span>}</span>
202
+ </h3>
203
+ </td>
204
+ <td className="ms-formbody">
205
+ {mode === "display" ? (
206
+ <span className="sp-value">{formatValue(f, values[f.name])}</span>
207
+ ) : (
208
+ <FieldControl field={f} value={values[f.name]} options={options[f.name]} disabled={st.readonly} required={st.required}
209
+ onChange={(v) => change(f, v)} onBlur={() => blur(f)} />
210
+ )}
211
+ {f.description && mode !== "display" && <div className="ms-metadata">{f.description}</div>}
212
+ {errors[f.name] && <span className="ms-formvalidation" role="alert"><br />{errors[f.name]}</span>}
213
+ </td>
214
+ </tr>
215
+ );
216
+ })}
217
+ {lst.attachments && (
218
+ <tr id="row-Attachments">
219
+ <td className="ms-formlabel"><h3 className="ms-standardheader"><span className="nobr">Attachments</span></h3></td>
220
+ <td className="ms-formbody">
221
+ {(original.Attachments ?? []).map((n: string) => (
222
+ <div key={n}>
223
+ <a href={api.attachmentUrl(list, id as string, n)}>{n}</a>
224
+ {mode === "edit" && <button type="button" className="sp-link" onClick={async () => {
225
+ await api.deleteAttachment(list, id as string, n);
226
+ setOriginal((o) => ({ ...o, Attachments: (o.Attachments ?? []).filter((x: string) => x !== n) }));
227
+ }}>Delete</button>}
228
+ </div>
229
+ ))}
230
+ {mode !== "display" && (
231
+ <>
232
+ {files.map((f) => <div key={f.name}>{f.name}</div>)}
233
+ <input type="file" aria-label="Attach file" onChange={(e) => e.target.files && setFiles((x) => [...x, ...Array.from(e.target.files!)])} />
234
+ </>
235
+ )}
236
+ </td>
237
+ </tr>
238
+ )}
239
+ </tbody>
240
+ </table>
241
+ {mode !== "new" && original.ID && (
242
+ <div className="sp-itemmeta">
243
+ Created at {formatValue({ name: "Created", label: "Created", type: "datetime" }, original.Created)} by {original.Author?.title ?? ""}
244
+ <br />
245
+ Last modified at {formatValue({ name: "Modified", label: "Modified", type: "datetime" }, original.Modified)} by {original.Editor?.title ?? ""}
246
+ </div>
247
+ )}
248
+ {toolbar}
249
+ {around("after")}
250
+ </div>
251
+ );
252
+ }
@@ -0,0 +1,28 @@
1
+ // Fallback renderer for a page screen before (or without) an agent-written Page.tsx:
2
+ // shows the authored HTML fragments, list views and banners from the spec in region order.
3
+ import { Layout } from "./Layout";
4
+ import { LegacyHtml } from "./LegacyHtml";
5
+ import { ListView, parseViewXml } from "./ListView";
6
+ import { Banner } from "./Banner";
7
+ import type { Spec } from "../../runtime/spec";
8
+
9
+ export function GenericPage({ spec }: { spec: Spec }) {
10
+ const comps = [...spec.screen.components].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
11
+ return (
12
+ <Layout title={spec.screen.title}>
13
+ {comps.map((c) => {
14
+ if (c.kind === "html") return <LegacyHtml key={c.id} html={c.props?.legacy_html ?? ""} />;
15
+ if (c.kind === "banner") return <Banner key={c.id} text={c.props?.text ?? ""} tone={c.props?.tone ?? "info"} />;
16
+ if (c.kind === "list_view") {
17
+ const list = c.props?.list as string;
18
+ const lst = spec.lists[list];
19
+ if (!lst) return null;
20
+ const v = parseViewXml(c.props?.view_xml);
21
+ return <ListView key={c.id} list={list} fields={lst.fields} title={c.title} showNew={false}
22
+ view={{ fields: v.fields ?? lst.fields.slice(0, 4).map((f) => f.name), orderby: v.orderby, top: v.top }} />;
23
+ }
24
+ return null;
25
+ })}
26
+ </Layout>
27
+ );
28
+ }
@@ -0,0 +1,54 @@
1
+ // SharePoint 2013/2016 chrome: suite bar, site title, quick launch, page title area.
2
+ import { useEffect, useState, type ReactNode } from "react";
3
+ import { Link, useLocation } from "react-router-dom";
4
+ import { api, devUser, setDevUser } from "../../api";
5
+ import { useUser } from "../../runtime/user";
6
+ import { site } from "../../site";
7
+
8
+ function DevUserSwitch() {
9
+ const { user, reload } = useUser();
10
+ const [users, setUsers] = useState<{ login: string; title: string }[]>([]);
11
+ useEffect(() => { api.users("").then(setUsers).catch(() => setUsers([])); }, []);
12
+ if (!import.meta.env.DEV) return null;
13
+ return (
14
+ <label className="sp-devuser" title="Development only: act as another seeded user to test roles">
15
+ Acting as{" "}
16
+ <select value={devUser() ?? user?.login ?? ""} onChange={(e) => { setDevUser(e.target.value); reload(); window.location.reload(); }}>
17
+ {users.map((u) => <option key={u.login} value={u.login}>{u.title}</option>)}
18
+ </select>
19
+ </label>
20
+ );
21
+ }
22
+
23
+ export function Layout({ title, children }: { title: string; children: ReactNode }) {
24
+ const { user, error } = useUser();
25
+ const loc = useLocation();
26
+ useEffect(() => { document.title = `${title} - ${site.title}`; }, [title]);
27
+ return (
28
+ <div className="sp-page">
29
+ <div className="sp-suitebar">
30
+ <span className="sp-brand">SharePoint</span>
31
+ <span className="sp-suite-right">
32
+ <DevUserSwitch />
33
+ <span className="sp-username">{user?.title ?? (error ? "Not signed in" : "")}</span>
34
+ </span>
35
+ </div>
36
+ <div className="sp-titlerow">
37
+ <div className="sp-sitetitle"><Link to="/">{site.title}</Link></div>
38
+ <h1 className="sp-pagetitle" id="pageTitle">{title}</h1>
39
+ </div>
40
+ <div className="sp-body">
41
+ <nav className="sp-quicklaunch" aria-label="Quick launch">
42
+ <ul>
43
+ {site.quickLaunch.map((q) => (
44
+ <li key={q.title} className={loc.pathname === q.href ? "selected" : ""}>
45
+ {q.external ? <a href={q.href}>{q.title}</a> : <Link to={q.href}>{q.title}</Link>}
46
+ </li>
47
+ ))}
48
+ </ul>
49
+ </nav>
50
+ <main className="sp-content">{error ? <div className="ms-formvalidation">{error}</div> : children}</main>
51
+ </div>
52
+ </div>
53
+ );
54
+ }
@@ -0,0 +1,9 @@
1
+ // Static markup authored in Script Editor / Content Editor web parts (help text, instructions).
2
+ // Scripts were removed during analysis; event handlers and javascript: URLs are stripped here too.
3
+ export function LegacyHtml({ html, className }: { html: string; className?: string }) {
4
+ const safe = html
5
+ .replace(/<script[\s\S]*?<\/script>/gi, "")
6
+ .replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "")
7
+ .replace(/(href|src)\s*=\s*(["'])\s*javascript:[^"']*\2/gi, "");
8
+ return <div className={className} dangerouslySetInnerHTML={{ __html: safe }} />;
9
+ }
@@ -0,0 +1,59 @@
1
+ // A list view (AllItems.aspx / list view web part): same columns, order and row limit as the legacy view.
2
+ import { useEffect, useState } from "react";
3
+ import { Link } from "react-router-dom";
4
+ import { api } from "../../api";
5
+ import { formatValue } from "../../runtime/format";
6
+ import type { Field } from "../../runtime/spec";
7
+
8
+ export interface ViewDef {
9
+ fields: string[];
10
+ orderby?: string;
11
+ top?: number;
12
+ }
13
+
14
+ export function parseViewXml(xml?: string | null): Partial<ViewDef> {
15
+ if (!xml) return {};
16
+ const fields = Array.from(xml.matchAll(/<ViewFields>([\s\S]*?)<\/ViewFields>/g)).flatMap((m) =>
17
+ Array.from(m[1].matchAll(/FieldRef\s+Name="([^"]+)"/g)).map((x) => x[1].replace(/^LinkTitle(NoMenu)?$/, "Title")));
18
+ const ob = xml.match(/<OrderBy>([\s\S]*?)<\/OrderBy>/);
19
+ const orderby = ob ? Array.from(ob[1].matchAll(/FieldRef\s+Name="([^"]+)"(?:\s+Ascending="(\w+)")?/g))
20
+ .map((x) => `${x[1]}${x[2] && x[2].toUpperCase() === "FALSE" ? " desc" : ""}`).join(",") : undefined;
21
+ const rl = xml.match(/<RowLimit[^>]*>(\d+)<\/RowLimit>/);
22
+ return { fields: fields.length ? fields : undefined, orderby, top: rl ? Number(rl[1]) : undefined };
23
+ }
24
+
25
+ export function ListView({ list, fields, view, route, showNew = true, title }: {
26
+ list: string; fields: Field[]; view: ViewDef; route?: string; showNew?: boolean; title?: string | null;
27
+ }) {
28
+ const [rows, setRows] = useState<any[] | null>(null);
29
+ const [err, setErr] = useState<string | null>(null);
30
+ useEffect(() => {
31
+ api.query(list, { orderby: view.orderby, top: view.top ?? 30 }).then((r) => setRows(r.value)).catch((e) => setErr(e.message));
32
+ }, [list, view.orderby, view.top]);
33
+ const byName = Object.fromEntries(fields.map((f) => [f.name, f]));
34
+ const sysLabel: Record<string, string> = { Created: "Created", Modified: "Modified", Author: "Created By", Editor: "Modified By", ID: "ID" };
35
+ return (
36
+ <div className="sp-listview">
37
+ {title && <h2 className="ms-webpart-titleText">{title}</h2>}
38
+ {showNew && route && <div className="sp-newitem"><Link to={`${route}/new`}>+ new item</Link></div>}
39
+ {err && <div className="ms-formvalidation">{err}</div>}
40
+ <table className="ms-listviewtable">
41
+ <thead><tr>{view.fields.map((n) => <th key={n}>{byName[n]?.label ?? sysLabel[n] ?? n}</th>)}</tr></thead>
42
+ <tbody>
43
+ {rows === null ? <tr><td colSpan={view.fields.length}>Loading...</td></tr> :
44
+ rows.length === 0 ? <tr><td colSpan={view.fields.length}>There are no items to show in this view.</td></tr> :
45
+ rows.map((r) => (
46
+ <tr key={r.ID}>
47
+ {view.fields.map((n) => (
48
+ <td key={n}>
49
+ {n === "Title" && route ? <Link to={`${route}/display/${r.ID}`}>{r.Title}</Link> :
50
+ formatValue(byName[n] ?? (n === "Created" || n === "Modified" ? { name: n, label: n, type: "datetime" } : undefined), r[n])}
51
+ </td>
52
+ ))}
53
+ </tr>
54
+ ))}
55
+ </tbody>
56
+ </table>
57
+ </div>
58
+ );
59
+ }