Compare commits

..

128 Commits

Author SHA1 Message Date
m 0a9ab94679 Add source preview to AI structure package
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-22 15:43:24 +03:00
m 7501111d29 Add XML structure preview tree for AI page
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-22 15:40:05 +03:00
m c9f3c12c3f Switch AI structure HTML5 flow to XML exports
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-22 15:18:51 +03:00
m 58afd3932e Add XML export preflight for AI structure
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-22 15:08:36 +03:00
m d8394e4e89 Clarify XML AI structure flow in HTML5 UI
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-22 15:02:26 +03:00
m 4c02e2f73a Optimize XML AI structure package for Codex
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-22 12:25:26 +03:00
m 5a4e3c6d9d Add local 1C runtime adapter execution for CF files
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-22 08:10:49 +03:00
m aa36d58a73 Stage SMB CF/CFE inputs for Windows Agent
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-22 07:40:16 +03:00
m 519f10dd6b Support combined CF and CFE AI structure imports
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-22 07:29:51 +03:00
m 2e86d25205 Add AI structure agent path check
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-22 04:46:27 +03:00
m b3689b1d9e Improve AI structure agent path diagnostics
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-22 04:40:35 +03:00
m e12332d3c6 Show Windows Agent status on AI structure page
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-22 01:05:09 +03:00
m d93b7cb07e Improve AI structure binary input diagnostics
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-22 01:00:26 +03:00
m 51d52ccf04 Handle UNC binary directories in AI structure flow
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-22 00:54:43 +03:00
m de8b0eb795 Support direct CF and CFE inputs in AI structure flow
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-22 00:45:57 +03:00
m b85bff6e06 Route HTML5 AI structure CF/CFE through Windows Agent
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-22 00:39:02 +03:00
m 23800dea71 Improve SMB credential handling for AI structure
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-22 00:20:04 +03:00
m afb455a2c6 Localize AI structure UI and fix form layout
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-22 00:09:58 +03:00
m 4afbb493b4 Show AI structure processing progress
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 23:49:12 +03:00
m 65a1437c7c Use saved SMB credentials for AI structure
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 23:43:40 +03:00
m 9ea2ff5518 Show AI structure path errors in HTML5
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 23:31:01 +03:00
m dafb552ad3 Fix Codex package local source references
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 21:12:52 +03:00
m fea29e665c Make Codex 1C context packages self contained
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 20:58:35 +03:00
m cbcfcc1741 Optimize AI structure output for Codex
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 20:51:34 +03:00
m e86f6be385 Prepare AI-ready 1C structure packages
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 20:43:14 +03:00
m 5f066d2f6b Show effective access roles in HTML5 workspace
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 19:40:12 +03:00
m 87236606d1 Create access profiles from HTML5 workspace
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 19:35:04 +03:00
m 2f7db03001 Add HTML5 access profile workspace
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 19:29:35 +03:00
m 29bbe1dca6 Dry run publish 1C access profiles
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 19:22:02 +03:00
m 6051f59e08 Plan publishing 1C access profiles
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 18:32:06 +03:00
m db5fdf0aa4 Save 1C access profile drafts
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 18:27:20 +03:00
m feaf40c205 Preview 1C access profile drafts
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 18:22:47 +03:00
m d0b74c05be Load 1C access profiles groups and users
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 18:17:27 +03:00
m 3c7b1825c4 Cover full 1C metadata object catalog
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 18:05:28 +03:00
m 9dc35bae20 Include source-only forms in IDE rendering
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 17:14:22 +03:00
m 9f1f1a8ee1 Fix form selection and common form rendering
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 17:03:32 +03:00
m a5e0c8bf0f Render 1C form items hierarchically
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 07:25:28 +03:00
m 8b9a076d86 Load EDT form elements on demand
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 06:57:06 +03:00
m 7d4d9917dd Select requested form in IDE designer
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 06:32:19 +03:00
m af900e4e34 Extract managed form elements from XML
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 06:10:05 +03:00
m 5bd188fe6f Render forms from indexed form elements
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 05:30:53 +03:00
m d26aaef44a Replace IDE form mode placeholder
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 05:15:43 +03:00
m ebded03ecf Update legacy form designer UI
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 05:08:03 +03:00
m e546985843 Build editable HTML5 form designer
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-21 04:51:13 +03:00
m 1b2721e2b7 Extract agent protocol models
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 22:57:02 +03:00
m 09300f013f Extract import source models
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 22:49:10 +03:00
m 8db3225359 Add shared timestamp utility
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 22:40:37 +03:00
m c979428d90 Extract import summary service
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 22:32:38 +03:00
m 588edfcf24 Extract import response models
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 22:21:00 +03:00
m 6dd4d69163 Extract import sync preview service
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 22:10:06 +03:00
m 8c19410da1 Extract import quality service
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 22:02:21 +03:00
m d610a9ad6c Extract snapshot module navigation service
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 21:54:55 +03:00
m b67d734aa4 Extract normalized object navigation service
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 21:46:22 +03:00
m ff8f9a6dd4 Extract normalized project summary service
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 20:45:42 +03:00
m b97c449211 Extract metadata tree builder
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 20:07:50 +03:00
m bb3e70f1e5 Extract metadata tree controller
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 19:51:30 +03:00
m 8d206a3bf2 Extract HTML5 project controller
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 19:45:08 +03:00
m 68b20a27fa Extract HTML5 authoring controller
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 19:36:46 +03:00
m 0750ebe836 Extract HTML5 setup controller
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 19:29:27 +03:00
m f6679d1694 Extract HTML5 operations controller
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 19:16:26 +03:00
m 07d23d2ba9 Extract HTML5 editor controller
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 19:11:24 +03:00
m 02aa084634 Add server-rendered 1C form editor
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 19:00:02 +03:00
m 35dd134ebc Model 1C modules as object parts
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 18:43:43 +03:00
m 1ad103b6dc Use immutable cache for HTML5 assets
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 12:59:41 +03:00
m faf1bbd10a Add CSP for HTML5 responses
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 12:45:54 +03:00
m 2e6fee5fc7 Support multipart HTML5 forms
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 12:41:50 +03:00
m 940d7e884b Extract HTML5 operation helpers
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 12:36:56 +03:00
m 8683b136b3 Extract HTML5 form helpers
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 12:32:09 +03:00
m 990eeedaba Extract HTML5 SSE helpers
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 12:27:22 +03:00
m 7b5893e5a8 Extract HTML5 response helpers
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 12:23:40 +03:00
m dd80ea2f9d Harden HTML5 response headers
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 12:19:40 +03:00
m c90d708f21 Mark HTML5 responses as dynamic
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 12:15:37 +03:00
m b0409044eb Centralize HTML5 response helpers
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 12:02:12 +03:00
m b2ef1e811d Harden HTML5 SSE headers
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 11:52:46 +03:00
m d3ae2459ce Version HTML5 asset URLs
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 11:48:43 +03:00
m 0a16058ebd Cache HTML5 static assets
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 11:44:31 +03:00
m d68c656ce6 Split HTML5 projects renderer
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 11:35:50 +03:00
m dfc400c4e9 Serve HTML5 styles as static asset
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 11:30:32 +03:00
m 6d92c82c2b Split HTML5 inspector renderer
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 11:24:11 +03:00
m b8256927bf Split HTML5 editor renderer
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 11:19:22 +03:00
m 53e983af4e Split HTML5 authoring renderer
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 11:13:46 +03:00
m 624dc5d7f0 Split HTML5 setup renderer
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 11:08:32 +03:00
m 0f8141d5f9 Add HTML5 contracts and operations renderer split
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 11:00:50 +03:00
m 22f59b7580 Harden HTML5 SSE and local assets
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 10:49:48 +03:00
m 65c82c4fed Add HTML5 setup SSE updates
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 02:51:17 +03:00
m 8a1c0da0ea Add HTML5 project panel SSE updates
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 02:46:21 +03:00
m fb66cfba6f Add HTML5 authoring SSE updates
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 02:41:02 +03:00
m de7248db9e Add HTML5 operations SSE updates
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 02:35:06 +03:00
m 816b009f47 Add HTML5 rollback apply summary
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 02:30:15 +03:00
m 5c506e4b23 Add HTML5 metadata preview summary
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 02:25:59 +03:00
m 8f2a17b24a Add HTML5 authoring apply summaries
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 02:22:10 +03:00
m dde672202c Add HTML5 authoring result summaries
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 02:15:40 +03:00
m 04cba89580 Add HTML5 authoring detail summary
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 02:12:07 +03:00
m 74369397c2 Add HTML5 authoring changes summary
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 02:08:35 +03:00
m 3f6b14e57a Add HTML5 object report summary
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 02:04:16 +03:00
m e5332e9a7c Add HTML5 project report summary
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 02:00:19 +03:00
m 15d659e661 Add HTML5 review summary
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 01:56:21 +03:00
m de119c2106 Link HTML5 review findings to source
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 01:52:28 +03:00
m 2aaf5d0082 Add HTML5 symbol reference summary
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 01:48:01 +03:00
m f1584023ff Add HTML5 source summary
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 01:43:27 +03:00
m 571d85e53c Add HTML5 object summary
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 01:39:59 +03:00
m 82690f0007 Add HTML5 object breadcrumb
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 01:36:13 +03:00
m 3e40ba64a8 Add HTML5 flowchart context links
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 01:31:35 +03:00
m a735048270 Make HTML5 flowchart nodes navigable
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 01:27:05 +03:00
m 6cc669f694 Add HTML5 flowchart depth controls
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 01:22:28 +03:00
m 20c1b1809b Style active HTML5 object actions
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 01:16:15 +03:00
m 9ff2cf3676 Mark active HTML5 object context action
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 01:12:32 +03:00
m 2a2786bc60 Add HTML5 object overview action
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 01:08:32 +03:00
m 477a94d302 Add HTML5 object context modes
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 01:05:22 +03:00
m 48070f0f70 Make HTML5 object actions update panels
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 01:01:11 +03:00
m 61cfc9d1cd Add HTML5 object action links
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 00:57:44 +03:00
m f830c4290f Add HTML5 focused object review
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 00:54:46 +03:00
m 6e89cdcd84 Add HTML5 focused object report
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 00:50:56 +03:00
m 9c4d02616f Link HTML5 object selection to symbol detail
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 00:47:39 +03:00
m 6f594395f8 Link HTML5 object selection to source
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 00:44:15 +03:00
m 70c6424c8c Link HTML5 object selection to flowchart
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 00:40:22 +03:00
m 7583851dc4 Add HTML5 flowchart fragment
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 00:36:43 +03:00
m 7ee6deb088 Add HTML5 object graph context
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 00:32:16 +03:00
m c871a8bbd2 Add HTML5 object data flow context
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 00:28:54 +03:00
m 79ae2b3023 Add HTML5 object privacy context
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 00:24:29 +03:00
m f695846b7b Add HTML5 object knowledge context
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 00:14:55 +03:00
m b93fd88e81 Add HTML5 object runtime context
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 00:11:26 +03:00
m 1da745c52e Add HTML5 object UI context
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 00:07:30 +03:00
m a8baa2aa49 Add HTML5 object access context
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 00:04:12 +03:00
m f20c045de9 Add HTML5 object context fragment
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-17 00:00:49 +03:00
m 41dc88c33b Add HTML5 metadata authoring form
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-16 23:55:02 +03:00
m 460881428b Add HTML5 authoring change apply form
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-16 23:50:03 +03:00
m c14db34f14 Add HTML5 authoring diff preview
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
2026-05-16 23:24:42 +03:00
65 changed files with 15506 additions and 3864 deletions
@@ -3036,22 +3036,74 @@ function FormDesignerPanel({
const t = messages[language];
const objectForms = data.selectedObjectUi?.forms.length ? data.selectedObjectUi.forms : data.forms;
const [selectedFormId, setSelectedFormId] = useState<string | null>(objectForms[0]?.form.lineage_id ?? null);
const [titleByForm, setTitleByForm] = useState<Record<string, string>>({});
const [layoutByForm, setLayoutByForm] = useState<Record<string, "auto" | "columns" | "compact">>({});
const [elementDrafts, setElementDrafts] = useState<Record<string, IdeFormElementDraft[]>>({});
const [newElementName, setNewElementName] = useState("");
const [newElementKind, setNewElementKind] = useState<IdeFormElementDraft["controlKind"]>("input");
const selectedFormQualifiedName = data.editorSelectedForm;
useEffect(() => {
if (selectedFormQualifiedName) {
const requestedForm = objectForms.find((item) => item.form.qualified_name === selectedFormQualifiedName);
if (requestedForm && requestedForm.form.lineage_id !== selectedFormId) {
setSelectedFormId(requestedForm.form.lineage_id);
return;
}
}
if (objectForms.length > 0 && !objectForms.some((item) => item.form.lineage_id === selectedFormId)) {
setSelectedFormId(objectForms[0].form.lineage_id);
}
}, [objectForms, selectedFormId]);
const form = objectForms.find((item) => item.form.lineage_id === selectedFormId) ?? objectForms[0];
const elements = form?.elements.slice(0, 8) ?? [];
}, [objectForms, selectedFormId, selectedFormQualifiedName]);
const form =
objectForms.find((item) => item.form.qualified_name === selectedFormQualifiedName) ??
objectForms.find((item) => item.form.lineage_id === selectedFormId) ??
objectForms[0];
const commands = form?.commands.slice(0, 6) ?? [];
const formTitle = form?.form.name ?? "ФормаДокумента";
const formObjectCaption = language === "ru" ? `${formTitle} (форма 1С)` : `${formTitle} (1C form)`;
const commandLabels = [t.postAndClose, ...commands.slice(0, 2).map((command) => command.name)];
const formKey = form?.form.lineage_id ?? "draft";
const baseElements = useMemo(() => buildIdeFormElements(form), [form]);
const elements = elementDrafts[formKey] ?? baseElements;
const flatElements = useMemo(() => flattenIdeFormElements(elements), [elements]);
const sidebarElements = flatElements.slice(0, 160);
const propertyElements = flatElements.slice(0, 48);
const formTitle = titleByForm[formKey] ?? form?.form.name ?? "ФормаДокумента";
const formObjectCaption = language === "ru" ? `${formTitle} (форма 1С 8.5)` : `${formTitle} (1C 8.5 form)`;
const layout = layoutByForm[formKey] ?? "auto";
useEffect(() => {
if (form && !elementDrafts[form.form.lineage_id]) {
setElementDrafts((current) => ({ ...current, [form.form.lineage_id]: buildIdeFormElements(form) }));
}
}, [elementDrafts, form]);
const updateElement = (id: string, patch: Partial<IdeFormElementDraft>) => {
setElementDrafts((current) => ({
...current,
[formKey]: updateIdeFormElementTree(current[formKey] ?? baseElements, id, patch)
}));
};
const addElement = () => {
const name = newElementName.trim();
if (!name) {
return;
}
const next: IdeFormElementDraft = {
id: `draft.${formKey}.${Date.now()}`,
name,
caption: name,
controlKind: newElementKind,
binding: name,
width: "stretch",
children: []
};
setElementDrafts((current) => ({ ...current, [formKey]: [...(current[formKey] ?? baseElements), next] }));
setNewElementName("");
};
return (
<Card className="min-h-0 rounded-none border-0 p-0 shadow-none" data-active-mode={modeId}>
<PanelTitle icon={Layers3} title={t.formDesigner} />
<div className="grid h-[calc(100%-45px)] min-h-0 grid-cols-[260px_minmax(0,1fr)]">
<div className="grid h-[calc(100%-45px)] min-h-0 grid-cols-[260px_minmax(0,1fr)_340px]">
<div className="min-h-0 overflow-auto border-r border-border bg-[#f4f4f4] p-3">
<div className="flex items-center justify-between border-b border-border pb-2">
<div className="text-xs font-semibold uppercase text-muted-foreground">{t.forms}</div>
@@ -3082,19 +3134,24 @@ function FormDesignerPanel({
</div>
<div className="mt-5 border-t border-border pt-3 text-xs font-semibold uppercase text-muted-foreground">{t.formElements}</div>
<div className="mt-3 space-y-1">
{elements.length === 0 ? (
{flatElements.length === 0 ? (
<div className="text-sm text-muted-foreground">{t.none}</div>
) : (
elements.map((element) => (
<div className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-background" key={element.lineage_id}>
<OneCTreeIcon kind={kindForTreeLabel(element.kind)} />
sidebarElements.map((element) => (
<div className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-background" key={element.id}>
<OneCTreeIcon kind={element.controlKind === "table" ? "tabular" : "attribute"} />
<div className="min-w-0">
<div className="truncate font-medium text-foreground">{element.name}</div>
<div className="truncate text-[11px] text-muted-foreground">{element.kind}</div>
<div className="truncate text-[11px] text-muted-foreground">{element.controlKind} · {element.binding}</div>
</div>
</div>
))
)}
{flatElements.length > sidebarElements.length ? (
<div className="px-2 py-1 text-xs text-muted-foreground">
+{flatElements.length - sidebarElements.length} {language === "ru" ? "элементов в макете" : "layout items"}
</div>
) : null}
</div>
<div className="mt-5 border-t border-border pt-3 text-xs font-semibold uppercase text-muted-foreground">{t.commands}</div>
<div className="mt-3 space-y-1">
@@ -3107,146 +3164,335 @@ function FormDesignerPanel({
</div>
</div>
<div className="min-h-0 overflow-auto bg-[#ececec] p-5">
<div className="mx-auto flex min-h-[760px] max-w-7xl flex-col rounded-xl border border-black/5 bg-white px-5 py-5 shadow-[0_18px_55px_rgba(15,23,42,0.18)]">
<div className="flex items-start justify-between">
<div className="mx-auto flex min-h-[760px] max-w-5xl flex-col border border-[#b8c0ca] bg-[#fbfbfc] shadow-[0_18px_55px_rgba(15,23,42,0.18)]" data-ide-form-window data-ide-form-layout={layout}>
<div className="flex min-h-9 items-center justify-between border-b border-[#cbd3df] bg-gradient-to-b from-[#f8f9fb] to-[#e9edf3] px-3 text-sm text-slate-950">
<div>
<div className="text-lg font-medium text-slate-950">{formObjectCaption}</div>
<button className="mt-8 inline-flex items-center gap-1 text-sm text-slate-900" type="button">
{t.mainSection}
<ChevronDown className="h-3.5 w-3.5" />
</button>
<div className="font-semibold">{formObjectCaption}</div>
<div className="text-[11px] text-slate-500">{data.selectedObjectSchema?.object.qualified_name ?? form?.form.qualified_name ?? data.projectId}</div>
</div>
<div className="flex items-center gap-2 text-slate-500">
<button className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 hover:bg-slate-200" type="button" title="menu">
<button className="flex h-7 w-7 items-center justify-center rounded-full bg-slate-100 hover:bg-slate-200" type="button" title="menu">
<MoreVertical className="h-4 w-4" />
</button>
<button className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 hover:bg-slate-200" type="button" title="expand">
<button className="flex h-7 w-7 items-center justify-center rounded-full bg-slate-100 hover:bg-slate-200" type="button" title="expand">
<Maximize2 className="h-4 w-4" />
</button>
<button className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 hover:bg-slate-200" type="button" title="close">
<button className="flex h-7 w-7 items-center justify-center rounded-full bg-slate-100 hover:bg-slate-200" type="button" title="close">
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="mt-7 grid max-w-3xl grid-cols-[minmax(0,360px)_minmax(0,360px)] gap-x-3 gap-y-3">
<label className="grid gap-1 text-xs text-slate-700">
<span><span className="text-red-500">*</span>{t.nameField}</span>
<span className="flex h-8 items-center rounded-md border border-slate-300 bg-white px-2 text-sm text-slate-900">{formTitle}</span>
</label>
<label className="grid gap-1 text-xs text-slate-700">
{t.code}
<span className="flex h-8 items-center gap-2 rounded-md border border-slate-300 bg-slate-100 px-2 text-sm text-slate-700">
X-00044
<Lock className="ml-auto h-3.5 w-3.5 text-slate-400" />
</span>
</label>
<div className="flex items-center gap-4 pt-5 text-sm text-slate-900">
{[t.client, t.agent, t.supplier].map((label, index) => (
<label className="inline-flex items-center gap-2" key={label}>
<span className={index === 1 ? "flex h-5 w-5 items-center justify-center rounded border border-blue-500 bg-white text-blue-600" : "h-5 w-5 rounded border border-slate-400 bg-white"}>
{index === 1 ? "✓" : ""}
</span>
{label}
</label>
))}
</div>
<label className="grid gap-1 text-xs text-slate-700">
{t.status}
<span className="relative flex h-8 items-center rounded-md border border-blue-600 bg-white px-2 text-sm">
<span className="h-4 w-px bg-slate-950" />
<ChevronDown className="ml-auto h-4 w-4 text-blue-600" />
<span className="absolute left-0 top-9 z-10 grid w-32 gap-1 rounded-lg bg-[#3f3f3f] p-3 text-xs text-white shadow-xl">
<span>{t.agent}</span>
<span>{language === "ru" ? "Прямой клиент" : "Direct client"}</span>
</span>
</span>
</label>
<label className="grid gap-1 text-xs text-slate-700">
{t.comment}
<span className="h-8 rounded-md border border-slate-300 bg-white" />
</label>
<div className="flex min-h-11 flex-wrap items-center gap-1 border-b border-[#ccd4df] bg-[#f4f6f9] px-3 py-2">
{commands.length ? commands.map((command) => (
<button className="h-7 border border-[#aeb8c6] bg-gradient-to-b from-white to-[#edf1f6] px-3 text-xs font-medium text-[#1f2937]" key={command.lineage_id} type="button">
{command.name}
</button>
)) : <span className="text-xs text-muted-foreground">{language === "ru" ? "Команды формы не описаны" : "No form commands"}</span>}
</div>
<div className="mt-4 border-b border-slate-200">
<div className="flex gap-5 text-sm">
{[t.goods, t.sites, t.compensationTerms, t.agencyAgreements, t.telegram, t.mail].map((tab, index) => (
<button
className={index === 0 ? "border-b-2 border-yellow-400 px-2 py-2 text-slate-950" : "px-2 py-2 text-slate-500 hover:text-slate-900"}
key={tab}
type="button"
>
{tab}
</button>
))}
<div className={[
"grid grid-cols-12 gap-x-3 gap-y-2 p-5",
layout === "compact" ? "gap-y-1" : ""
].join(" ")}>
{elements.length ? (
elements.map((element) => (
<IdeFormControl element={element} forceHalf={layout === "columns"} key={element.id} />
))
) : (
<div className="col-span-12 border border-dashed border-[#aeb8c6] bg-white px-4 py-6 text-sm text-slate-600" data-ide-form-empty>
<div className="font-semibold text-slate-900">{language === "ru" ? "Структура элементов формы не загружена" : "Form element structure is not loaded"}</div>
<div className="mt-1 text-xs">
{form?.form.qualified_name ?? form?.form.name ?? data.projectId}
</div>
<div className="mt-3 text-xs">
{language === "ru"
? "В текущем индексе для этой формы нет узлов элементов. SFERA не подставляет шаблонные поля, чтобы не искажать объект 1С."
: "The current index has no element nodes for this form. SFERA does not insert template fields because that would distort the 1C object."}
</div>
</div>
)}
</div>
<div className="mt-3 overflow-hidden rounded-md border border-slate-100">
<div className="flex h-10 items-center gap-3 bg-[#f0f0f0] px-3 text-sm text-slate-800">
<button className="inline-flex h-8 items-center gap-2 rounded px-2 hover:bg-white" type="button">
<Plus className="h-4 w-4" />
{t.create}
</button>
<span className="h-6 w-px bg-slate-300" />
<button className="flex h-8 w-8 items-center justify-center rounded hover:bg-white" type="button" title="copy">
<Copy className="h-4 w-4 text-slate-500" />
</button>
<div className="ml-auto flex items-center gap-3">
<button className="inline-flex items-center gap-2 hover:text-slate-950" type="button">
<Search className="h-4 w-4" />
{t.search}
</button>
<button className="flex h-8 w-8 items-center justify-center rounded hover:bg-white" type="button" title="filter">
<Filter className="h-4 w-4" />
</button>
<button className="flex h-8 w-8 items-center justify-center rounded hover:bg-white" type="button" title="more">
<MoreVertical className="h-4 w-4" />
</button>
</div>
</div>
<div className="grid grid-cols-[48px_1.1fr_90px_1.6fr_1fr_1fr_1.2fr] border-b border-slate-100 text-xs font-semibold text-slate-950">
{["", t.nameField, t.code, t.sentToBankCompanyName, t.mergeProject, t.legalEntity, t.result].map((heading) => (
<div className="px-3 py-3" key={heading}>{heading}</div>
))}
</div>
<div className="flex min-h-[210px] items-center justify-center text-sm text-slate-500">{t.emptyList}</div>
</div>
<div className="mt-auto grid max-w-3xl grid-cols-[minmax(0,330px)_minmax(0,360px)] gap-x-11 gap-y-3 pt-6">
{[
[t.author, t.none],
[t.creationDate, t.none],
[t.editor, t.none],
[t.editDate, t.none]
].map(([label, value]) => (
<label className="grid gap-1 text-xs text-slate-700" key={label}>
{label}
<span className="flex h-8 items-center rounded-md border border-slate-300 bg-slate-100 px-2 text-sm text-slate-700">{value}</span>
</label>
))}
</div>
<div className="mt-5 flex items-center justify-end gap-3">
{commandLabels.map((command) => (
<button className="h-8 rounded-md bg-slate-100 px-3 text-xs font-medium text-slate-700 hover:bg-slate-200" key={command} type="button">
{command}
<div className="mt-auto border-t border-[#ccd4df] bg-white px-4 py-3">
<div className="flex flex-wrap justify-end gap-2">
{commands.slice(0, 3).map((command) => (
<button className="h-8 border border-[#aeb8c6] bg-gradient-to-b from-white to-[#edf1f6] px-3 text-xs font-medium text-slate-700" key={`bottom-${command.lineage_id}`} type="button">
{command.name}
</button>
))}
<button className="h-9 rounded-md bg-yellow-400 px-4 text-sm font-semibold text-slate-950 hover:bg-yellow-300" type="button">
<button className="h-8 bg-yellow-400 px-4 text-sm font-semibold text-slate-950 hover:bg-yellow-300" type="button">
{t.saveAndClose}
</button>
<button className="h-9 rounded-md bg-slate-200 px-4 text-sm font-semibold text-slate-900 hover:bg-slate-300" type="button">
<button className="h-8 bg-slate-200 px-4 text-sm font-semibold text-slate-900 hover:bg-slate-300" type="button">
{t.save}
</button>
</div>
</div>
</div>
</div>
<aside className="min-h-0 overflow-auto border-l border-border bg-background">
<div className="border-b border-border p-3">
<div className="text-sm font-medium">{language === "ru" ? "Свойства формы" : "Form properties"}</div>
<label className="mt-3 grid gap-1 text-xs font-medium text-muted-foreground">
{language === "ru" ? "Заголовок" : "Title"}
<input className="h-8 border border-border bg-background px-2 text-sm text-foreground" value={formTitle} onChange={(event) => setTitleByForm((current) => ({ ...current, [formKey]: event.target.value }))} />
</label>
<label className="mt-2 grid gap-1 text-xs font-medium text-muted-foreground">
{language === "ru" ? "Размещение" : "Layout"}
<select className="h-8 border border-border bg-background px-2 text-sm text-foreground" value={layout} onChange={(event) => setLayoutByForm((current) => ({ ...current, [formKey]: event.target.value as "auto" | "columns" | "compact" }))}>
<option value="auto">{language === "ru" ? "Авто 1С" : "1C auto"}</option>
<option value="columns">{language === "ru" ? "Колонки" : "Columns"}</option>
<option value="compact">{language === "ru" ? "Компактно" : "Compact"}</option>
</select>
</label>
</div>
<div className="grid grid-cols-2 gap-2 border-b border-border p-3">
<IdeFormMetric label="elements" value={flatElements.length} />
<IdeFormMetric label="commands" value={commands.length} />
</div>
<div className="border-b border-border p-3">
<div className="text-xs font-medium text-muted-foreground">{language === "ru" ? "Добавить элемент" : "Add element"}</div>
<div className="mt-2 grid grid-cols-[minmax(0,1fr)_110px] gap-2">
<input className="h-8 border border-border bg-background px-2 text-sm text-foreground" placeholder={language === "ru" ? "Новый реквизит" : "New attribute"} value={newElementName} onChange={(event) => setNewElementName(event.target.value)} />
<select className="h-8 border border-border bg-background px-2 text-sm text-foreground" value={newElementKind} onChange={(event) => setNewElementKind(event.target.value as IdeFormElementDraft["controlKind"])}>
<option value="input">{language === "ru" ? "Поле" : "Input"}</option>
<option value="date">{language === "ru" ? "Дата" : "Date"}</option>
<option value="checkbox">{language === "ru" ? "Флажок" : "Checkbox"}</option>
<option value="table">{language === "ru" ? "Таблица" : "Table"}</option>
<option value="group">{language === "ru" ? "Группа" : "Group"}</option>
<option value="text">{language === "ru" ? "Текст" : "Text"}</option>
</select>
</div>
<button className="mt-2 h-8 w-full bg-primary px-3 text-sm font-medium text-primary-foreground" onClick={addElement} type="button">
{language === "ru" ? "Добавить в макет" : "Add to layout"}
</button>
</div>
<div className="divide-y divide-border">
{propertyElements.map((element) => (
<div className="grid gap-2 p-3" key={`props-${element.id}`}>
<div className="truncate text-xs font-semibold">{element.name}</div>
<input className="h-8 border border-border bg-background px-2 text-sm text-foreground" value={element.caption} onChange={(event) => updateElement(element.id, { caption: event.target.value })} />
<div className="grid grid-cols-2 gap-2">
<select className="h-8 border border-border bg-background px-2 text-sm text-foreground" value={element.controlKind} onChange={(event) => updateElement(element.id, { controlKind: event.target.value as IdeFormElementDraft["controlKind"] })}>
<option value="input">{language === "ru" ? "Поле ввода" : "Input"}</option>
<option value="date">{language === "ru" ? "Дата" : "Date"}</option>
<option value="checkbox">{language === "ru" ? "Флажок" : "Checkbox"}</option>
<option value="table">{language === "ru" ? "Таблица" : "Table"}</option>
<option value="group">{language === "ru" ? "Группа" : "Group"}</option>
<option value="text">{language === "ru" ? "Текст" : "Text"}</option>
</select>
<select className="h-8 border border-border bg-background px-2 text-sm text-foreground" value={element.width} onChange={(event) => updateElement(element.id, { width: event.target.value as IdeFormElementDraft["width"] })}>
<option value="stretch">{language === "ru" ? "Вся строка" : "Full"}</option>
<option value="half">1/2</option>
<option value="third">1/3</option>
</select>
</div>
<input className="h-8 border border-border bg-background px-2 text-sm text-foreground" value={element.binding} onChange={(event) => updateElement(element.id, { binding: event.target.value })} />
</div>
))}
{flatElements.length > propertyElements.length ? (
<div className="p-3 text-xs text-muted-foreground">
{language === "ru"
? `Показаны первые ${propertyElements.length} свойств из ${flatElements.length}. Остальные элементы доступны в макете формы.`
: `Showing first ${propertyElements.length} properties out of ${flatElements.length}. Other items are available in the form layout.`}
</div>
) : null}
</div>
</aside>
</div>
</Card>
);
}
type IdeFormElementDraft = {
id: string;
name: string;
caption: string;
controlKind: "input" | "date" | "checkbox" | "table" | "group" | "text";
binding: string;
width: "stretch" | "half" | "third";
qualifiedName?: string;
parentQualifiedName?: string | null;
children: IdeFormElementDraft[];
};
function IdeFormControl({ element, forceHalf }: Readonly<{ element: IdeFormElementDraft; forceHalf: boolean }>) {
const span = element.controlKind === "table" || element.controlKind === "group" || element.controlKind === "text" ? "col-span-12" : forceHalf || element.width === "half" ? "col-span-6" : element.width === "third" ? "col-span-4" : "col-span-12";
if (element.controlKind === "table") {
return (
<section className={`${span} border border-[#aeb8c6] bg-white`} data-ide-form-element={element.id} data-ide-form-control={element.controlKind}>
<div className="flex min-h-8 items-center justify-between border-b border-[#ccd4df] bg-[#eef2f7] px-2 text-xs font-semibold text-[#1f2937]">
<span>{element.caption}</span>
<span className="text-[11px] font-medium text-[#687385]">{element.binding}</span>
</div>
{ideFormControlInput(element)}
</section>
);
}
if (element.controlKind === "group") {
return (
<fieldset className={`${span} border border-[#b9c1cd] bg-[#f6f8fb] px-3 pb-3 pt-2`} data-ide-form-element={element.id} data-ide-form-control={element.controlKind}>
<legend className="px-1 text-xs font-semibold text-[#4b5563]">{element.caption}</legend>
<div className="grid grid-cols-12 gap-x-3 gap-y-2">
{element.children.length ? element.children.map((child) => <IdeFormControl element={child} forceHalf={forceHalf} key={child.id} />) : null}
</div>
</fieldset>
);
}
if (element.controlKind === "text") {
return (
<div className={`${span} border border-transparent px-1 py-1`} data-ide-form-element={element.id} data-ide-form-control={element.controlKind}>
{ideFormControlInput(element)}
</div>
);
}
return (
<div className={`${span} grid min-h-8 grid-cols-[150px_minmax(0,1fr)] items-center gap-2 border border-transparent px-1 py-1 hover:border-[#b9c1cd] hover:bg-white`} data-ide-form-element={element.id} data-ide-form-control={element.controlKind}>
<label className="truncate text-xs font-semibold text-[#4b5563]">{element.caption}</label>
{ideFormControlInput(element)}
</div>
);
}
function ideFormControlInput(element: IdeFormElementDraft) {
if (element.controlKind === "table") {
const columns = element.children.filter((child) => child.controlKind !== "text" && child.controlKind !== "group");
const visibleColumns = columns.length ? columns : [{ ...element, caption: element.caption, name: element.name, id: `${element.id}.column`, children: [] }];
return (
<div className="min-h-32 overflow-hidden bg-white text-xs">
<div className="grid" style={{ gridTemplateColumns: `repeat(${Math.min(visibleColumns.length, 8)}, minmax(120px, 1fr))` }}>
{visibleColumns.slice(0, 8).map((column) => (
<span className="min-h-7 border-b border-r border-[#d7dde6] bg-[#f4f6f9] px-2 py-1 font-semibold text-[#374151] last:border-r-0" key={column.id}>
{column.caption}
</span>
))}
</div>
{[0, 1, 2].map((row) => (
<div className="grid" key={row} style={{ gridTemplateColumns: `repeat(${Math.min(visibleColumns.length, 8)}, minmax(120px, 1fr))` }}>
{visibleColumns.slice(0, 8).map((column) => (
<span className="min-h-7 border-b border-r border-[#edf1f6] px-2 py-1 last:border-r-0" key={`${row}-${column.id}`} />
))}
</div>
))}
</div>
);
}
if (element.controlKind === "checkbox") {
return <label className="flex items-center gap-2 text-xs text-slate-800"><input checked readOnly type="checkbox" />{element.binding}</label>;
}
if (element.controlKind === "group") {
return <div className="min-h-11 border border-dashed border-[#aeb8c6] bg-[#f6f8fb] px-2 py-2 text-xs font-medium text-muted-foreground">{element.binding}</div>;
}
if (element.controlKind === "text") {
return <div className="whitespace-pre-wrap border border-transparent bg-transparent px-1 py-1 text-xs leading-5 text-[#374151]">{element.caption || element.binding}</div>;
}
return <input className="h-7 border border-[#aeb8c6] bg-white px-2 text-xs" readOnly value={element.binding} />;
}
function IdeFormMetric({ label, value }: Readonly<{ label: string; value: number }>) {
return (
<div className="border border-border p-2">
<div className="text-base font-semibold">{value}</div>
<div className="text-[11px] text-muted-foreground">{label}</div>
</div>
);
}
function buildIdeFormElements(form: ProjectWorkspaceData["forms"][number] | undefined): IdeFormElementDraft[] {
const explicitElements = form?.elements ?? [];
if (explicitElements.length) {
const formQualifiedName = form?.form.qualified_name ?? "";
const drafts = explicitElements
.filter((element) => formElementString(element.attributes, ["visible"]) !== "false")
.map((element, index) => {
const qualifiedName = element.qualified_name ?? element.name;
return {
id: element.lineage_id || `element.${index}`,
name: element.name,
caption: formElementCaption(element),
controlKind: controlKindForFormNode(element.name, formElementString(element.attributes, ["control_kind", "control", "type", "kind"]) ?? element.kind),
binding: formElementString(element.attributes, ["binding", "dataPath", "data_path", "path"]) ?? qualifiedName ?? element.name,
width: formElementWidth(element.attributes, index),
qualifiedName,
parentQualifiedName: parentQualifiedNameForElement(qualifiedName, formQualifiedName),
children: []
} satisfies IdeFormElementDraft;
});
return nestIdeFormElements(drafts);
}
return [];
}
function formElementCaption(element: ProjectWorkspaceData["forms"][number]["elements"][number]) {
const title = formElementString(element.attributes, ["caption", "title", "synonym"]);
if (title) return title;
const dataPath = formElementString(element.attributes, ["dataPath", "data_path", "path"]);
if (dataPath?.includes(".")) return dataPath.split(".").at(-1) ?? element.name;
return element.name;
}
function parentQualifiedNameForElement(qualifiedName: string, formQualifiedName: string) {
const dot = qualifiedName.lastIndexOf(".");
if (dot < 0) return null;
const parent = qualifiedName.slice(0, dot);
return parent === formQualifiedName ? null : parent;
}
function nestIdeFormElements(elements: IdeFormElementDraft[]) {
const byQualifiedName = new Map(elements.map((element) => [element.qualifiedName, element]));
const roots: IdeFormElementDraft[] = [];
for (const element of elements) {
const parent = element.parentQualifiedName ? byQualifiedName.get(element.parentQualifiedName) : null;
if (parent) {
parent.children.push(element);
} else {
roots.push(element);
}
}
return roots;
}
function flattenIdeFormElements(elements: IdeFormElementDraft[]): IdeFormElementDraft[] {
return elements.flatMap((element) => [element, ...flattenIdeFormElements(element.children)]);
}
function updateIdeFormElementTree(elements: IdeFormElementDraft[], id: string, patch: Partial<IdeFormElementDraft>): IdeFormElementDraft[] {
return elements.map((element) => (
element.id === id
? { ...element, ...patch }
: { ...element, children: updateIdeFormElementTree(element.children, id, patch) }
));
}
function formElementString(attributes: Record<string, unknown>, keys: string[]): string | null {
for (const key of keys) {
const value = attributes[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return null;
}
function formElementWidth(attributes: Record<string, unknown>, index: number): IdeFormElementDraft["width"] {
const raw = formElementString(attributes, ["width", "layout_width", "placement"]);
if (raw === "half" || raw === "third" || raw === "stretch") {
return raw;
}
return index < 2 ? "half" : "stretch";
}
function controlKindForFormNode(name: string, kind: string): IdeFormElementDraft["controlKind"] {
const normalized = `${name} ${kind}`.toLowerCase();
if (normalized.includes("decoration") || normalized.includes("label") || normalized.includes("надп")) return "text";
if (normalized.includes("group") || normalized.includes("группа") || normalized.includes("pages") || normalized.includes("page")) return "group";
if (normalized.includes("таб") || normalized.includes("table")) return "table";
if (normalized.includes("дата") || normalized.includes("date")) return "date";
if (normalized.includes("флаг") || normalized.includes("boolean") || normalized.includes("булево")) return "checkbox";
return "input";
}
function MetadataDesignerPanel({
activeMode,
data,
@@ -772,7 +772,10 @@ export function ProjectSetupClient({ initialSetup }: Readonly<{ initialSetup: Pr
return;
} else if (mode === "reindex") {
setLastSyncPreview(null);
await postJson(`/api/sfera/projects/${setup.project_id}/reindex`, undefined);
const job = await postJson<OperationJob>(`/api/sfera/projects/${setup.project_id}/reindex/jobs`, undefined);
setServerImportJob(job);
keepImportRun = true;
return;
} else {
setLastImportResult(null);
setServerImportJob(null);
@@ -5500,7 +5503,7 @@ function ObjectWorkbench({
<ObjectPartList title="Модули" rows={object.modules} />
<ObjectRightsList rows={object.rights} />
</div>
<FormDesignerPanel object={object} />
<FormDesignerPanel projectId={projectId} object={object} />
<ObjectImpactPanel impact={objectImpact} />
<ModuleSourcePanel
projectId={projectId}
@@ -5512,18 +5515,75 @@ function ObjectWorkbench({
);
}
function FormDesignerPanel({ object }: Readonly<{ object: NormalizedMetadataObject }>) {
type FormDesignerElementDraft = {
id: string;
name: string;
caption: string;
kind: "input" | "date" | "checkbox" | "table" | "group" | "text";
binding: string;
width: "stretch" | "half" | "third";
};
function FormDesignerPanel({ projectId, object }: Readonly<{ projectId: string; object: NormalizedMetadataObject }>) {
const [activeFormName, setActiveFormName] = useState<string | null>(null);
const activeForm = object.forms.find((form) => form.name === activeFormName) ?? object.forms[0] ?? null;
const formFields = [...object.attributes, ...object.tabular_sections];
const activeFormKey = activeForm?.name ?? "";
const [titleByForm, setTitleByForm] = useState<Record<string, string>>({});
const [layoutByForm, setLayoutByForm] = useState<Record<string, "auto" | "columns" | "compact">>({});
const [elementOverrides, setElementOverrides] = useState<Record<string, Partial<FormDesignerElementDraft>>>({});
const [extraElementsByForm, setExtraElementsByForm] = useState<Record<string, FormDesignerElementDraft[]>>({});
const [newElementName, setNewElementName] = useState("");
const [newElementKind, setNewElementKind] = useState<FormDesignerElementDraft["kind"]>("input");
const baseElements = activeForm ? buildFormDesignerElements(activeForm) : [];
const elements = [...baseElements, ...(extraElementsByForm[activeFormKey] ?? [])].map((element) => ({
...element,
...(elementOverrides[element.id] ?? {})
}));
const formTitle = titleByForm[activeFormKey] ?? activeForm?.name ?? "Форма";
const layout = layoutByForm[activeFormKey] ?? "auto";
const html5EditorHref = activeForm
? `/html5/projects/${encodeURIComponent(projectId)}/forms/editor?form=${encodeURIComponent(formQualifiedName(object, activeForm))}`
: `/html5/projects/${encodeURIComponent(projectId)}/forms/editor`;
const updateElement = (id: string, patch: Partial<FormDesignerElementDraft>) => {
setElementOverrides((current) => ({ ...current, [id]: { ...(current[id] ?? {}), ...patch } }));
};
const addElement = () => {
const name = newElementName.trim();
if (!name || !activeFormKey) {
return;
}
const next: FormDesignerElementDraft = {
id: `draft-${activeFormKey}-${Date.now()}`,
name,
caption: name,
kind: newElementKind,
binding: name,
width: "stretch"
};
setExtraElementsByForm((current) => ({ ...current, [activeFormKey]: [...(current[activeFormKey] ?? []), next] }));
setNewElementName("");
};
return (
<div className="mt-5 rounded-md border border-border">
<div className="flex h-10 items-center justify-between border-b border-border px-3">
<div className="text-sm font-medium">Form designer</div>
<div className="mt-5 overflow-hidden rounded-md border border-border">
<div className="flex min-h-10 items-center justify-between gap-3 border-b border-border px-3 py-2">
<div>
<div className="text-sm font-medium">Редактор формы</div>
<div className="text-xs text-muted-foreground">Управляемая форма 1С 8.5: элементы, команды, реквизиты и модуль остаются частью объекта.</div>
</div>
<div className="flex items-center gap-2">
{activeForm ? (
<a className="rounded-md border border-border px-2 py-1 text-xs font-medium hover:bg-muted" href={html5EditorHref}>
HTML5 редактор
</a>
) : null}
<Badge>{object.forms.length}</Badge>
</div>
</div>
{activeForm ? (
<div className="grid min-h-72 grid-cols-[220px_minmax(0,1fr)_260px]">
<div className="grid min-h-[520px] grid-cols-[220px_minmax(0,1fr)_340px]">
<div className="border-r border-border p-2">
{object.forms.map((form) => (
<button
@@ -5535,40 +5595,144 @@ function FormDesignerPanel({ object }: Readonly<{ object: NormalizedMetadataObje
}`}
>
<div className="truncate font-medium">{form.name}</div>
<div className="truncate text-xs text-muted-foreground">{form.kind}</div>
<div className="truncate text-xs text-muted-foreground">{formQualifiedName(object, form)}</div>
</button>
))}
</div>
<div className="min-w-0 p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{activeForm.name}</div>
<div className="truncate text-xs text-muted-foreground">{object.qualified_name}</div>
<div className="min-w-0 bg-[#f3f5f8] p-5">
<div className="mx-auto min-h-[430px] max-w-[760px] border border-[#b8c0ca] bg-[#fbfbfc] shadow-lg" data-legacy-form-window>
<div className="flex min-h-9 items-center justify-between border-b border-[#cbd3df] bg-gradient-to-b from-[#f8f9fb] to-[#e9edf3] px-3 text-sm font-semibold">
<span className="truncate">{formTitle}</span>
<span className="text-[11px] text-muted-foreground">1C:Enterprise 8.5-style managed form</span>
</div>
<Badge>{activeForm.kind}</Badge>
<div className="flex min-h-11 flex-wrap gap-1 border-b border-[#ccd4df] bg-[#f4f6f9] px-3 py-2">
{object.commands.length ? object.commands.map((command) => (
<button
key={`form-command-${command.name}`}
type="button"
className="h-7 rounded-none border border-[#aeb8c6] bg-gradient-to-b from-white to-[#edf1f6] px-3 text-xs font-medium text-[#1f2937]"
>
{command.name}
</button>
)) : <span className="text-xs text-muted-foreground">Команды не описаны</span>}
</div>
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
{formFields.slice(0, 18).map((field) => (
<div key={`field-${field.kind}-${field.name}`} className="rounded-md border border-border p-2">
<div className="truncate text-sm font-medium">{field.name}</div>
<div className="truncate text-xs text-muted-foreground">{field.kind}</div>
<div
className={`grid grid-cols-12 gap-x-3 gap-y-2 p-4 ${layout === "compact" ? "gap-y-1" : ""}`}
data-legacy-form-layout={layout}
>
{elements.length ? (
elements.map((element) => (
<LegacyFormControl key={element.id} element={element} forceHalf={layout === "columns"} />
))
) : (
<div className="col-span-12 border border-dashed border-[#aeb8c6] bg-white px-4 py-6 text-sm text-muted-foreground">
<div className="font-semibold text-foreground">Структура элементов формы не загружена</div>
<div className="mt-1 text-xs">{formQualifiedName(object, activeForm)}</div>
<div className="mt-3 text-xs">SFERA не подставляет реквизиты объекта вместо элементов формы, чтобы не искажать объект 1С.</div>
</div>
))}
{formFields.length === 0 ? (
<div className="text-sm text-muted-foreground">Поля формы не найдены в structure-only metadata.</div>
) : null}
)}
</div>
</div>
<div className="border-l border-border p-3">
<div className="text-sm font-medium">Form context</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<SmallMetric label="fields" value={formFields.length} />
</div>
<div className="overflow-auto border-l border-border bg-background">
<div className="border-b border-border p-3">
<div className="text-sm font-medium">Свойства формы</div>
<div className="mt-3 grid gap-2">
<label className="grid gap-1 text-xs font-medium text-muted-foreground">
Заголовок
<input
value={formTitle}
onChange={(event) => setTitleByForm((current) => ({ ...current, [activeFormKey]: event.target.value }))}
className="h-8 border border-border bg-background px-2 text-sm text-foreground"
/>
</label>
<label className="grid gap-1 text-xs font-medium text-muted-foreground">
Размещение
<select
value={layout}
onChange={(event) => setLayoutByForm((current) => ({ ...current, [activeFormKey]: event.target.value as "auto" | "columns" | "compact" }))}
className="h-8 border border-border bg-background px-2 text-sm text-foreground"
>
<option value="auto">Авто 1С</option>
<option value="columns">Колонки</option>
<option value="compact">Компактно</option>
</select>
</label>
</div>
</div>
<div className="grid grid-cols-2 gap-2 border-b border-border p-3">
<SmallMetric label="elements" value={elements.length} />
<SmallMetric label="commands" value={object.commands.length} />
<SmallMetric label="modules" value={object.modules.length} />
<SmallMetric label="events" value={object.events.length} />
</div>
<ObjectMetaList title="Commands" rows={object.commands} />
<ObjectMetaList title="Form metadata" rows={Object.entries(activeForm.attributes ?? {}).map(([key, value]) => `${key}: ${String(value)}`)} />
<div className="border-b border-border p-3">
<div className="text-xs font-medium text-muted-foreground">Добавить элемент</div>
<div className="mt-2 grid grid-cols-[minmax(0,1fr)_110px] gap-2">
<input
value={newElementName}
onChange={(event) => setNewElementName(event.target.value)}
placeholder="Новый реквизит"
className="h-8 border border-border bg-background px-2 text-sm text-foreground"
/>
<select
value={newElementKind}
onChange={(event) => setNewElementKind(event.target.value as FormDesignerElementDraft["kind"])}
className="h-8 border border-border bg-background px-2 text-sm text-foreground"
>
<option value="input">Поле</option>
<option value="date">Дата</option>
<option value="checkbox">Флажок</option>
<option value="table">Таблица</option>
<option value="group">Группа</option>
<option value="text">Текст</option>
</select>
</div>
<button type="button" onClick={addElement} className="mt-2 h-8 w-full rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground">
Добавить в макет
</button>
</div>
<div className="divide-y divide-border">
{elements.map((element) => (
<div key={`props-${element.id}`} className="grid gap-2 p-3">
<div className="truncate text-xs font-semibold">{element.name}</div>
<input
value={element.caption}
onChange={(event) => updateElement(element.id, { caption: event.target.value })}
className="h-8 border border-border bg-background px-2 text-sm text-foreground"
/>
<div className="grid grid-cols-2 gap-2">
<select
value={element.kind}
onChange={(event) => updateElement(element.id, { kind: event.target.value as FormDesignerElementDraft["kind"] })}
className="h-8 border border-border bg-background px-2 text-sm text-foreground"
>
<option value="input">Поле ввода</option>
<option value="date">Дата</option>
<option value="checkbox">Флажок</option>
<option value="table">Таблица</option>
<option value="group">Группа</option>
<option value="text">Текст</option>
</select>
<select
value={element.width}
onChange={(event) => updateElement(element.id, { width: event.target.value as FormDesignerElementDraft["width"] })}
className="h-8 border border-border bg-background px-2 text-sm text-foreground"
>
<option value="stretch">Вся строка</option>
<option value="half">1/2</option>
<option value="third">1/3</option>
</select>
</div>
<input
value={element.binding}
onChange={(event) => updateElement(element.id, { binding: event.target.value })}
className="h-8 border border-border bg-background px-2 text-sm text-foreground"
/>
</div>
))}
</div>
<ObjectMetaList title="Модуль/метаданные формы" rows={Object.entries(activeForm.attributes ?? {}).map(([key, value]) => `${key}: ${String(value)}`)} />
</div>
</div>
) : (
@@ -5578,6 +5742,87 @@ function FormDesignerPanel({ object }: Readonly<{ object: NormalizedMetadataObje
);
}
function LegacyFormControl({ element, forceHalf }: Readonly<{ element: FormDesignerElementDraft; forceHalf: boolean }>) {
const span = element.kind === "table" || element.kind === "group" ? "col-span-12" : forceHalf || element.width === "half" ? "col-span-6" : element.width === "third" ? "col-span-4" : "col-span-12";
return (
<div className={`${span} grid min-h-8 grid-cols-[150px_minmax(0,1fr)] items-center gap-2 border border-transparent px-1 py-1 hover:border-[#b9c1cd] hover:bg-white`}>
<label className="truncate text-xs font-semibold text-[#4b5563]">{element.caption}</label>
{legacyFormControlInput(element)}
</div>
);
}
function legacyFormControlInput(element: FormDesignerElementDraft): ReactNode {
if (element.kind === "table") {
return (
<div className="min-h-24 border border-[#aeb8c6] bg-white text-xs">
<div className="grid grid-cols-[2fr_1fr_1fr] bg-[#eef2f7] font-semibold">
<span className="border-b border-r border-[#d7dde6] px-2 py-1">{element.binding}</span>
<span className="border-b border-r border-[#d7dde6] px-2 py-1">Количество</span>
<span className="border-b border-[#d7dde6] px-2 py-1">Сумма</span>
</div>
<div className="grid grid-cols-[2fr_1fr_1fr]"><span className="min-h-7 border-r border-[#d7dde6]" /><span className="border-r border-[#d7dde6]" /><span /></div>
</div>
);
}
if (element.kind === "checkbox") {
return <label className="flex items-center gap-2 text-xs"><input type="checkbox" checked readOnly />{element.binding}</label>;
}
if (element.kind === "group") {
return <div className="min-h-11 border border-dashed border-[#aeb8c6] bg-[#f6f8fb] px-2 py-2 text-xs font-medium text-muted-foreground">{element.binding}</div>;
}
if (element.kind === "text") {
return <textarea value={element.binding} readOnly className="min-h-16 resize-none border border-[#aeb8c6] bg-white px-2 py-1 text-xs" />;
}
return <input value={element.binding} readOnly className="h-7 border border-[#aeb8c6] bg-white px-2 text-xs" />;
}
function buildFormDesignerElements(form: ObjectPart): FormDesignerElementDraft[] {
const rawElements = form.attributes.elements;
if (Array.isArray(rawElements)) {
return rawElements
.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
.map((item, index) => {
const name = String(item.name ?? item.caption ?? `Элемент${index + 1}`);
const kind = String(item.control_kind ?? item.control ?? item.type ?? item.kind ?? "");
return {
id: String(item.lineage_id ?? item.id ?? `${form.name}-${name}-${index}`),
name,
caption: String(item.caption ?? name),
kind: formDesignerKindFor(kind, name),
binding: String(item.binding ?? item.path ?? name),
width: String(item.width ?? "") === "half" ? "half" : String(item.width ?? "") === "third" ? "third" : "stretch"
};
});
}
return [];
}
function formDesignerKindFor(kind: string, name: string): FormDesignerElementDraft["kind"] {
const raw = `${kind} ${name}`.toLowerCase();
if (raw.includes("table") || raw.includes("табли") || raw.includes("список")) {
return "table";
}
if (raw.includes("check") || raw.includes("boolean") || raw.includes("флаж") || raw.includes("булево")) {
return "checkbox";
}
if (raw.includes("date") || raw.includes("дата")) {
return "date";
}
if (raw.includes("group") || raw.includes("груп")) {
return "group";
}
if (raw.includes("text") || raw.includes("надпись")) {
return "text";
}
return "input";
}
function formQualifiedName(object: NormalizedMetadataObject, form: ObjectPart): string {
const qualified = form.attributes.qualified_name ?? form.attributes.qualifiedName;
return typeof qualified === "string" && qualified ? qualified : `${object.qualified_name}.${form.name}`;
}
function ObjectMetaList({ title, rows }: Readonly<{ title: string; rows: ObjectPart[] | string[] }>) {
return (
<div className="mt-3">
+31 -2
View File
@@ -139,6 +139,7 @@ export type NamedNode = {
kind: string;
name: string;
qualified_name: string;
attributes: Record<string, unknown>;
};
export type SourceLocation = {
@@ -816,6 +817,22 @@ export async function getBslCompletions(
return getJson<BslCompletionItem[]>(apiUrl, `/projects/${projectId}/bsl/completions?${params.toString()}`);
}
function ownerQualifiedNameForForm(formQualifiedName: string) {
const parts = formQualifiedName.split(".");
if (parts[0] === "ОбщаяФорма") {
return formQualifiedName;
}
return parts.length > 1 ? parts.slice(0, -1).join(".") : formQualifiedName;
}
function looksLikeObjectFormQualifiedName(qualifiedName: string) {
const parts = qualifiedName.split(".");
if (parts[0] === "ОбщаяФорма") {
return parts.length === 2;
}
return parts.length >= 3;
}
export async function getProjectWorkspaceData(projectId: string, apiUrl = resolveApiUrl(), selectedRoutine?: string | null, activeMode?: string | null) {
const selectedRoutineName = selectedRoutine?.trim() ?? null;
const workspaceMode = activeMode?.trim() || "overview";
@@ -850,7 +867,18 @@ export async function getProjectWorkspaceData(projectId: string, apiUrl = resolv
);
selectedTreeNode = firstCommonModulePage?.children[0] ?? null;
}
const selectedObjectName = selectedRoutineName ?? selectedTreeNode?.qualified_name ?? null;
const selectedFormQualifiedName =
selectedTreeNode?.kind === "FORM"
? selectedTreeNode.qualified_name
: selectedRoutineName && (
selectedRoutineName.split(".").at(-1)?.toLocaleLowerCase("ru-RU").includes("форма") ||
looksLikeObjectFormQualifiedName(selectedRoutineName)
)
? selectedRoutineName
: null;
const selectedObjectName = selectedFormQualifiedName
? ownerQualifiedNameForForm(selectedFormQualifiedName)
: selectedRoutineName ?? selectedTreeNode?.qualified_name ?? null;
const selectedObjectModules = selectedObjectName
? getOptionalJson<WorkspaceModuleSource[]>(
apiUrl,
@@ -956,7 +984,7 @@ export async function getProjectWorkspaceData(projectId: string, apiUrl = resolv
apiUrl,
metadataCatalog,
metadataTree,
selectedMetadataNode: selectedMetadataSearch?.results[0] ?? null,
selectedMetadataNode: selectedTreeNode,
selectedObjectSchema,
selectedObjectUi,
selectedObjectImpact,
@@ -977,6 +1005,7 @@ export async function getProjectWorkspaceData(projectId: string, apiUrl = resolv
editorProposedText: authoringProposedText,
editorSelectedObject,
editorSelectedRoutine,
editorSelectedForm: selectedFormQualifiedName,
editorModules: objectModules,
editorModuleName: selectedObjectModule?.name ?? snapshotModule?.name ?? null,
editorSourcePath: selectedObjectModule?.source_path ?? snapshotModule?.source_path ?? null
+1 -1
View File
@@ -29,6 +29,7 @@ http://server/base/hs/sfera/v1/metadata/apply
- `data.read` - чтение данных через ограниченный запрос или менеджер объекта.
- `data.write` - изменение данных только при явном `allow_mutation`.
- `metadata.apply` - изменение структуры не выполняется из HTTP runtime. Возвращает план установки `.cfe`; применение делает Windows Agent через Designer.
- `access.profile.apply` - dry-run проверки плана профиля доступа через `/v1/metadata/apply`. Универсальный мост подтверждает профиль, роли и операции, но реальную запись профилей доступа выполняет только отдельный адаптер под конкретную конфигурацию/БСП или Windows Agent.
## Безопасность
@@ -41,4 +42,3 @@ http://server/base/hs/sfera/v1/metadata/apply
- `dry_run=false`.
Без этого операции изменения возвращают блокировку.
@@ -49,7 +49,8 @@
КонецЕсли;
Возврат ОтветJSON(BridgeMetadataApply(
ПолучитьПоле(Контекст, "payload", Новый Структура),
ПолучитьПоле(Контекст, "dry_run", Истина)));
ПолучитьПоле(Контекст, "dry_run", Истина),
ПолучитьПоле(Контекст, "allow_mutation", Ложь)));
КонецФункции
#КонецОбласти
@@ -64,6 +65,7 @@
Результат.Вставить("timestamp", ТекущаяДата());
Результат.Вставить("mutation_supported", Истина);
Результат.Вставить("metadata_apply_supported", Ложь);
Результат.Вставить("access_profile_apply_supported", Истина);
Возврат Результат;
КонецФункции
@@ -153,7 +155,11 @@
Возврат ОшибкаSFERA("Generic write adapter is intentionally not enabled yet. Implement object-specific handlers first.");
КонецФункции
Функция BridgeMetadataApply(Параметры, DryRun)
Функция BridgeMetadataApply(Параметры, DryRun, AllowMutation)
Операция = Строка(ПолучитьПоле(Параметры, "operation", ""));
Если Операция = "access.profile.apply" Тогда
Возврат BridgeAccessProfileApply(Параметры, DryRun, AllowMutation);
КонецЕсли;
Результат = Новый Структура;
Результат.Вставить("status", "planned");
Результат.Вставить("message", "Changing configuration structure is performed by SFERA Windows Agent through Designer and .cfe update, not by runtime HTTP.");
@@ -162,6 +168,27 @@
Возврат Результат;
КонецФункции
Функция BridgeAccessProfileApply(Параметры, DryRun, AllowMutation)
Профиль = ПолучитьПоле(Параметры, "profile", Новый Структура);
Операции = ПолучитьПоле(Параметры, "operations", Новый Массив);
ИмяПрофиля = Строка(ПолучитьПоле(Профиль, "qualified_name", ПолучитьПоле(Профиль, "name", "")));
Если ПустаяСтрока(ИмяПрофиля) Тогда
Возврат ОшибкаSFERA("profile.name or profile.qualified_name is required for access.profile.apply");
КонецЕсли;
Если Не DryRun И Не AllowMutation Тогда
Возврат ОшибкаSFERA("Access profile mutation is blocked. Use dry_run=true or allow_mutation=true with project mutation guard enabled.");
КонецЕсли;
Результат = Новый Структура;
Результат.Вставить("status", ?(DryRun, "dry_run", "planned"));
Результат.Вставить("operation", "access.profile.apply");
Результат.Вставить("profile", ИмяПрофиля);
Результат.Вставить("operations_count", КоличествоЭлементовSFERA(Операции));
Результат.Вставить("operations", Операции);
Результат.Вставить("message", "Access profile plan was accepted by SFERA extension. Runtime mutation is not executed by the generic bridge; apply through a configuration-specific adapter or Windows Agent.");
Результат.Вставить("dry_run", DryRun);
Возврат Результат;
КонецФункции
#КонецОбласти
#Область СлужебныеПроцедурыИФункции
@@ -210,6 +237,16 @@
Возврат ЗначениеПоУмолчанию;
КонецФункции
Функция КоличествоЭлементовSFERA(Значение)
Если Значение = Неопределено Тогда
Возврат 0;
КонецЕсли;
Если ТипЗнч(Значение) = Тип("Массив") Или ТипЗнч(Значение) = Тип("Структура") Или ТипЗнч(Значение) = Тип("Соответствие") Тогда
Возврат Значение.Количество();
КонецЕсли;
Возврат 0;
КонецФункции
Процедура ДобавитьКоллекциюМетаданных(Коллекции, ИмяКоллекции, КоллекцияМетаданных)
Объекты = Новый Массив;
Для Каждого ОбъектМетаданных Из КоллекцияМетаданных Цикл
@@ -1,7 +1,7 @@
from __future__ import annotations
import hashlib
from pathlib import Path
from pathlib import Path, PurePosixPath
import re
import xml.etree.ElementTree as ET
@@ -71,6 +71,49 @@ class Rights(ObjectPart):
permissions: dict = {}
class AccessRoleAssignment(BaseModel):
role: str
role_qualified_name: str | None = None
source: str | None = None
attributes: dict = {}
class AccessProfile(BaseModel):
name: str
qualified_name: str | None = None
source_path: str | None = None
attributes: dict = {}
roles: list[AccessRoleAssignment] = []
class AccessGroup(BaseModel):
name: str
qualified_name: str | None = None
source_path: str | None = None
profile: str | None = None
profile_qualified_name: str | None = None
attributes: dict = {}
roles: list[AccessRoleAssignment] = []
users: list[str] = []
class AccessUser(BaseModel):
name: str
qualified_name: str | None = None
source_path: str | None = None
full_name: str | None = None
disabled: bool = False
attributes: dict = {}
roles: list[AccessRoleAssignment] = []
groups: list[str] = []
class AccessModel(BaseModel):
profiles: list[AccessProfile] = []
groups: list[AccessGroup] = []
users: list[AccessUser] = []
class Extension(ObjectPart):
kind: str = "EXTENSION"
version: str | None = None
@@ -137,6 +180,7 @@ class NormalizedProject(BaseModel):
project_id: str | None = None
configuration: ConfigurationRoot
source_path: str | None = None
access: AccessModel = Field(default_factory=AccessModel)
def normalize_bsl_source(text: str) -> str:
@@ -171,7 +215,7 @@ def parse_one_c_xml_file(path: str | Path) -> list[OneCXmlObject]:
source_path = normalize_source_path(path)
root = ET.fromstring(_read_text_file(Path(path)))
result: list[OneCXmlObject] = []
_walk_xml_objects(source_path, root, result, current_role=None, parent_qualified_name=None)
_walk_xml_objects(source_path, root, result, current_role=None, parent_qualified_name=None, parent_object_kind=None)
return result
@@ -207,6 +251,9 @@ def build_normalized_project(
extension_metadata_objects: dict[str, dict[str, MetadataObject]] = {}
part_owners: dict[str, tuple[MetadataObject, ObjectPart]] = {}
pending_roles: dict[str, Role] = {}
access_profiles: dict[str, AccessProfile] = {}
access_groups: dict[str, AccessGroup] = {}
access_users: dict[str, AccessUser] = {}
extensions: list[Extension] = []
extension_by_qualified_name: dict[str, Extension] = {}
saw_configuration = False
@@ -248,6 +295,19 @@ def build_normalized_project(
metadata=dict(item.attributes),
rights=role.rights,
)
elif item.object_kind == "ACCESS_PROFILE":
profile = _access_profile_from_item(item)
access_profiles[_access_key(profile.qualified_name, profile.name)] = profile
elif item.object_kind == "ACCESS_GROUP":
group = _access_group_from_item(item)
access_groups[_access_key(group.qualified_name, group.name)] = group
elif item.object_kind == "ACCESS_USER":
user = _access_user_from_item(item)
access_users[_access_key(user.qualified_name, user.name)] = user
elif item.object_kind == "ACCESS_ROLE_ASSIGNMENT":
_attach_access_role_assignment(item, access_profiles, access_groups, access_users)
elif item.object_kind == "ACCESS_GROUP_MEMBERSHIP":
_attach_access_group_membership(item, access_groups, access_users)
elif item.object_kind in _ROOT_METADATA_OBJECT_KINDS:
target_objects = _metadata_target_for_item(
item,
@@ -292,6 +352,11 @@ def build_normalized_project(
return NormalizedProject(
project_id=project_id,
source_path=source_path,
access=AccessModel(
profiles=sorted(access_profiles.values(), key=lambda item: item.name.casefold()),
groups=sorted(access_groups.values(), key=lambda item: item.name.casefold()),
users=sorted(access_users.values(), key=lambda item: item.name.casefold()),
),
configuration=ConfigurationRoot(
name=configuration_name,
metadata=configuration_metadata,
@@ -344,6 +409,143 @@ def _all_metadata_objects(
return result
def _access_key(qualified_name: str | None, name: str) -> str:
return (qualified_name or name).casefold()
def _access_role_assignment_from_attributes(attributes: dict, *, source: str | None = None) -> AccessRoleAssignment | None:
role = _first_attr(attributes, "role", "Role", "Роль", "roleName", "ИмяРоли")
if not role:
return None
role_qualified_name = role if "." in role else f"Роль.{role}"
return AccessRoleAssignment(
role=role.split(".")[-1],
role_qualified_name=role_qualified_name,
source=source or _first_attr(attributes, "source", "Source", "Источник"),
attributes=dict(attributes),
)
def _access_profile_from_item(item: OneCXmlObject) -> AccessProfile:
attributes = dict(item.attributes)
profile = AccessProfile(
name=item.name,
qualified_name=item.qualified_name,
source_path=item.source_path,
attributes=attributes,
)
for role in _split_attr_list(attributes, "roles", "Roles", "Роли"):
assignment = _access_role_assignment_from_attributes({"role": role}, source=item.qualified_name)
if assignment is not None:
profile.roles.append(assignment)
return profile
def _access_group_from_item(item: OneCXmlObject) -> AccessGroup:
attributes = dict(item.attributes)
group = AccessGroup(
name=item.name,
qualified_name=item.qualified_name,
source_path=item.source_path,
profile=_first_attr(attributes, "profile", "Profile", "Профиль", "accessProfile", "ПрофильГруппыДоступа"),
profile_qualified_name=_first_attr(attributes, "profileQualifiedName", "ProfileQualifiedName", "ПрофильПолноеИмя"),
attributes=attributes,
)
for role in _split_attr_list(attributes, "roles", "Roles", "Роли"):
assignment = _access_role_assignment_from_attributes({"role": role}, source=item.qualified_name)
if assignment is not None:
group.roles.append(assignment)
group.users.extend(_split_attr_list(attributes, "users", "Users", "Пользователи", "members", "Members", "Участники"))
return group
def _access_user_from_item(item: OneCXmlObject) -> AccessUser:
attributes = dict(item.attributes)
user = AccessUser(
name=item.name,
qualified_name=item.qualified_name,
source_path=item.source_path,
full_name=_first_attr(attributes, "fullName", "FullName", "ПолноеИмя", "full_name"),
disabled=_truthy(_first_attr(attributes, "disabled", "Disabled", "Недействителен", "isDisabled")),
attributes=attributes,
)
for role in _split_attr_list(attributes, "roles", "Roles", "Роли"):
assignment = _access_role_assignment_from_attributes({"role": role}, source=item.qualified_name)
if assignment is not None:
user.roles.append(assignment)
user.groups.extend(_split_attr_list(attributes, "groups", "Groups", "Группы", "accessGroups", "ГруппыДоступа"))
return user
def _attach_access_role_assignment(
item: OneCXmlObject,
profiles: dict[str, AccessProfile],
groups: dict[str, AccessGroup],
users: dict[str, AccessUser],
) -> None:
assignment = _access_role_assignment_from_attributes(item.attributes, source=item.qualified_name)
if assignment is None:
return
owner = _first_attr(item.attributes, "owner", "Owner", "Владелец", "profile", "Profile", "Профиль", "group", "Group", "Группа", "user", "User", "Пользователь")
owner_key = owner.casefold() if owner else item.qualified_name.rsplit(".", 1)[0].casefold()
for collection in (profiles, groups, users):
target = collection.get(owner_key)
if target is None:
target = next(
(
value
for value in collection.values()
if value.name.casefold() == owner_key or str(value.qualified_name or "").casefold() == owner_key
),
None,
)
if target is not None:
target.roles.append(assignment)
return
def _attach_access_group_membership(
item: OneCXmlObject,
groups: dict[str, AccessGroup],
users: dict[str, AccessUser],
) -> None:
group_name = _first_attr(item.attributes, "group", "Group", "Группа", "accessGroup", "ГруппаДоступа")
user_name = _first_attr(item.attributes, "user", "User", "Пользователь", "member", "Member", "Участник")
if not group_name or not user_name:
return
group = groups.get(group_name.casefold()) or next(
(value for value in groups.values() if value.name.casefold() == group_name.casefold()),
None,
)
user = users.get(user_name.casefold()) or next(
(value for value in users.values() if value.name.casefold() == user_name.casefold()),
None,
)
if group is not None and user_name not in group.users:
group.users.append(user_name)
if user is not None and group_name not in user.groups:
user.groups.append(group_name)
def _first_attr(attributes: dict, *keys: str) -> str:
for key in keys:
value = attributes.get(key)
if value not in (None, ""):
return str(value)
return ""
def _split_attr_list(attributes: dict, *keys: str) -> list[str]:
value = _first_attr(attributes, *keys)
if not value:
return []
return [item.strip() for item in re.split(r"[,;\n]", value) if item.strip()]
def _truthy(value: str) -> bool:
return value.casefold() in {"true", "1", "yes", "да", "истина"}
def _walk_xml_objects(
source_path: str,
element: ET.Element,
@@ -351,26 +553,37 @@ def _walk_xml_objects(
*,
current_role: OneCXmlObject | None,
parent_qualified_name: str | None,
parent_object_kind: str | None,
) -> None:
role_context = current_role
child_parent_qualified_name = parent_qualified_name
object_kind = _xml_object_kind(element)
child_parent_object_kind = parent_object_kind
object_kind = _xml_object_kind(element, parent_object_kind=parent_object_kind)
if object_kind == "RIGHT":
right = _xml_right_object(source_path, element, role_context)
if right is not None:
result.append(right)
elif object_kind == "ACCESS_ROLE_ASSIGNMENT":
assignment = _xml_access_role_assignment(source_path, element, parent_qualified_name)
if assignment is not None:
result.append(assignment)
elif object_kind == "ACCESS_GROUP_MEMBERSHIP":
membership = _xml_access_group_membership(source_path, element, parent_qualified_name)
if membership is not None:
result.append(membership)
elif object_kind is not None:
name = _xml_name(element)
name = _xml_name(element, source_path=source_path)
if name:
xml_object = OneCXmlObject(
source_path=source_path,
object_kind=object_kind,
name=name,
qualified_name=_xml_qualified_name(element, name, object_kind, parent_qualified_name),
qualified_name=_xml_qualified_name(element, name, object_kind, parent_qualified_name, source_path),
attributes=_xml_attributes(element),
)
result.append(xml_object)
child_parent_qualified_name = xml_object.qualified_name
child_parent_object_kind = object_kind
if object_kind == "ROLE":
role_context = xml_object
@@ -381,6 +594,7 @@ def _walk_xml_objects(
result,
current_role=role_context,
parent_qualified_name=child_parent_qualified_name,
parent_object_kind=child_parent_object_kind,
)
@@ -426,6 +640,56 @@ def _xml_role_reference(element: ET.Element) -> str:
return ""
def _xml_access_role_assignment(
source_path: str,
element: ET.Element,
parent_qualified_name: str | None,
) -> OneCXmlObject | None:
attributes = _xml_attributes(element)
role = _first_attr(attributes, "role", "Role", "Роль", "name", "Name", "Имя")
if not role:
text = (element.text or "").strip()
role = text if text else ""
if not role:
return None
owner = _first_attr(attributes, "profile", "Profile", "Профиль", "group", "Group", "Группа", "user", "User", "Пользователь")
if not owner and parent_qualified_name:
owner = parent_qualified_name
attributes.setdefault("role", role)
if owner:
attributes.setdefault("owner", owner)
return OneCXmlObject(
source_path=source_path,
object_kind="ACCESS_ROLE_ASSIGNMENT",
name=role,
qualified_name=f"{owner}.{role}" if owner else role,
attributes=attributes,
)
def _xml_access_group_membership(
source_path: str,
element: ET.Element,
parent_qualified_name: str | None,
) -> OneCXmlObject | None:
attributes = _xml_attributes(element)
user = _first_attr(attributes, "user", "User", "Пользователь", "member", "Member", "Участник", "name", "Name", "Имя")
group = _first_attr(attributes, "group", "Group", "Группа", "accessGroup", "ГруппаДоступа")
if not group and parent_qualified_name:
group = parent_qualified_name
if not user or not group:
return None
attributes.setdefault("user", user)
attributes.setdefault("group", group)
return OneCXmlObject(
source_path=source_path,
object_kind="ACCESS_GROUP_MEMBERSHIP",
name=user,
qualified_name=f"{group}.{user}",
attributes=attributes,
)
_OBJECT_KIND_BY_TAG = {
"configuration": "PROJECT",
"конфигурация": "PROJECT",
@@ -502,6 +766,13 @@ _OBJECT_KIND_BY_TAG = {
"subsystem": "SUBSYSTEM",
"subsystems": "SUBSYSTEM",
"подсистема": "SUBSYSTEM",
"sequence": "SEQUENCE",
"sequences": "SEQUENCE",
"последовательность": "SEQUENCE",
"documentnumerator": "DOCUMENT_NUMERATOR",
"documentnumerators": "DOCUMENT_NUMERATOR",
"нумератордокументов": "DOCUMENT_NUMERATOR",
"нумератор": "DOCUMENT_NUMERATOR",
"httpservice": "HTTP_SERVICE",
"httpservices": "HTTP_SERVICE",
"httpсервис": "HTTP_SERVICE",
@@ -529,6 +800,41 @@ _OBJECT_KIND_BY_TAG = {
"пакетxdto": "XDTO_PACKAGE",
"role": "ROLE",
"роль": "ROLE",
"accessprofile": "ACCESS_PROFILE",
"accessprofiles": "ACCESS_PROFILE",
"accessgroupprofile": "ACCESS_PROFILE",
"accessgroupprofiles": "ACCESS_PROFILE",
"профильгруппыдоступа": "ACCESS_PROFILE",
"профилигруппдоступа": "ACCESS_PROFILE",
"accessgroup": "ACCESS_GROUP",
"accessgroups": "ACCESS_GROUP",
"группадоступа": "ACCESS_GROUP",
"группыдоступа": "ACCESS_GROUP",
"infobaseuser": "ACCESS_USER",
"infobaseusers": "ACCESS_USER",
"accessuser": "ACCESS_USER",
"accessusers": "ACCESS_USER",
"user": "ACCESS_USER",
"users": "ACCESS_USER",
"пользователь": "ACCESS_USER",
"пользователи": "ACCESS_USER",
"roleassignment": "ACCESS_ROLE_ASSIGNMENT",
"roleassignments": "ACCESS_ROLE_ASSIGNMENT",
"accessrole": "ACCESS_ROLE_ASSIGNMENT",
"accessroles": "ACCESS_ROLE_ASSIGNMENT",
"profilerole": "ACCESS_ROLE_ASSIGNMENT",
"grouprole": "ACCESS_ROLE_ASSIGNMENT",
"userrole": "ACCESS_ROLE_ASSIGNMENT",
"рольдоступа": "ACCESS_ROLE_ASSIGNMENT",
"роли": "ACCESS_ROLE_ASSIGNMENT",
"member": "ACCESS_GROUP_MEMBERSHIP",
"members": "ACCESS_GROUP_MEMBERSHIP",
"membership": "ACCESS_GROUP_MEMBERSHIP",
"memberships": "ACCESS_GROUP_MEMBERSHIP",
"participant": "ACCESS_GROUP_MEMBERSHIP",
"participants": "ACCESS_GROUP_MEMBERSHIP",
"участник": "ACCESS_GROUP_MEMBERSHIP",
"участники": "ACCESS_GROUP_MEMBERSHIP",
"sessionparameter": "SESSION_PARAMETER",
"sessionparameters": "SESSION_PARAMETER",
"параметрсеанса": "SESSION_PARAMETER",
@@ -695,10 +1001,38 @@ _OBJECT_KIND_BY_TAG = {
"предопределенный": "PREDEFINED",
}
_FORM_ELEMENT_TAGS = {
"item",
"items",
"element",
"elements",
"formitem",
"formitems",
"field",
"fields",
"group",
"groups",
"table",
"tables",
"button",
"buttons",
"page",
"pages",
}
_QUALIFIED_PREFIX_BY_KIND = {
"CATALOG": "Справочник",
"DOCUMENT": "Документ",
"CONSTANT": "Константа",
"DOCUMENT_JOURNAL": "ЖурналДокументов",
"ENUM": "Перечисление",
"REPORT": "Отчет",
"DATA_PROCESSOR": "Обработка",
"CHART_OF_CHARACTERISTIC_TYPES": "ПланВидовХарактеристик",
"CHART_OF_ACCOUNTS": "ПланСчетов",
"CHART_OF_CALCULATION_TYPES": "ПланВидовРасчета",
"EXTERNAL_DATA_SOURCE": "ВнешнийИсточникДанных",
"REGISTER": "Регистр",
"INFORMATION_REGISTER": "РегистрСведений",
"ACCUMULATION_REGISTER": "РегистрНакопления",
@@ -711,6 +1045,8 @@ _QUALIFIED_PREFIX_BY_KIND = {
"BUSINESS_PROCESS": "БизнесПроцесс",
"TASK": "Задача",
"SUBSYSTEM": "Подсистема",
"SEQUENCE": "Последовательность",
"DOCUMENT_NUMERATOR": "НумераторДокументов",
"HTTP_SERVICE": "HTTPСервис",
"WEB_SERVICE": "WebСервис",
"WS_REFERENCE": "WSСсылка",
@@ -738,6 +1074,9 @@ _QUALIFIED_PREFIX_BY_KIND = {
"STYLE_ITEM": "ЭлементСтиля",
"STYLE": "Стиль",
"LANGUAGE": "Язык",
"ACCESS_PROFILE": "ПрофильГруппыДоступа",
"ACCESS_GROUP": "ГруппаДоступа",
"ACCESS_USER": "Пользователь",
"FORM": "Форма",
"COMMAND": "Команда",
"URL_TEMPLATE": "ШаблонURL",
@@ -780,6 +1119,8 @@ _QUALIFIED_PREFIX_BY_TAG = {
"bot": "Бот",
"interface": "Интерфейс",
"fulltextsearchdictionary": "СловарьПолнотекстовогоПоиска",
"sequence": "Последовательность",
"documentnumerator": "НумераторДокументов",
}
_ROOT_METADATA_OBJECT_KINDS = {
@@ -807,6 +1148,8 @@ _ROOT_METADATA_OBJECT_KINDS = {
"BUSINESS_PROCESS",
"TASK",
"SUBSYSTEM",
"SEQUENCE",
"DOCUMENT_NUMERATOR",
"HTTP_SERVICE",
"WEB_SERVICE",
"WS_REFERENCE",
@@ -828,6 +1171,7 @@ _ROOT_METADATA_OBJECT_KINDS = {
"COMMON_LAYOUT",
"COMMON_PICTURE",
"INTEGRATION_SERVICE",
"EXTENSION",
"PALETTE_COLOR",
"STYLE_ITEM",
"STYLE",
@@ -838,8 +1182,11 @@ _ROOT_METADATA_OBJECT_KINDS = {
_GROUP_BY_OBJECT_KIND = {
"PROJECT": "Конфигурация",
"COMMON_MODULE": "Общие модули",
"CONSTANT": "Константы",
"CATALOG": "Справочники",
"DOCUMENT": "Документы",
"DOCUMENT_JOURNAL": "Журналы документов",
"ENUM": "Перечисления",
"REGISTER": "Регистры",
"INFORMATION_REGISTER": "Регистры сведений",
"ACCUMULATION_REGISTER": "Регистры накопления",
@@ -847,10 +1194,18 @@ _GROUP_BY_OBJECT_KIND = {
"CALCULATION_REGISTER": "Регистры расчета",
"REPORT": "Отчеты",
"DATA_PROCESSOR": "Обработки",
"CHART_OF_CHARACTERISTIC_TYPES": "Планы видов характеристик",
"CHART_OF_ACCOUNTS": "Планы счетов",
"CHART_OF_CALCULATION_TYPES": "Планы видов расчета",
"BUSINESS_PROCESS": "Бизнес-процессы",
"TASK": "Задачи",
"EXTENSION": "Расширения конфигурации",
"FORM": "Формы",
"COMMAND": "Команды",
"ROLE": "Роли",
"SUBSYSTEM": "Подсистемы",
"SEQUENCE": "Последовательности",
"DOCUMENT_NUMERATOR": "Нумераторы документов",
"HTTP_SERVICE": "HTTP-сервисы",
"WEB_SERVICE": "Web-сервисы",
"WS_REFERENCE": "WS-ссылки",
@@ -1095,9 +1450,15 @@ def _attach_bsl_modules(root: Path, normalized: NormalizedProject) -> None:
"original_hash": source.original_hash,
"source_text": source.text,
"module_role": role,
"owner_qualified_name": owner.qualified_name,
"owner_kind": owner.object_kind,
"object_part": _module_object_part(role, form_name),
}
if form_name:
attributes["form_name"] = form_name
form = _find_owner_form(owner, form_name)
if form is not None:
attributes["form_qualified_name"] = form.qualified_name
owner.modules.append(
Module(
name=source_file.stem,
@@ -1189,6 +1550,29 @@ def _module_qualified_name(owner: MetadataObject, role: str, form_name: str, mod
return f"{owner.qualified_name}.{role_suffix}"
def _module_object_part(role: str, form_name: str = "") -> str:
return {
"OBJECT_MODULE": "object.module",
"MANAGER_MODULE": "object.manager",
"RECORD_SET_MODULE": "object.record_set",
"FORM_MODULE": f"form.{form_name}.module" if form_name else "form.module",
"MODULE": "module",
}.get(role, "module")
def _find_owner_form(owner: MetadataObject, form_name: str) -> Form | None:
normalized = form_name.casefold()
return next(
(
form
for form in owner.forms
if form.name.casefold() == normalized
or str(form.qualified_name or "").casefold().endswith(f".{normalized}")
),
None,
)
def _form_name_for_module(root: Path, source_file: Path) -> str:
parts = list(_relative_path(source_file, root).parts)
normalized_parts = [_normalize_path_part(part) for part in parts]
@@ -1259,8 +1643,23 @@ _PATH_METADATA_ALIASES = {
}
def _xml_object_kind(element: ET.Element) -> str | None:
def _xml_object_kind(element: ET.Element, *, parent_object_kind: str | None = None) -> str | None:
tag = _local_name(element.tag).lower()
if parent_object_kind in {"FORM", "ELEMENT"} and tag in _FORM_ELEMENT_TAGS and _xml_name(element):
return "ELEMENT"
if parent_object_kind in {"ACCESS_PROFILE", "ACCESS_GROUP", "ACCESS_USER"} and tag in {
"role",
"roles",
"роль",
"роли",
"accessrole",
"profilerole",
"grouprole",
"userrole",
}:
return "ACCESS_ROLE_ASSIGNMENT"
if parent_object_kind == "ACCESS_GROUP" and tag in {"member", "members", "user", "users", "участник", "участники", "пользователь", "пользователи"}:
return "ACCESS_GROUP_MEMBERSHIP"
if tag in {"metadataobject", "object"}:
type_name = _xml_type_name(element)
if type_name:
@@ -1278,7 +1677,7 @@ def _xml_type_name(element: ET.Element) -> str:
return ""
def _xml_name(element: ET.Element) -> str:
def _xml_name(element: ET.Element, *, source_path: str = "") -> str:
for key in ("name", "Name", "Имя"):
if key in element.attrib:
return element.attrib[key]
@@ -1286,6 +1685,9 @@ def _xml_name(element: ET.Element) -> str:
if _local_name(child.tag).lower() in {"name", "имя"} and child.text:
return child.text.strip()
tag = _local_name(element.tag).lower()
edt_form = _edt_form_context_from_path(source_path)
if tag == "form" and edt_form is not None:
return edt_form[0]
fallback_keys = {
"urltemplate": ("template", "url", "path", "Шаблон", "URL"),
"urltemplates": ("template", "url", "path", "Шаблон", "URL"),
@@ -1334,6 +1736,7 @@ def _xml_qualified_name(
name: str,
object_kind: str,
parent_qualified_name: str | None,
source_path: str = "",
) -> str:
for key in ("qualifiedName", "QualifiedName", "ПолноеИмя"):
if key in element.attrib:
@@ -1341,6 +1744,10 @@ def _xml_qualified_name(
for child in _xml_property_children(element):
if _local_name(child.tag).lower() in {"qualifiedname", "полноеимя"} and child.text:
return child.text.strip()
if object_kind == "FORM" and parent_qualified_name is None:
edt_form = _edt_form_context_from_path(source_path)
if edt_form is not None:
return edt_form[1]
if parent_qualified_name:
if object_kind in _ROOT_METADATA_OBJECT_KINDS and object_kind not in {"PROJECT", "ROLE"}:
prefix = _QUALIFIED_PREFIX_BY_KIND.get(object_kind, object_kind)
@@ -1353,8 +1760,55 @@ def _xml_qualified_name(
return name
_EDT_OWNER_PREFIX_BY_DIRECTORY = {
"AccountingRegisters": "РегистрБухгалтерии",
"AccumulationRegisters": "РегистрНакопления",
"BusinessProcesses": "БизнесПроцесс",
"CalculationRegisters": "РегистрРасчета",
"Catalogs": "Справочник",
"ChartsOfAccounts": "ПланСчетов",
"ChartsOfCalculationTypes": "ПланВидовРасчета",
"ChartsOfCharacteristicTypes": "ПланВидовХарактеристик",
"DataProcessors": "Обработка",
"DocumentJournals": "ЖурналДокументов",
"Documents": "Документ",
"Enums": "Перечисление",
"ExchangePlans": "ПланОбмена",
"ExternalDataSources": "ВнешнийИсточникДанных",
"InformationRegisters": "РегистрСведений",
"Reports": "Отчет",
"Tasks": "Задача",
}
def _edt_form_context_from_path(source_path: str) -> tuple[str, str] | None:
if not source_path or PurePosixPath(source_path).name.casefold() != "form.form":
return None
parts = PurePosixPath(source_path).parts
try:
forms_index = parts.index("Forms")
except ValueError:
forms_index = -1
if forms_index > 1 and forms_index + 1 < len(parts):
owner_directory = parts[forms_index - 2]
owner_name = parts[forms_index - 1]
form_name = parts[forms_index + 1]
owner_prefix = _EDT_OWNER_PREFIX_BY_DIRECTORY.get(owner_directory)
if owner_prefix:
return form_name, f"{owner_prefix}.{owner_name}.{form_name}"
if len(parts) >= 3 and parts[-3] == "CommonForms":
form_name = parts[-2]
return form_name, f"ОбщаяФорма.{form_name}"
return None
def _xml_attributes(element: ET.Element) -> dict:
attributes = dict(element.attrib)
for key, value in element.attrib.items():
local_key = _local_name(key)
attributes.setdefault(local_key, value)
if local_key.lower() == "type":
attributes.setdefault("control_kind", value.split(":")[-1].split(".")[-1])
attribute_role = _xml_attribute_role(element)
if attribute_role:
attributes.setdefault("attribute_role", attribute_role)
@@ -1386,10 +1840,18 @@ def _xml_nested_text_value(element: ET.Element) -> str:
return localized.get("ru") or localized.get("ru_RU") or next(iter(localized.values()))
if _local_name(element.tag).lower() == "value":
return _element_text_content(element)
path_segments = [
_element_text_content(child)
for child in element
if _local_name(child.tag).lower() in {"segment", "segments", "pathsegment"}
]
path_segments = [value for value in path_segments if value]
if path_segments:
return ".".join(path_segments)
values = [
_element_text_content(child)
for child in element
if _local_name(child.tag).lower() in {"value", "text", "строка", "представление"}
if _local_name(child.tag).lower() in {"value", "text", "строка", "представление", "caption"}
]
values = [value for value in values if value]
if values:
@@ -1463,6 +1925,11 @@ def _read_text_file(path: Path) -> str:
__all__ = [
"COMMON_BRANCH_CHILDREN",
"AccessGroup",
"AccessModel",
"AccessProfile",
"AccessRoleAssignment",
"AccessUser",
"Command",
"ConfigurationRoot",
"Extension",
@@ -34,6 +34,8 @@ COMMON_BRANCH_CHILDREN = (
"Подписки на события",
"Критерии отбора",
"Регламентные задания",
"Последовательности",
"Нумераторы документов",
"Функциональные опции",
"Параметры функциональных опций",
"Определяемые типы",
@@ -145,6 +147,7 @@ REPORT_CHILDREN = (
METADATA_TYPE_SPECS: tuple[MetadataTypeSpec, ...] = (
MetadataTypeSpec("COMMON", "Общие", "Общие", "common", COMMON_BRANCH_CHILDREN),
MetadataTypeSpec("SUBSYSTEM", "Подсистема", "Подсистемы", "subsystem", ("Состав", "Командный интерфейс", "Права")),
MetadataTypeSpec(
"COMMON_MODULE",
"Общий модуль",
@@ -186,8 +189,11 @@ METADATA_TYPE_SPECS: tuple[MetadataTypeSpec, ...] = (
MetadataTypeSpec("EXTERNAL_DATA_SOURCE", "Внешний источник данных", "Внешние источники данных", "external-source", ("Таблицы", "Кубы", "Функции", "Формы", "Команды", "Макеты")),
MetadataTypeSpec("EXCHANGE_PLAN", "План обмена", "Планы обмена", "exchange-plan", STRUCTURED_OBJECT_CHILDREN + ("Состав",), OBJECT_MODULES),
MetadataTypeSpec("EVENT_SUBSCRIPTION", "Подписка на событие", "Подписки на события", "event", ("События",), HANDLER_METHOD),
MetadataTypeSpec("ROLE", "Роль", "Роли", "role", ("Права", "Ограничения доступа", "Объекты доступа")),
MetadataTypeSpec("EXTENSION", "Расширение конфигурации", "Расширения конфигурации", "extension", ("Объекты расширения", "Заимствованные объекты", "Добавленные реквизиты", "Формы", "Команды", "Проверки совместимости")),
MetadataTypeSpec("SCHEDULED_JOB", "Регламентное задание", "Регламентные задания", "scheduled-job", ("Расписание", "Параметры"), ("Метод",)),
MetadataTypeSpec("SEQUENCE", "Последовательность", "Последовательности", "sequence", ("Измерения", "Документы", "Границы")),
MetadataTypeSpec("DOCUMENT_NUMERATOR", "Нумератор документов", "Нумераторы документов", "numbering", ("Документы", "Периодичность", "Длина номера")),
MetadataTypeSpec("SESSION_PARAMETER", "Параметр сеанса", "Параметры сеанса", "parameter"),
MetadataTypeSpec("COMMON_ATTRIBUTE", "Общий реквизит", "Общие реквизиты", "attribute"),
MetadataTypeSpec("FILTER_CRITERION", "Критерий отбора", "Критерии отбора", "filter"),
@@ -217,6 +223,7 @@ METADATA_TYPE_SPECS: tuple[MetadataTypeSpec, ...] = (
METADATA_TYPE_DESCRIPTIONS = {
"COMMON": "Служебная ветка дерева конфигурации, объединяющая общие объекты метаданных.",
"SUBSYSTEM": "Подсистема группирует прикладные объекты и участвует в построении командного интерфейса.",
"COMMON_MODULE": "Общий модуль содержит процедуры и функции, доступные из разных областей выполнения конфигурации.",
"CONSTANT": "Константа хранит единичное значение конфигурации и может иметь формы, команды, права и модуль менеджера.",
"CATALOG": "Справочник описывает прикладной список объектов с реквизитами, табличными частями, формами, командами, макетами, правами и предопределенными данными.",
@@ -237,8 +244,11 @@ METADATA_TYPE_DESCRIPTIONS = {
"EXTERNAL_DATA_SOURCE": "Внешний источник данных описывает подключение к внешним таблицам, кубам и функциям.",
"EXCHANGE_PLAN": "План обмена описывает узлы и состав данных для распределенного обмена.",
"EVENT_SUBSCRIPTION": "Подписка на событие связывает событие платформы или объекта с обработчиком.",
"ROLE": "Роль описывает набор прав доступа к объектам конфигурации и их данным.",
"EXTENSION": "Расширение конфигурации содержит добавленные и заимствованные объекты, а также проверки совместимости.",
"SCHEDULED_JOB": "Регламентное задание описывает метод, параметры и расписание фонового выполнения.",
"SEQUENCE": "Последовательность управляет сквозной последовательностью проведения документов и границами восстановления.",
"DOCUMENT_NUMERATOR": "Нумератор документов задает общие правила нумерации для одного или нескольких видов документов.",
"SESSION_PARAMETER": "Параметр сеанса задает значение, доступное в течение пользовательского сеанса.",
"COMMON_ATTRIBUTE": "Общий реквизит добавляет реквизит сразу к выбранному набору объектов конфигурации.",
"FILTER_CRITERION": "Критерий отбора задает состав реквизитов для универсального отбора ссылочных данных.",
@@ -282,6 +292,7 @@ METADATA_TYPE_DOCUMENTATION_URLS.update(
METADATA_TYPE_PROPERTIES: dict[str, tuple[str, ...]] = {
"COMMON": ("Состав общих объектов",),
"SUBSYSTEM": STANDARD_PROPERTIES + ("Состав", "Включать в командный интерфейс", "Картинка", "Родитель"),
"COMMON_MODULE": STANDARD_PROPERTIES + ("Клиент", "Сервер", "Внешнее соединение", "Глобальный", "Вызов сервера", "Повторное использование возвращаемых значений"),
"CONSTANT": STANDARD_PROPERTIES + ("Тип значения", "Основная форма", "Форма выбора"),
"CATALOG": REFERENCE_OBJECT_PROPERTIES,
@@ -302,8 +313,11 @@ METADATA_TYPE_PROPERTIES: dict[str, tuple[str, ...]] = {
"EXTERNAL_DATA_SOURCE": STANDARD_PROPERTIES + ("Соединение", "Таблицы", "Кубы", "Функции"),
"EXCHANGE_PLAN": DATA_OBJECT_PROPERTIES + ("Состав обмена", "Распределенная ИБ", "Авторегистрация изменений"),
"EVENT_SUBSCRIPTION": STANDARD_PROPERTIES + ("Источник", "Событие", "Обработчик", "Перед/после события"),
"ROLE": STANDARD_PROPERTIES + ("Права", "RLS", "Ограничения доступа"),
"EXTENSION": ("Имя", "Назначение", "Версия", "Режим совместимости", "Заимствованные объекты", "Проверки совместимости"),
"SCHEDULED_JOB": STANDARD_PROPERTIES + ("Метод", "Расписание", "Использование", "Параметры", "Предопределенное"),
"SEQUENCE": STANDARD_PROPERTIES + ("Документы", "Измерения", "Периодичность", "Заполнение", "Граница"),
"DOCUMENT_NUMERATOR": STANDARD_PROPERTIES + ("Длина номера", "Тип номера", "Периодичность", "Документы"),
"SESSION_PARAMETER": STANDARD_PROPERTIES + ("Тип значения",),
"COMMON_ATTRIBUTE": STANDARD_PROPERTIES + ("Тип значения", "Состав", "Разделение данных", "Автоиспользование"),
"FILTER_CRITERION": STANDARD_PROPERTIES + ("Тип значения", "Состав реквизитов"),
@@ -219,6 +219,70 @@ def test_normalize_edt_project_preserves_source_path_and_common_object_descripti
assert common_forms[0].metadata["comment"] == "Используется в подборе товаров"
def test_normalize_edt_project_knows_full_common_metadata_catalog(tmp_path: Path):
for file_name, class_name, object_name in [
("Продажи.mdo", "Subsystem", "Продажи"),
("Менеджер.mdo", "Role", "Менеджер"),
("ПроведениеДокументов.mdo", "Sequence", "ПроведениеДокументов"),
("ОбщийНумератор.mdo", "DocumentNumerator", "ОбщийНумератор"),
("ДоступностьСкидок.mdo", "FunctionalOption", "ДоступностьСкидок"),
("ФормаПодбора.mdo", "CommonForm", "ФормаПодбора"),
("ПубличныйAPI.mdo", "HTTPService", "ПубличныйAPI"),
]:
(tmp_path / file_name).write_text(
f"""
<mdclass:{class_name} xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
<name>{object_name}</name>
</mdclass:{class_name}>
""",
encoding="utf-8",
)
normalized = normalize_one_c_project(tmp_path, project_id="edt-full-common")
objects = {
item.qualified_name
for group in normalized.configuration.groups
for item in group.objects
}
assert {
"Подсистема.Продажи",
"Роль.Менеджер",
"Последовательность.ПроведениеДокументов",
"НумераторДокументов.ОбщийНумератор",
"ФункциональнаяОпция.ДоступностьСкидок",
"ОбщаяФорма.ФормаПодбора",
"HTTPСервис.ПубличныйAPI",
}.issubset(objects)
def test_normalize_project_loads_access_profiles_groups_and_users(tmp_path: Path):
xml = tmp_path / "access.xml"
xml.write_text(
"""
<AccessData>
<Role name="ЧтениеПродаж" qualifiedName="Роль.ЧтениеПродаж" />
<AccessProfile name="МенеджерПродаж">
<Role name="ЧтениеПродаж" />
</AccessProfile>
<AccessGroup name="ОтделПродаж" profile="МенеджерПродаж">
<Member user="ivanov" />
</AccessGroup>
<InfobaseUser name="ivanov" fullName="Иванов Иван" />
</AccessData>
""",
encoding="utf-8",
)
normalized = normalize_one_c_project(tmp_path, project_id="access-data")
assert normalized.access.profiles[0].name == "МенеджерПродаж"
assert normalized.access.profiles[0].roles[0].role_qualified_name == "Роль.ЧтениеПродаж"
assert normalized.access.groups[0].profile == "МенеджерПродаж"
assert normalized.access.groups[0].users == ["ivanov"]
assert normalized.access.users[0].full_name == "Иванов Иван"
def test_normalize_edt_project_preserves_localized_descriptions(tmp_path: Path):
catalog = tmp_path / "Контрагенты.mdo"
catalog.write_text(
@@ -78,6 +78,40 @@ _METADATA_OWNER_KINDS = {
NodeKind.SCHEDULED_JOB,
NodeKind.BUSINESS_PROCESS,
NodeKind.TASK,
NodeKind.SUBSYSTEM,
NodeKind.SEQUENCE,
NodeKind.DOCUMENT_NUMERATOR,
NodeKind.EVENT_SUBSCRIPTION,
NodeKind.SESSION_PARAMETER,
NodeKind.COMMON_ATTRIBUTE,
NodeKind.FILTER_CRITERION,
NodeKind.FUNCTIONAL_OPTION,
NodeKind.FUNCTIONAL_OPTION_PARAMETER,
NodeKind.DEFINED_TYPE,
NodeKind.SETTINGS_STORAGE,
NodeKind.COMMON_COMMAND,
NodeKind.COMMAND_GROUP,
NodeKind.COMMON_FORM,
NodeKind.COMMON_LAYOUT,
NodeKind.COMMON_PICTURE,
NodeKind.WEB_SERVICE,
NodeKind.HTTP_SERVICE,
NodeKind.WS_REFERENCE,
NodeKind.WEBSOCKET_CLIENT,
NodeKind.INTEGRATION_SERVICE,
NodeKind.BOT,
NodeKind.INTERFACE,
NodeKind.FULL_TEXT_SEARCH_DICTIONARY,
NodeKind.PALETTE_COLOR,
NodeKind.STYLE_ITEM,
NodeKind.STYLE,
NodeKind.LANGUAGE,
NodeKind.ACCESS_PROFILE,
NodeKind.ACCESS_GROUP,
NodeKind.ACCESS_USER,
NodeKind.XDTO_PACKAGE,
NodeKind.EXTENSION,
NodeKind.ROLE,
}
_PATH_METADATA_ALIASES = {
"catalogs": ("Справочник", NodeKind.CATALOG),
@@ -120,6 +154,69 @@ _PATH_METADATA_ALIASES = {
"бизнеспроцессы": ("БизнесПроцесс", NodeKind.BUSINESS_PROCESS),
"tasks": ("Задача", NodeKind.TASK),
"задачи": ("Задача", NodeKind.TASK),
"subsystems": ("Подсистема", NodeKind.SUBSYSTEM),
"подсистемы": ("Подсистема", NodeKind.SUBSYSTEM),
"roles": ("Роль", NodeKind.ROLE),
"роли": ("Роль", NodeKind.ROLE),
"sequences": ("Последовательность", NodeKind.SEQUENCE),
"последовательности": ("Последовательность", NodeKind.SEQUENCE),
"documentnumerators": ("НумераторДокументов", NodeKind.DOCUMENT_NUMERATOR),
"нумераторыдокументов": ("НумераторДокументов", NodeKind.DOCUMENT_NUMERATOR),
"eventsubscriptions": ("ПодпискаНаСобытие", NodeKind.EVENT_SUBSCRIPTION),
"подпискинасобытия": ("ПодпискаНаСобытие", NodeKind.EVENT_SUBSCRIPTION),
"sessionparameters": ("ПараметрСеанса", NodeKind.SESSION_PARAMETER),
"параметрысеанса": ("ПараметрСеанса", NodeKind.SESSION_PARAMETER),
"commonattributes": ("ОбщийРеквизит", NodeKind.COMMON_ATTRIBUTE),
"общиереквизиты": ("ОбщийРеквизит", NodeKind.COMMON_ATTRIBUTE),
"filtercriteria": ("КритерийОтбора", NodeKind.FILTER_CRITERION),
"критерииотбора": ("КритерийОтбора", NodeKind.FILTER_CRITERION),
"functionaloptions": ("ФункциональнаяОпция", NodeKind.FUNCTIONAL_OPTION),
"функциональныеопции": ("ФункциональнаяОпция", NodeKind.FUNCTIONAL_OPTION),
"functionaloptionsparameters": ("ПараметрФункциональнойОпции", NodeKind.FUNCTIONAL_OPTION_PARAMETER),
"параметрыфункциональныхопций": ("ПараметрФункциональнойОпции", NodeKind.FUNCTIONAL_OPTION_PARAMETER),
"definedtypes": ("ОпределяемыйТип", NodeKind.DEFINED_TYPE),
"определяемыетипы": ("ОпределяемыйТип", NodeKind.DEFINED_TYPE),
"settingsstorages": ("ХранилищеНастроек", NodeKind.SETTINGS_STORAGE),
"хранилищанастроек": ("ХранилищеНастроек", NodeKind.SETTINGS_STORAGE),
"commoncommands": ("ОбщаяКоманда", NodeKind.COMMON_COMMAND),
"общиекоманды": ("ОбщаяКоманда", NodeKind.COMMON_COMMAND),
"commandgroups": ("ГруппаКоманд", NodeKind.COMMAND_GROUP),
"группыкоманд": ("ГруппаКоманд", NodeKind.COMMAND_GROUP),
"commonforms": ("ОбщаяФорма", NodeKind.COMMON_FORM),
"общиеформы": ("ОбщаяФорма", NodeKind.COMMON_FORM),
"commontemplates": ("ОбщийМакет", NodeKind.COMMON_LAYOUT),
"commonlayouts": ("ОбщийМакет", NodeKind.COMMON_LAYOUT),
"общиемакеты": ("ОбщийМакет", NodeKind.COMMON_LAYOUT),
"commonpictures": ("ОбщаяКартинка", NodeKind.COMMON_PICTURE),
"общиекартинки": ("ОбщаяКартинка", NodeKind.COMMON_PICTURE),
"xdtopackages": ("XDTO", NodeKind.XDTO_PACKAGE),
"xdtoпакеты": ("XDTO", NodeKind.XDTO_PACKAGE),
"webservices": ("WebСервис", NodeKind.WEB_SERVICE),
"webсервисы": ("WebСервис", NodeKind.WEB_SERVICE),
"httpservices": ("HTTPСервис", NodeKind.HTTP_SERVICE),
"httpсервисы": ("HTTPСервис", NodeKind.HTTP_SERVICE),
"wsreferences": ("WSСсылка", NodeKind.WS_REFERENCE),
"wsссылки": ("WSСсылка", NodeKind.WS_REFERENCE),
"websocketclients": ("WebSocketКлиент", NodeKind.WEBSOCKET_CLIENT),
"websocketклиенты": ("WebSocketКлиент", NodeKind.WEBSOCKET_CLIENT),
"integrationservices": ("СервисИнтеграции", NodeKind.INTEGRATION_SERVICE),
"сервисыинтеграции": ("СервисИнтеграции", NodeKind.INTEGRATION_SERVICE),
"bots": ("Бот", NodeKind.BOT),
"боты": ("Бот", NodeKind.BOT),
"interfaces": ("Интерфейс", NodeKind.INTERFACE),
"интерфейсы": ("Интерфейс", NodeKind.INTERFACE),
"fulltextsearchdictionaries": ("СловарьПолнотекстовогоПоиска", NodeKind.FULL_TEXT_SEARCH_DICTIONARY),
"словариполнотекстовогопоиска": ("СловарьПолнотекстовогоПоиска", NodeKind.FULL_TEXT_SEARCH_DICTIONARY),
"palettecolors": ("ЦветПалитры", NodeKind.PALETTE_COLOR),
"цветапалитры": ("ЦветПалитры", NodeKind.PALETTE_COLOR),
"styleitems": ("ЭлементСтиля", NodeKind.STYLE_ITEM),
"элементыстиля": ("ЭлементСтиля", NodeKind.STYLE_ITEM),
"styles": ("Стиль", NodeKind.STYLE),
"стили": ("Стиль", NodeKind.STYLE),
"languages": ("Язык", NodeKind.LANGUAGE),
"языки": ("Язык", NodeKind.LANGUAGE),
"extensions": ("Расширение", NodeKind.EXTENSION),
"расширения": ("Расширение", NodeKind.EXTENSION),
}
@@ -199,6 +296,8 @@ def index_project(path: str | Path, *, project_id: str | None = None, structure_
command_nodes: list[SemanticNode] = []
form_nodes: list[SemanticNode] = []
role_rights: list[dict] = []
access_role_assignments: list[dict] = []
access_group_memberships: list[dict] = []
for source_file in source_files:
text = _read_text_file(source_file)
@@ -306,6 +405,12 @@ def index_project(path: str | Path, *, project_id: str | None = None, structure_
if xml_object.object_kind == "RIGHT":
role_rights.append(xml_object.attributes)
continue
if xml_object.object_kind == "ACCESS_ROLE_ASSIGNMENT":
access_role_assignments.append(xml_object.attributes)
continue
if xml_object.object_kind == "ACCESS_GROUP_MEMBERSHIP":
access_group_memberships.append(xml_object.attributes)
continue
kind = _xml_node_kind(xml_object.object_kind)
if kind is None:
continue
@@ -348,14 +453,49 @@ def index_project(path: str | Path, *, project_id: str | None = None, structure_
NodeKind.SCHEDULED_JOB,
NodeKind.BUSINESS_PROCESS,
NodeKind.TASK,
NodeKind.SUBSYSTEM,
NodeKind.SEQUENCE,
NodeKind.DOCUMENT_NUMERATOR,
NodeKind.EVENT_SUBSCRIPTION,
NodeKind.SESSION_PARAMETER,
NodeKind.COMMON_ATTRIBUTE,
NodeKind.FILTER_CRITERION,
NodeKind.FUNCTIONAL_OPTION,
NodeKind.FUNCTIONAL_OPTION_PARAMETER,
NodeKind.DEFINED_TYPE,
NodeKind.SETTINGS_STORAGE,
NodeKind.COMMON_COMMAND,
NodeKind.COMMAND_GROUP,
NodeKind.COMMON_FORM,
NodeKind.COMMON_LAYOUT,
NodeKind.COMMON_PICTURE,
NodeKind.WEB_SERVICE,
NodeKind.HTTP_SERVICE,
NodeKind.WS_REFERENCE,
NodeKind.WEBSOCKET_CLIENT,
NodeKind.INTEGRATION_SERVICE,
NodeKind.BOT,
NodeKind.INTERFACE,
NodeKind.FULL_TEXT_SEARCH_DICTIONARY,
NodeKind.PALETTE_COLOR,
NodeKind.STYLE_ITEM,
NodeKind.STYLE,
NodeKind.LANGUAGE,
NodeKind.XDTO_PACKAGE,
NodeKind.EXTENSION,
NodeKind.ACCESS_PROFILE,
NodeKind.ACCESS_GROUP,
NodeKind.ACCESS_USER,
NodeKind.ROLE,
NodeKind.FORM,
NodeKind.TABULAR_SECTION,
}:
parent_by_prefix[node.qualified_name] = node
edges.extend(_link_metadata_to_modules(root, module_nodes, metadata_nodes))
edges.extend(_link_metadata_to_modules(root, module_nodes, metadata_nodes, form_nodes))
edges.extend(_link_role_rights(nodes, role_rights))
edges.extend(_link_access_role_assignments(nodes, access_role_assignments))
edges.extend(_link_access_group_memberships(nodes, access_group_memberships))
edges.extend(_link_scheduled_jobs_to_routines(scheduled_job_nodes, routine_by_name))
edges.extend(_link_commands_to_handlers(command_nodes, routine_by_name))
edges.extend(_link_forms_to_handlers(form_nodes, routine_by_name))
@@ -958,7 +1098,36 @@ def _xml_node_kind(object_kind: str) -> NodeKind | None:
"BUSINESS_PROCESS": NodeKind.BUSINESS_PROCESS,
"TASK": NodeKind.TASK,
"SUBSYSTEM": NodeKind.SUBSYSTEM,
"SEQUENCE": NodeKind.SEQUENCE,
"DOCUMENT_NUMERATOR": NodeKind.DOCUMENT_NUMERATOR,
"EVENT_SUBSCRIPTION": NodeKind.EVENT_SUBSCRIPTION,
"SESSION_PARAMETER": NodeKind.SESSION_PARAMETER,
"COMMON_ATTRIBUTE": NodeKind.COMMON_ATTRIBUTE,
"FILTER_CRITERION": NodeKind.FILTER_CRITERION,
"FUNCTIONAL_OPTION": NodeKind.FUNCTIONAL_OPTION,
"FUNCTIONAL_OPTION_PARAMETER": NodeKind.FUNCTIONAL_OPTION_PARAMETER,
"DEFINED_TYPE": NodeKind.DEFINED_TYPE,
"SETTINGS_STORAGE": NodeKind.SETTINGS_STORAGE,
"COMMON_COMMAND": NodeKind.COMMON_COMMAND,
"COMMAND_GROUP": NodeKind.COMMAND_GROUP,
"COMMON_FORM": NodeKind.COMMON_FORM,
"COMMON_LAYOUT": NodeKind.COMMON_LAYOUT,
"COMMON_PICTURE": NodeKind.COMMON_PICTURE,
"WEB_SERVICE": NodeKind.WEB_SERVICE,
"HTTP_SERVICE": NodeKind.HTTP_SERVICE,
"WS_REFERENCE": NodeKind.WS_REFERENCE,
"WEBSOCKET_CLIENT": NodeKind.WEBSOCKET_CLIENT,
"INTEGRATION_SERVICE": NodeKind.INTEGRATION_SERVICE,
"BOT": NodeKind.BOT,
"INTERFACE": NodeKind.INTERFACE,
"FULL_TEXT_SEARCH_DICTIONARY": NodeKind.FULL_TEXT_SEARCH_DICTIONARY,
"PALETTE_COLOR": NodeKind.PALETTE_COLOR,
"STYLE_ITEM": NodeKind.STYLE_ITEM,
"STYLE": NodeKind.STYLE,
"LANGUAGE": NodeKind.LANGUAGE,
"ACCESS_PROFILE": NodeKind.ACCESS_PROFILE,
"ACCESS_GROUP": NodeKind.ACCESS_GROUP,
"ACCESS_USER": NodeKind.ACCESS_USER,
"XDTO_PACKAGE": NodeKind.XDTO_PACKAGE,
"EXTENSION": NodeKind.EXTENSION,
"LAYOUT": NodeKind.LAYOUT,
@@ -983,7 +1152,9 @@ def _xml_edge_kind(kind: NodeKind) -> EdgeKind:
return EdgeKind.HAS_TABULAR_SECTION
if kind == NodeKind.ROLE:
return EdgeKind.HAS_ROLE
if kind == NodeKind.FORM_ELEMENT:
return EdgeKind.HAS_ELEMENT
return EdgeKind.CONTAINS
def _find_xml_parent(parents: dict[str, SemanticNode], qualified_name: str) -> SemanticNode | None:
@@ -1025,6 +1196,7 @@ def _link_metadata_to_modules(
root: Path,
module_nodes: dict[str, SemanticNode],
metadata_nodes: list[SemanticNode],
form_nodes: list[SemanticNode],
) -> list[SemanticEdge]:
if not metadata_nodes:
return []
@@ -1034,6 +1206,7 @@ def _link_metadata_to_modules(
(node.kind, _normalize_lookup_key(node.name)): node
for node in metadata_nodes
}
forms_by_qualified = {_normalize_lookup_key(node.qualified_name): node for node in form_nodes}
edges: list[SemanticEdge] = []
for source_path, module in module_nodes.items():
@@ -1042,6 +1215,26 @@ def _link_metadata_to_modules(
if owner is None:
continue
line = module.source_ref.line_start or 1
module_role = _module_role(source_file)
form_name = _form_name_for_module(root, source_file)
object_part = _module_object_part(module_role, form_name)
module.attributes.update(
{
"owner_lineage_id": owner.lineage_id,
"owner_qualified_name": owner.qualified_name,
"owner_kind": owner.kind.value,
"object_part": object_part,
"module_role": module_role,
}
)
if form_name:
module.attributes["form_name"] = form_name
edge_attributes = {
"link_type": "METADATA_MODULE",
"module_role": module_role,
"object_part": object_part,
"form_name": form_name,
}
edges.append(
_edge(
EdgeKind.CONTAINS,
@@ -1049,16 +1242,61 @@ def _link_metadata_to_modules(
module,
source_path,
line,
{
"link_type": "METADATA_MODULE",
"module_role": _module_role(source_file),
"form_name": _form_name_for_module(root, source_file),
},
edge_attributes,
)
)
if module_role == "FORM_MODULE" and form_name:
form_node = _find_form_node_for_module(owner, form_name, forms_by_qualified)
if form_node is not None:
module.attributes["form_lineage_id"] = form_node.lineage_id
module.attributes["form_qualified_name"] = form_node.qualified_name
edges.append(
_edge(
EdgeKind.CONTAINS,
form_node,
module,
source_path,
line,
{**edge_attributes, "link_type": "FORM_MODULE"},
)
)
return edges
def _find_form_node_for_module(
owner: SemanticNode,
form_name: str,
forms_by_qualified: dict[str, SemanticNode],
) -> SemanticNode | None:
candidates = [
f"{owner.qualified_name}.{form_name}",
f"{owner.qualified_name}.Форма.{form_name}",
]
for candidate in candidates:
form = forms_by_qualified.get(_normalize_lookup_key(candidate))
if form is not None:
return form
suffix = f".{form_name}".casefold()
return next(
(
form
for key, form in forms_by_qualified.items()
if key.endswith(suffix) and key.startswith(_normalize_lookup_key(owner.qualified_name))
),
None,
)
def _module_object_part(module_role: str, form_name: str = "") -> str:
return {
"OBJECT_MODULE": "object.module",
"MANAGER_MODULE": "object.manager",
"RECORD_SET_MODULE": "object.record_set",
"FORM_MODULE": f"form.{form_name}.module" if form_name else "form.module",
"MODULE": "module",
}.get(module_role, "module")
def _link_role_rights(nodes: list[SemanticNode], role_rights: list[dict]) -> list[SemanticEdge]:
if not role_rights:
return []
@@ -1094,6 +1332,57 @@ def _link_role_rights(nodes: list[SemanticNode], role_rights: list[dict]) -> lis
return edges
def _link_access_role_assignments(nodes: list[SemanticNode], assignments: list[dict]) -> list[SemanticEdge]:
if not assignments:
return []
by_qualified = {_normalize_lookup_key(node.qualified_name): node for node in nodes}
by_name_kind = {
(node.kind, _normalize_lookup_key(node.name)): node
for node in nodes
if node.kind in {NodeKind.ACCESS_PROFILE, NodeKind.ACCESS_GROUP, NodeKind.ACCESS_USER, NodeKind.ROLE}
}
edges: list[SemanticEdge] = []
for assignment in assignments:
owner_name = str(assignment.get("owner") or assignment.get("profile") or assignment.get("group") or assignment.get("user") or "")
role_name = str(assignment.get("role") or assignment.get("Role") or assignment.get("Роль") or "")
if not owner_name or not role_name:
continue
owner = by_qualified.get(_normalize_lookup_key(owner_name)) or next(
(
by_name_kind.get((kind, _normalize_lookup_key(owner_name)))
for kind in (NodeKind.ACCESS_PROFILE, NodeKind.ACCESS_GROUP, NodeKind.ACCESS_USER)
if by_name_kind.get((kind, _normalize_lookup_key(owner_name))) is not None
),
None,
)
role = by_qualified.get(_normalize_lookup_key(role_name)) or by_qualified.get(_normalize_lookup_key(f"Роль.{role_name}")) or by_name_kind.get((NodeKind.ROLE, _normalize_lookup_key(role_name)))
if owner is None or role is None:
continue
edges.append(_edge(EdgeKind.ASSIGNS_ROLE, owner, role, owner.source_ref.source_path, 1, dict(assignment)))
return edges
def _link_access_group_memberships(nodes: list[SemanticNode], memberships: list[dict]) -> list[SemanticEdge]:
if not memberships:
return []
by_qualified = {_normalize_lookup_key(node.qualified_name): node for node in nodes}
by_name_kind = {
(node.kind, _normalize_lookup_key(node.name)): node
for node in nodes
if node.kind in {NodeKind.ACCESS_GROUP, NodeKind.ACCESS_USER}
}
edges: list[SemanticEdge] = []
for membership in memberships:
group_name = str(membership.get("group") or membership.get("Group") or membership.get("Группа") or "")
user_name = str(membership.get("user") or membership.get("User") or membership.get("Пользователь") or "")
group = by_qualified.get(_normalize_lookup_key(group_name)) or by_name_kind.get((NodeKind.ACCESS_GROUP, _normalize_lookup_key(group_name)))
user = by_qualified.get(_normalize_lookup_key(user_name)) or by_name_kind.get((NodeKind.ACCESS_USER, _normalize_lookup_key(user_name)))
if group is None or user is None:
continue
edges.append(_edge(EdgeKind.MEMBER_OF, user, group, user.source_ref.source_path, 1, dict(membership)))
return edges
def _link_scheduled_jobs_to_routines(
scheduled_jobs: list[SemanticNode],
routine_by_name: dict[str, SemanticNode],
@@ -70,6 +70,70 @@ def test_index_project_extracts_1c_metadata_objects(tmp_path: Path):
assert any(edge.kind == EdgeKind.HAS_COMMAND for edge in snapshot.edges)
def test_index_project_keeps_extended_1c_metadata_objects(tmp_path: Path):
xml = tmp_path / "metadata.xml"
xml.write_text(
"""
<Configuration>
<Subsystem name="Продажи" qualifiedName="Подсистема.Продажи" />
<Role name="Менеджер" qualifiedName="Роль.Менеджер" />
<Sequence name="ПроведениеДокументов" qualifiedName="Последовательность.ПроведениеДокументов" />
<DocumentNumerator name="ОбщийНумератор" qualifiedName="НумераторДокументов.ОбщийНумератор" />
<CommonForm name="ФормаПодбора" qualifiedName="ОбщаяФорма.ФормаПодбора" />
<FunctionalOption name="ДоступностьСкидок" qualifiedName="ФункциональнаяОпция.ДоступностьСкидок" />
<WebService name="Обмен" qualifiedName="WebСервис.Обмен" />
<WSReference name="ВнешнийСервис" qualifiedName="WSСсылка.ВнешнийСервис" />
<WebSocketClient name="Чат" qualifiedName="WebSocketКлиент.Чат" />
<IntegrationService name="Интеграция" qualifiedName="СервисИнтеграции.Интеграция" />
</Configuration>
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="extended-metadata")
by_kind = {node.kind for node in snapshot.nodes}
assert {
NodeKind.SUBSYSTEM,
NodeKind.ROLE,
NodeKind.SEQUENCE,
NodeKind.DOCUMENT_NUMERATOR,
NodeKind.COMMON_FORM,
NodeKind.FUNCTIONAL_OPTION,
NodeKind.WEB_SERVICE,
NodeKind.WS_REFERENCE,
NodeKind.WEBSOCKET_CLIENT,
NodeKind.INTEGRATION_SERVICE,
}.issubset(by_kind)
def test_index_project_links_access_profiles_groups_and_users(tmp_path: Path):
xml = tmp_path / "access.xml"
xml.write_text(
"""
<AccessData>
<Role name="ЧтениеПродаж" qualifiedName="Роль.ЧтениеПродаж" />
<AccessProfile name="МенеджерПродаж">
<Role name="ЧтениеПродаж" />
</AccessProfile>
<AccessGroup name="ОтделПродаж" profile="МенеджерПродаж">
<Member user="ivanov" />
</AccessGroup>
<InfobaseUser name="ivanov" fullName="Иванов Иван" />
</AccessData>
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="access-graph")
assert any(node.kind == NodeKind.ACCESS_PROFILE and node.name == "МенеджерПродаж" for node in snapshot.nodes)
assert any(node.kind == NodeKind.ACCESS_GROUP and node.name == "ОтделПродаж" for node in snapshot.nodes)
assert any(node.kind == NodeKind.ACCESS_USER and node.name == "ivanov" for node in snapshot.nodes)
assert any(edge.kind == EdgeKind.ASSIGNS_ROLE for edge in snapshot.edges)
assert any(edge.kind == EdgeKind.MEMBER_OF for edge in snapshot.edges)
def test_index_project_remaps_edges_from_duplicate_metadata_nodes(tmp_path: Path):
first = tmp_path / "first.xml"
first.write_text(
@@ -346,6 +410,65 @@ def test_index_project_links_form_command_to_handler(tmp_path: Path):
assert any(edge.kind == EdgeKind.HANDLES for edge in snapshot.edges)
def test_index_project_extracts_managed_form_items_without_layouts(tmp_path: Path):
xml = tmp_path / "form.xml"
xml.write_text(
"""
<Form name="ФормаДокумента" qualifiedName="Документ.Заказ.ФормаДокумента">
<items xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="form:FormGroup" name="Основное">
<caption>Основное</caption>
<items xsi:type="form:FormField" name="Номер">
<caption>Номер</caption>
<dataPath>Объект.Номер</dataPath>
</items>
</items>
<Layout name="ПечатнаяФорма" qualifiedName="Документ.Заказ.ФормаДокумента.ПечатнаяФорма" />
</Form>
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="ui-form-items")
form = form_semantics(snapshot)[0]
assert [element.name for element in form.elements] == ["Основное", "Номер"]
assert all(element.kind == NodeKind.FORM_ELEMENT for element in form.elements)
assert form.elements[1].attributes["dataPath"] == "Объект.Номер"
assert not any(element.name == "ПечатнаяФорма" for element in form.elements)
def test_index_project_extracts_edt_form_items_from_form_file_path(tmp_path: Path):
form_dir = tmp_path / "src" / "Catalogs" / "ВидыЗаказовПокупателей" / "Forms" / "ФормаСписка"
form_dir.mkdir(parents=True)
(form_dir / "Form.form").write_text(
"""
<form:Form xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:form="http://g5.1c.ru/v8/dt/form">
<items xsi:type="form:Table">
<name>Список</name>
<dataPath xsi:type="form:DataPath">
<segments>Список</segments>
</dataPath>
<items xsi:type="form:FormField">
<name>Наименование</name>
<dataPath xsi:type="form:DataPath">
<segments>Список.Description</segments>
</dataPath>
</items>
</items>
</form:Form>
""",
encoding="utf-8",
)
snapshot = index_project(tmp_path, project_id="edt-form-items")
form = next(item for item in form_semantics(snapshot) if item.form.name == "ФормаСписка")
assert form.form.qualified_name == "Справочник.ВидыЗаказовПокупателей.ФормаСписка"
assert [element.name for element in form.elements] == ["Список", "Наименование"]
assert form.elements[0].attributes["control_kind"] == "Table"
assert form.elements[1].attributes["dataPath"] == "Список.Description"
def test_index_project_links_form_events_to_handlers(tmp_path: Path):
xml = tmp_path / "form.xml"
xml.write_text(
+31
View File
@@ -26,6 +26,35 @@ class NodeKind(str, Enum):
BUSINESS_PROCESS = "BUSINESS_PROCESS"
TASK = "TASK"
SUBSYSTEM = "SUBSYSTEM"
SEQUENCE = "SEQUENCE"
DOCUMENT_NUMERATOR = "DOCUMENT_NUMERATOR"
EVENT_SUBSCRIPTION = "EVENT_SUBSCRIPTION"
SESSION_PARAMETER = "SESSION_PARAMETER"
COMMON_ATTRIBUTE = "COMMON_ATTRIBUTE"
FILTER_CRITERION = "FILTER_CRITERION"
FUNCTIONAL_OPTION = "FUNCTIONAL_OPTION"
FUNCTIONAL_OPTION_PARAMETER = "FUNCTIONAL_OPTION_PARAMETER"
DEFINED_TYPE = "DEFINED_TYPE"
SETTINGS_STORAGE = "SETTINGS_STORAGE"
COMMON_COMMAND = "COMMON_COMMAND"
COMMAND_GROUP = "COMMAND_GROUP"
COMMON_FORM = "COMMON_FORM"
COMMON_LAYOUT = "COMMON_LAYOUT"
COMMON_PICTURE = "COMMON_PICTURE"
WEB_SERVICE = "WEB_SERVICE"
WS_REFERENCE = "WS_REFERENCE"
WEBSOCKET_CLIENT = "WEBSOCKET_CLIENT"
INTEGRATION_SERVICE = "INTEGRATION_SERVICE"
BOT = "BOT"
INTERFACE = "INTERFACE"
FULL_TEXT_SEARCH_DICTIONARY = "FULL_TEXT_SEARCH_DICTIONARY"
PALETTE_COLOR = "PALETTE_COLOR"
STYLE_ITEM = "STYLE_ITEM"
STYLE = "STYLE"
LANGUAGE = "LANGUAGE"
ACCESS_PROFILE = "ACCESS_PROFILE"
ACCESS_GROUP = "ACCESS_GROUP"
ACCESS_USER = "ACCESS_USER"
HTTP_SERVICE = "HTTP_SERVICE"
XDTO_PACKAGE = "XDTO_PACKAGE"
EXTENSION = "EXTENSION"
@@ -57,3 +86,5 @@ class EdgeKind(str, Enum):
RUNS = "RUNS"
USES_INTEGRATION = "USES_INTEGRATION"
HANDLES = "HANDLES"
ASSIGNS_ROLE = "ASSIGNS_ROLE"
MEMBER_OF = "MEMBER_OF"
@@ -19,15 +19,20 @@ def form_semantics(snapshot: SirSnapshot) -> list[FormSemantics]:
for node in snapshot.nodes
if node.kind == NodeKind.FORM
}
element_children: dict[str, list[SemanticNode]] = {}
for edge in snapshot.edges:
form = forms.get(edge.source_lineage)
target = nodes.get(edge.target_lineage)
if form is None or target is None:
if target is None:
continue
if edge.kind == EdgeKind.HAS_ELEMENT and target.kind == NodeKind.FORM_ELEMENT:
element_children.setdefault(edge.source_lineage, []).append(target)
if form is None:
continue
if edge.kind == EdgeKind.HAS_COMMAND:
form.commands.append(target)
elif edge.kind == EdgeKind.HAS_ELEMENT:
form.elements.append(target)
for form in forms.values():
form.elements.extend(_flatten_form_elements(form.form.lineage_id, element_children))
command_to_form = {
command.lineage_id: form
for form in forms.values()
@@ -43,4 +48,20 @@ def form_semantics(snapshot: SirSnapshot) -> list[FormSemantics]:
return sorted(forms.values(), key=lambda item: item.form.qualified_name)
def _flatten_form_elements(root_lineage: str, element_children: dict[str, list[SemanticNode]]) -> list[SemanticNode]:
result: list[SemanticNode] = []
seen: set[str] = set()
def visit(parent_lineage: str) -> None:
for element in element_children.get(parent_lineage, []):
if element.lineage_id in seen:
continue
seen.add(element.lineage_id)
result.append(element)
visit(element.lineage_id)
visit(root_lineage)
return result
__all__ = ["FormSemantics", "form_semantics"]
+99 -1
View File
@@ -694,6 +694,100 @@ function Export-CfOrCfeFromInfobase {
return $exportRoot
}
function Convert-LocalCfOrCfeToMetadataExport {
param([object]$Job, [string[]]$PlatformBins)
$payloadPath = [string]$Job.local_path
if ([string]::IsNullOrWhiteSpace($payloadPath)) {
throw "local_path is required for direct CF/CFE conversion."
}
if (!(Test-Path -LiteralPath $payloadPath)) {
throw "Local CF/CFE path not found on agent machine: $payloadPath"
}
$designerPath = Get-DesignerBinPath -Job $Job -PlatformBins $PlatformBins
$workRoot = Join-Path $env:TEMP "sfera-agent"
$exportRoot = Join-Path $workRoot "$($Job.job_id)-local-binary"
if (Test-Path -LiteralPath $exportRoot) { Remove-Item -LiteralPath $exportRoot -Recurse -Force }
New-Item -ItemType Directory -Force -Path $exportRoot | Out-Null
$builderInfobase = Join-Path $exportRoot "builder-infobase"
$createLog = Join-Path $exportRoot "create-builder-infobase.log"
Invoke-1CCommand `
-PlatformPath $designerPath `
-Arguments @("CREATEINFOBASE", "File=$builderInfobase;") `
-LogPath $createLog `
-JobId $Job.job_id `
-ActionTitle "1C CREATEINFOBASE for local CF/CFE conversion" `
-TimeoutSeconds 180
$builderArgs = @("/F", $builderInfobase)
$sourceKind = [string]$Job.source
$fileName = [System.IO.Path]::GetFileName($payloadPath)
$artifactsRoot = Join-Path $exportRoot "artifacts"
New-Item -ItemType Directory -Force -Path $artifactsRoot | Out-Null
Copy-Item -LiteralPath $payloadPath -Destination (Join-Path $artifactsRoot $fileName) -Force
if ($sourceKind -eq "CF_FILE") {
$loadLog = Join-Path $exportRoot "designer-loadcfg-local-cf.log"
Invoke-DesignerCommand `
-DesignerPath $designerPath `
-Arguments (@($builderArgs) + @("/LoadCfg", $payloadPath)) `
-LogPath $loadLog `
-JobId $Job.job_id `
-ActionTitle "1C LoadCfg local CF" `
-TimeoutSeconds 180
$metadataRoot = Join-Path $exportRoot "configuration"
New-Item -ItemType Directory -Force -Path $metadataRoot | Out-Null
$metadataLog = Join-Path $exportRoot "designer-dumpconfigtofiles-local-cf.log"
Invoke-DesignerCommand `
-DesignerPath $designerPath `
-Arguments (@($builderArgs) + @("/DumpConfigToFiles", $metadataRoot, "-Format", "Hierarchical")) `
-LogPath $metadataLog `
-JobId $Job.job_id `
-ActionTitle "1C DumpConfigToFiles from local CF" `
-TimeoutSeconds 180
Copy-Item -LiteralPath $payloadPath -Destination (Join-Path $metadataRoot $fileName) -Force
Send-JobLogs -JobId $Job.job_id -Logs @("Local .cf converted to metadata export for server-side parsing.")
return $exportRoot
}
if ($sourceKind -eq "CFE_FILE") {
$extensionName = Get-JobMetadataValue -Job $Job -Key "one_c_extension"
if ([string]::IsNullOrWhiteSpace($extensionName)) {
$extensionName = [System.IO.Path]::GetFileNameWithoutExtension($payloadPath)
}
if ([string]::IsNullOrWhiteSpace($extensionName)) {
throw "Extension name is required for local CFE conversion."
}
$loadLog = Join-Path $exportRoot "designer-loadcfg-local-cfe.log"
Invoke-DesignerCommand `
-DesignerPath $designerPath `
-Arguments (@($builderArgs) + @("/LoadCfg", $payloadPath, "-Extension", $extensionName, "/UpdateDBCfg")) `
-LogPath $loadLog `
-JobId $Job.job_id `
-ActionTitle "1C LoadCfg local CFE" `
-TimeoutSeconds 180
$metadataRoot = Join-Path $exportRoot "extension"
New-Item -ItemType Directory -Force -Path $metadataRoot | Out-Null
$metadataLog = Join-Path $exportRoot "designer-dumpconfigtofiles-local-cfe.log"
Invoke-DesignerCommand `
-DesignerPath $designerPath `
-Arguments (@($builderArgs) + @("/DumpConfigToFiles", $metadataRoot, "-Format", "Hierarchical", "-Extension", $extensionName)) `
-LogPath $metadataLog `
-JobId $Job.job_id `
-ActionTitle "1C DumpConfigToFiles from local CFE" `
-TimeoutSeconds 180
Copy-Item -LiteralPath $payloadPath -Destination (Join-Path $metadataRoot $fileName) -Force
Send-JobLogs -JobId $Job.job_id -Logs @("Local .cfe converted to metadata export for server-side parsing.")
return $exportRoot
}
throw "Unsupported source for local CF/CFE conversion: $sourceKind"
}
function Install-SferaExtensionJob {
param([object]$Job, [string[]]$PlatformBins)
$workRoot = Join-Path $env:TEMP "sfera-agent"
@@ -1138,9 +1232,13 @@ while ($true) {
continue
}
$payloadPath = $job.local_path
if (($job.source -eq "CF_FILE") -or (($job.source -eq "CFE_FILE") -and [string]::IsNullOrWhiteSpace($payloadPath))) {
if (($job.source -eq "CF_FILE") -or ($job.source -eq "CFE_FILE")) {
if (![string]::IsNullOrWhiteSpace($payloadPath)) {
$payloadPath = Convert-LocalCfOrCfeToMetadataExport -Job $job -PlatformBins $platformBins
} else {
$payloadPath = Export-CfOrCfeFromInfobase -Job $job -PlatformBins $platformBins
}
}
if ([string]::IsNullOrWhiteSpace($payloadPath)) {
throw "Job does not contain local_path or enough 1C infobase settings for agent export."
}
+1
View File
@@ -28,6 +28,7 @@ dependencies = [
"sfera-ui-semantics",
"smbprotocol>=1.15",
"uvicorn>=0.30",
"python-multipart>=0.0.20",
]
[tool.uv]
@@ -0,0 +1,144 @@
from __future__ import annotations
from enum import Enum
from pydantic import BaseModel, Field
from api_server.import_source_models import ImportMode, ImportSourceKind
class AgentImportJobStatus(str, Enum):
QUEUED = "QUEUED"
RUNNING = "RUNNING"
SUCCEEDED = "SUCCEEDED"
FAILED = "FAILED"
CANCELLED = "CANCELLED"
class AgentImportJobRequest(BaseModel):
agent_id: str
source: ImportSourceKind
local_path: str | None = None
bin_path: str | None = None
infobase: str | None = None
credentials_ref: str | None = None
mode: ImportMode = ImportMode.FULL_REPLACE
metadata: dict = Field(default_factory=dict)
class AgentImportJob(BaseModel):
job_id: str
project_id: str
agent_id: str
source: ImportSourceKind
mode: ImportMode = ImportMode.FULL_REPLACE
status: AgentImportJobStatus = AgentImportJobStatus.QUEUED
local_path: str | None = None
bin_path: str | None = None
infobase: str | None = None
credentials_ref: str | None = None
metadata: dict = Field(default_factory=dict)
created_at: str
updated_at: str
claimed_at: str | None = None
completed_at: str | None = None
server_path: str | None = None
logs: list[str] = Field(default_factory=list)
error: str | None = None
import_summary: dict | None = None
class AgentImportJobResult(BaseModel):
status: AgentImportJobStatus = AgentImportJobStatus.SUCCEEDED
server_path: str | None = None
logs: list[str] = Field(default_factory=list)
error: str | None = None
metadata: dict = Field(default_factory=dict)
class AgentImportJobLogRequest(BaseModel):
logs: list[str] = Field(default_factory=list)
status: AgentImportJobStatus | None = None
class AgentBrowseRequestStatus(str, Enum):
QUEUED = "QUEUED"
RUNNING = "RUNNING"
SUCCEEDED = "SUCCEEDED"
FAILED = "FAILED"
class AgentBrowseRequestCreate(BaseModel):
agent_id: str
path: str | None = None
class AgentFolderEntry(BaseModel):
name: str
path: str
is_directory: bool = True
class AgentBrowseRequest(BaseModel):
request_id: str
agent_id: str
path: str | None = None
status: AgentBrowseRequestStatus = AgentBrowseRequestStatus.QUEUED
created_at: str
updated_at: str
claimed_at: str | None = None
completed_at: str | None = None
entries: list[AgentFolderEntry] = Field(default_factory=list)
parent_path: str | None = None
error: str | None = None
class AgentBrowseResult(BaseModel):
status: AgentBrowseRequestStatus = AgentBrowseRequestStatus.SUCCEEDED
entries: list[AgentFolderEntry] = Field(default_factory=list)
parent_path: str | None = None
error: str | None = None
class AgentHeartbeatRequest(BaseModel):
agent_id: str
host: str | None = None
user: str | None = None
version: str | None = None
started_at: str | None = None
network_roots: list[str] = Field(default_factory=list)
platform_bins: list[str] = Field(default_factory=list)
class AgentStatus(BaseModel):
agent_id: str
status: str = "offline"
last_seen_at: str | None = None
host: str | None = None
user: str | None = None
version: str | None = None
started_at: str | None = None
network_roots: list[str] = Field(default_factory=list)
platform_bins: list[str] = Field(default_factory=list)
class AgentProjectConfig(BaseModel):
project_id: str
project_name: str
one_c_bin: str | None = None
edt_path: str | None = None
default_local_path: str | None = None
network_roots: list[str] = Field(default_factory=list)
class AgentConfigResponse(BaseModel):
agent_id: str
poll_seconds: int = 5
projects: list[AgentProjectConfig] = Field(default_factory=list)
class ServerBrowseResponse(BaseModel):
path: str
parent_path: str | None = None
entries: list[AgentFolderEntry] = Field(default_factory=list)
error: str | None = None
@@ -0,0 +1,751 @@
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
from one_c_normalizer import NormalizedProject, normalize_one_c_project
from semantic_kernel import index_project
from sir import SirSnapshot, snapshot_to_json
AI_STRUCTURE_VERSION = "1.0"
_PARSEABLE_SUFFIXES = {".xml", ".mdo", ".bsl"}
_BINARY_1C_SUFFIXES = {".cf", ".cfe"}
_CODEX_SOURCE_SUFFIXES = {".xml", ".mdo", ".bsl", ".json", ".txt"}
_MAX_CODEX_SOURCE_FILE_BYTES = 2_000_000
_ROOT_XML_NAMES = {"metadata.xml", "configuration.xml"}
def prepare_ai_structure(
*,
project_id: str,
input_path: Path,
output_path: Path,
structure_only: bool = False,
display_input_path: str | None = None,
display_output_path: str | None = None,
) -> dict[str, Any]:
if not input_path.exists():
raise FileNotFoundError(f"Входная папка не найдена: {input_path}")
output_path.mkdir(parents=True, exist_ok=True)
files = _inventory(input_path)
parseable = any(Path(item["relative_path"]).suffix.casefold() in _PARSEABLE_SUFFIXES for item in files)
binaries = [item for item in files if Path(item["relative_path"]).suffix.casefold() in _BINARY_1C_SUFFIXES]
diagnostics: list[str] = []
snapshot: SirSnapshot | None = None
normalized: NormalizedProject | None = None
if parseable:
snapshot = index_project(input_path, project_id=project_id, structure_only=structure_only)
try:
normalized = normalize_one_c_project(input_path, project_id=project_id)
except Exception as error:
diagnostics.append(f"Не удалось построить NormalizedProject: {error}")
elif binaries:
diagnostics.append(
"Во входной папке есть только бинарные файлы .cf/.cfe. Для серверной подготовки структуры нужен "
"экспорт Designer DumpConfigToFiles или выгрузка через Windows Agent перед семантической индексацией."
)
else:
diagnostics.append("Во входной папке не найдены файлы метаданных 1С, XML, BSL или бинарные .cf/.cfe.")
codex_root = output_path / _codex_folder_name(project_id)
manifest = _manifest(
project_id,
display_input_path or str(input_path),
display_output_path or str(output_path),
_join_display_path(display_output_path, codex_root.name) if display_output_path else str(codex_root),
files,
snapshot,
normalized,
diagnostics,
binaries,
_source_layout_summary(input_path),
_source_preview_summary(input_path),
)
_write_json(output_path / "manifest.json", manifest)
_write_json(output_path / "source_inventory.json", {"files": files})
_write_json(output_path / "source_preview.json", manifest.get("source_preview") or [])
if snapshot is not None:
(output_path / "sir_snapshot.json").write_bytes(snapshot_to_json(snapshot))
_write_json(output_path / "ai_objects.json", _ai_objects(snapshot))
_write_json(output_path / "ai_modules.json", _ai_modules(snapshot))
_write_json(output_path / "ai_edges.json", [edge.model_dump(mode="json") for edge in snapshot.edges])
if normalized is not None:
_write_json(output_path / "normalized_project.json", normalized.model_dump(mode="json"))
_write_json(output_path / "project_layout.json", manifest.get("source_layout") or {})
_write_json(output_path / "compact_objects.json", _compact_objects(normalized))
_write_json(output_path / "compact_modules.json", _compact_modules(normalized))
_write_text(output_path / "ai_context.md", _ai_context_markdown(manifest, snapshot, normalized))
_write_text(output_path / "export_plan.md", _export_plan_markdown(project_id, input_path, output_path, binaries, parseable))
_write_codex_package(codex_root, input_path, manifest, files, snapshot, normalized, binaries, parseable)
return manifest
def _inventory(root: Path) -> list[dict[str, Any]]:
paths = [root] if root.is_file() else sorted(path for path in root.rglob("*") if path.is_file())
return [
{
"relative_path": path.name if root.is_file() else path.relative_to(root).as_posix(),
"suffix": path.suffix.casefold(),
"size": path.stat().st_size,
}
for path in paths
]
def _manifest(
project_id: str,
input_path: str,
output_path: str,
codex_root: str,
files: list[dict[str, Any]],
snapshot: SirSnapshot | None,
normalized: NormalizedProject | None,
diagnostics: list[str],
binaries: list[dict[str, Any]],
source_layout: dict[str, Any],
source_preview: list[dict[str, Any]],
) -> dict[str, Any]:
return {
"version": AI_STRUCTURE_VERSION,
"project_id": project_id,
"input_path": input_path,
"output_path": output_path,
"codex_package_path": codex_root,
"codex_package_folder": Path(codex_root).name if not codex_root.startswith("\\\\") else codex_root.rstrip("\\").rsplit("\\", 1)[-1],
"status": "ready" if snapshot is not None or normalized is not None else "export_required",
"files_count": len(files),
"binary_1c_files": binaries,
"source_layout": source_layout,
"source_preview": source_preview,
"artifacts": _artifacts(snapshot, normalized),
"snapshot": None
if snapshot is None
else {
"snapshot_id": snapshot.snapshot_id,
"snapshot_hash": snapshot.snapshot_hash,
"nodes": len(snapshot.nodes),
"edges": len(snapshot.edges),
"diagnostics": len(snapshot.diagnostics),
},
"normalized": None
if normalized is None
else {
"objects": sum(len(group.objects) for group in normalized.configuration.groups),
"groups": len(normalized.configuration.groups),
"extensions": len(normalized.configuration.extensions),
"access_profiles": len(normalized.access.profiles),
"access_groups": len(normalized.access.groups),
"access_users": len(normalized.access.users),
},
"diagnostics": diagnostics,
}
def _artifacts(snapshot: SirSnapshot | None, normalized: NormalizedProject | None) -> list[str]:
artifacts = [
"manifest.json",
"source_inventory.json",
"ai_context.md",
"export_plan.md",
"codex_package",
"project_layout.json",
"source_preview.json",
"compact_objects.json",
"compact_modules.json",
]
if snapshot is not None:
artifacts.extend(["sir_snapshot.json", "ai_objects.json", "ai_modules.json", "ai_edges.json"])
if normalized is not None:
artifacts.append("normalized_project.json")
return artifacts
def _codex_folder_name(project_id: str) -> str:
safe = re.sub(r"[^A-Za-z0-9_.-]+", "-", project_id).strip("-._") or "project"
return f"codex-1c-context-{safe}"
def _join_display_path(root: str | None, child: str) -> str:
if not root:
return child
separator = "\\" if root.startswith("\\\\") or "\\" in root else "/"
cleaned = root.rstrip("/\\")
return f"{cleaned}{separator}{child}"
def _write_codex_package(
root: Path,
input_path: Path,
manifest: dict[str, Any],
files: list[dict[str, Any]],
snapshot: SirSnapshot | None,
normalized: NormalizedProject | None,
binaries: list[dict[str, Any]],
parseable: bool,
) -> None:
(root / "context").mkdir(parents=True, exist_ok=True)
(root / "indexes").mkdir(parents=True, exist_ok=True)
(root / "objects").mkdir(parents=True, exist_ok=True)
(root / "modules").mkdir(parents=True, exist_ok=True)
(root / "raw").mkdir(parents=True, exist_ok=True)
(root / "compact").mkdir(parents=True, exist_ok=True)
source_map = _copy_codex_sources(input_path, root / "source")
compact_objects = _compact_objects(normalized)
compact_modules = _compact_modules(normalized)
_write_text(root / "AGENTS.md", _codex_agents_markdown(manifest))
_write_text(root / "README.md", _codex_readme_markdown(manifest))
_write_text(root / "context" / "CODEX_START_HERE.md", _codex_start_here_markdown(manifest))
_write_text(root / "context" / "project-overview.md", _ai_context_markdown(manifest, snapshot, normalized))
_write_text(root / "context" / "project-brief.md", _project_brief_markdown(manifest, compact_objects, compact_modules))
_write_text(root / "context" / "export-plan.md", _export_plan_markdown(manifest["project_id"], Path(manifest["input_path"]), root, binaries, parseable))
_write_json(root / "indexes" / "manifest.json", manifest)
_write_json(root / "indexes" / "codex-navigation.json", _codex_navigation(manifest, source_map))
_write_json(root / "indexes" / "source-inventory.json", {"files": files})
_write_json(root / "indexes" / "source-map.json", {"files": source_map})
_write_json(root / "indexes" / "project-layout.json", manifest.get("source_layout") or {})
_write_json(root / "indexes" / "source-preview.json", manifest.get("source_preview") or [])
_write_json(root / "indexes" / "objects-compact.json", compact_objects)
_write_json(root / "indexes" / "modules-compact.json", compact_modules)
_write_json(root / "compact" / "objects.json", compact_objects)
_write_json(root / "compact" / "modules.json", compact_modules)
if snapshot is not None:
(root / "raw" / "sir_snapshot.json").write_bytes(snapshot_to_json(snapshot))
source_lookup = _source_lookup(source_map)
objects = _ai_objects(snapshot, source_lookup)
modules = _ai_modules(snapshot, source_lookup)
_write_json(root / "indexes" / "objects.json", objects)
_write_json(root / "indexes" / "modules.json", modules)
_write_json(root / "indexes" / "edges.json", [edge.model_dump(mode="json") for edge in snapshot.edges])
_write_object_markdown_files(root / "objects", objects)
_write_module_markdown_files(root / "modules", modules)
if normalized is not None:
_write_json(root / "raw" / "normalized_project.json", normalized.model_dump(mode="json"))
_write_text(root / "context" / "metadata-tree.md", _normalized_tree_markdown(normalized))
_write_json(root / "indexes" / "access-model.json", normalized.access.model_dump(mode="json"))
def _copy_codex_sources(input_path: Path, target: Path) -> list[dict[str, Any]]:
target.mkdir(parents=True, exist_ok=True)
source_files = [input_path] if input_path.is_file() else sorted(path for path in input_path.rglob("*") if path.is_file())
copied: list[dict[str, Any]] = []
for path in source_files:
suffix = path.suffix.casefold()
relative = path.name if input_path.is_file() else path.relative_to(input_path).as_posix()
if not _should_copy_codex_source(path, input_path):
continue
size = path.stat().st_size
if size > _MAX_CODEX_SOURCE_FILE_BYTES:
copied.append(
{
"original_path": str(path),
"relative_path": relative,
"copied": False,
"reason": f"file is larger than {_MAX_CODEX_SOURCE_FILE_BYTES} bytes",
"size": size,
}
)
continue
destination = target / relative
destination.parent.mkdir(parents=True, exist_ok=True)
try:
text = path.read_text(encoding="utf-8-sig")
except UnicodeDecodeError:
text = path.read_text(encoding="cp1251", errors="replace")
destination.write_text(text, encoding="utf-8")
copied.append(
{
"original_path": str(path),
"relative_path": relative,
"codex_path": f"source/{relative}",
"copied": True,
"size": size,
}
)
return copied
def _should_copy_codex_source(path: Path, root: Path) -> bool:
suffix = path.suffix.casefold()
if suffix == ".bsl" or suffix == ".mdo":
return True
if suffix not in _CODEX_SOURCE_SUFFIXES:
return False
if suffix == ".xml":
if path.name.casefold() in _ROOT_XML_NAMES:
return True
parts = [part.casefold() for part in path.relative_to(root).parts[:-1]] if root.is_dir() else []
return any(part in {"forms", "configuration", "конфигурация"} for part in parts)
return suffix in {".json", ".txt"}
def _source_lookup(source_map: list[dict[str, Any]]) -> dict[str, str]:
lookup: dict[str, str] = {}
for item in source_map:
if not item.get("copied"):
continue
codex_path = str(item.get("codex_path") or "")
if not codex_path:
continue
for key in ["relative_path", "original_path"]:
value = str(item.get(key) or "")
if not value:
continue
normalized = value.replace("\\", "/")
lookup[normalized] = codex_path
lookup[str(Path(value)).replace("\\", "/")] = codex_path
return lookup
def _codex_agents_markdown(manifest: dict[str, Any]) -> str:
return f"""# AGENTS.md для пакета контекста 1С
Эта папка сгенерирована SFERA для Codex.
## Как использовать эту папку
- Используйте пакет как контекст только для чтения для проекта `{manifest['project_id']}`.
- Начинайте с `README.md`, `context/project-brief.md` и `context/project-overview.md`.
- Для быстрой навигации сначала используйте `indexes/objects-compact.json`, `indexes/modules-compact.json` и `indexes/project-layout.json`.
- К тяжелым файлам `indexes/objects.json`, `indexes/modules.json`, `raw/normalized_project.json` и `source/` переходите только когда компактной сводки уже не хватает.
- Для текста BSL/XML/MDO используйте локальную папку `source/`. Это выборочная копия нужных исходников, а не полный дубликат всей выгрузки.
- Используйте `indexes/source-map.json`, чтобы сопоставлять исходные пути с локальными путями `source/...`.
- Если есть `raw/normalized_project.json`, считайте его основной моделью метаданных 1С.
- Модули, формы, команды, реквизиты, табличные части и права являются частями объектов 1С-владельцев. Не рассматривайте модуль формы как отдельный независимый файл.
- При генерации BSL сохраняйте контекст объекта-владельца из `qualified_name`, `lineage_id` и `source`.
- Если `status` равен `export_required`, сначала выгрузите `.cf/.cfe` через 1C Designer/Windows Agent и затем пересоздайте пакет по выгруженным файлам.
## Важные файлы
- `context/project-brief.md` - короткая сводка для быстрого старта Codex.
- `context/project-overview.md` - расширенный контекст для человека.
- `context/metadata-tree.md` - дерево метаданных из NormalizedProject.
- `indexes/*.json` - машиночитаемые индексы; сначала используйте compact-варианты.
- `source/` - выборочные UTF-8 копии BSL/MDO и ключевых XML.
- `objects/*.md` - карточки объектов.
- `modules/*.md` - карточки модулей.
- `raw/*.json` - полная сырая модель SFERA.
"""
def _codex_readme_markdown(manifest: dict[str, Any]) -> str:
snapshot = manifest.get("snapshot") or {}
normalized = manifest.get("normalized") or {}
lines = [
f"# Контекст 1С для Codex: {manifest['project_id']}",
"",
f"- Статус: `{manifest['status']}`",
f"- Источник: `{manifest['input_path']}`",
f"- Просканировано файлов: {manifest['files_count']}",
f"- Узлов SIR: {snapshot.get('nodes', 0)}",
f"- Связей SIR: {snapshot.get('edges', 0)}",
f"- Нормализованных объектов: {normalized.get('objects', 0)}",
f"- Расширений: {normalized.get('extensions', 0)}",
"",
"Перенесите эту папку целиком в проект Codex, когда хотите, чтобы Codex писал код для этой конфигурации 1С.",
"Для экономии токенов сначала используйте compact-индексы и brief-контекст, а к `source/` и `raw/` переходите только при необходимости.",
]
layout = manifest.get("source_layout") or {}
if layout:
lines.extend(
[
"",
"## Структура выгрузки",
f"- Основная конфигурация: `{layout.get('main_configuration_root') or 'не определена'}`",
f"- Папок расширений: {len(layout.get('extension_roots') or [])}",
]
)
if manifest.get("diagnostics"):
lines.extend(["", "## Диагностика"])
lines.extend(f"- {item}" for item in manifest["diagnostics"])
return "\n".join(lines) + "\n"
def _codex_start_here_markdown(manifest: dict[str, Any]) -> str:
return f"""# Начните здесь для Codex
Проект: `{manifest['project_id']}`
Статус: `{manifest['status']}`
Читайте в таком порядке:
1. `AGENTS.md`
2. `README.md`
3. `context/project-brief.md`
4. `context/project-overview.md`
5. `indexes/project-layout.json`
6. `indexes/objects-compact.json`
7. `indexes/modules-compact.json`
8. `context/metadata-tree.md`
9. `indexes/objects.json`
10. `indexes/modules.json`
11. `indexes/edges.json`
12. `source/`
При генерации кода:
- Сначала найдите объект 1С-владельца.
- Затем изучите контекст его модуля, формы и команды.
- Для точного текста исходника предпочитайте локальные копии в `source/`, но открывайте их только когда compact-индекса уже недостаточно.
- Используйте `raw/normalized_project.json`, когда структура объекта важнее, чем сырой XML.
- Используйте `indexes/source-map.json`, если нужно сопоставить ссылки SFERA с локальными путями пакета.
"""
def _codex_navigation(manifest: dict[str, Any], source_map: list[dict[str, Any]]) -> dict[str, Any]:
copied_sources = [item for item in source_map if item.get("copied")]
return {
"project_id": manifest["project_id"],
"status": manifest["status"],
"start_here": "context/CODEX_START_HERE.md",
"instructions": "AGENTS.md",
"overview": "context/project-overview.md",
"brief": "context/project-brief.md",
"project_layout": "indexes/project-layout.json",
"compact_objects_index": "indexes/objects-compact.json",
"compact_modules_index": "indexes/modules-compact.json",
"metadata_tree": "context/metadata-tree.md",
"objects_index": "indexes/objects.json",
"modules_index": "indexes/modules.json",
"edges_index": "indexes/edges.json",
"source_map": "indexes/source-map.json",
"raw_normalized_project": "raw/normalized_project.json",
"raw_sir_snapshot": "raw/sir_snapshot.json",
"local_source_count": len(copied_sources),
"first_sources": [item.get("codex_path") for item in copied_sources[:25]],
}
def _write_object_markdown_files(root: Path, objects: list[dict[str, Any]]) -> None:
for item in objects[:1000]:
filename = _safe_context_filename(str(item.get("qualified_name") or item.get("name") or "object")) + ".md"
_write_text(root / filename, _object_markdown(item))
def _write_module_markdown_files(root: Path, modules: list[dict[str, Any]]) -> None:
for item in modules[:1000]:
filename = _safe_context_filename(str(item.get("qualified_name") or item.get("name") or "module")) + ".md"
_write_text(root / filename, _module_markdown(item))
def _safe_context_filename(value: str) -> str:
safe = re.sub(r"[^A-Za-zА-Яа-яЁё0-9_.-]+", "_", value).strip("._")
return (safe or "item")[:140]
def _object_markdown(item: dict[str, Any]) -> str:
local_source = _local_source_path(item)
return "\n".join(
[
f"# {item.get('qualified_name') or item.get('name')}",
"",
f"- Вид: `{item.get('kind')}`",
f"- Имя: `{item.get('name')}`",
f"- Lineage: `{item.get('lineage_id')}`",
f"- Semantic: `{item.get('semantic_id')}`",
f"- Источник: `{item.get('source')}`",
f"- Локальный исходник: `{local_source}`",
"",
"## Атрибуты",
"```json",
json.dumps(item.get("attributes") or {}, ensure_ascii=False, indent=2, default=str),
"```",
]
) + "\n"
def _module_markdown(item: dict[str, Any]) -> str:
local_source = _local_source_path(item)
return "\n".join(
[
f"# {item.get('qualified_name') or item.get('name')}",
"",
f"- Имя: `{item.get('name')}`",
f"- Lineage: `{item.get('lineage_id')}`",
f"- Источник: `{item.get('source')}`",
f"- Локальный исходник: `{local_source}`",
"",
"## Атрибуты модуля",
"```json",
json.dumps(item.get("attributes") or {}, ensure_ascii=False, indent=2, default=str),
"```",
]
) + "\n"
def _local_source_path(item: dict[str, Any]) -> str:
local = str(item.get("local_source_path") or "")
if local:
return local
source = item.get("source") or {}
if not isinstance(source, dict):
return ""
source_path = str(source.get("source_path") or "")
return f"source/{source_path}" if source_path else ""
def _source_ref_local_path(source_ref: object | None, source_lookup: dict[str, str]) -> str:
if source_ref is None:
return ""
source_path = str(getattr(source_ref, "source_path", "") or "")
if not source_path:
return ""
normalized = source_path.replace("\\", "/")
return source_lookup.get(normalized) or source_lookup.get(str(Path(source_path)).replace("\\", "/")) or ""
def _normalized_tree_markdown(normalized: NormalizedProject) -> str:
lines = [f"# Дерево метаданных: {normalized.project_id or 'project'}", ""]
for group in normalized.configuration.groups:
lines.append(f"## {group.name}")
if not group.objects:
lines.append("- нет объектов")
continue
for item in group.objects[:500]:
lines.append(f"- `{item.qualified_name}` ({item.object_kind})")
for form in item.forms[:20]:
lines.append(f" - form: `{form.name}`")
for command in item.commands[:20]:
lines.append(f" - command: `{command.name}`")
lines.append("")
return "\n".join(lines)
def _ai_objects(snapshot: SirSnapshot, source_lookup: dict[str, str] | None = None) -> list[dict[str, Any]]:
return [
{
"kind": node.kind.value if hasattr(node.kind, "value") else str(node.kind),
"name": node.name,
"qualified_name": node.qualified_name,
"lineage_id": node.lineage_id,
"semantic_id": node.semantic_id,
"source": None if node.source_ref is None else node.source_ref.model_dump(mode="json"),
"local_source_path": _source_ref_local_path(node.source_ref, source_lookup or {}),
"attributes": node.attributes,
}
for node in snapshot.nodes
if (node.kind.value if hasattr(node.kind, "value") else str(node.kind)) != "MODULE"
]
def _ai_modules(snapshot: SirSnapshot, source_lookup: dict[str, str] | None = None) -> list[dict[str, Any]]:
return [
{
"name": node.name,
"qualified_name": node.qualified_name,
"lineage_id": node.lineage_id,
"source": None if node.source_ref is None else node.source_ref.model_dump(mode="json"),
"local_source_path": _source_ref_local_path(node.source_ref, source_lookup or {}),
"attributes": node.attributes,
}
for node in snapshot.nodes
if (node.kind.value if hasattr(node.kind, "value") else str(node.kind)) == "MODULE"
]
def _ai_context_markdown(manifest: dict[str, Any], snapshot: SirSnapshot | None, normalized: NormalizedProject | None) -> str:
lines = [
f"# Структура SFERA для ИИ: {manifest['project_id']}",
"",
f"- Статус: {manifest['status']}",
f"- Исходных файлов: {manifest['files_count']}",
f"- Артефакты: {', '.join(manifest['artifacts'])}",
]
if snapshot is not None:
lines.extend(
[
f"- Узлов SIR: {len(snapshot.nodes)}",
f"- Связей SIR: {len(snapshot.edges)}",
f"- Хеш снимка: {snapshot.snapshot_hash}",
]
)
if normalized is not None:
lines.append(f"- Групп нормализованных метаданных: {len(normalized.configuration.groups)}")
if manifest["diagnostics"]:
lines.append("")
lines.append("## Диагностика")
lines.extend(f"- {item}" for item in manifest["diagnostics"])
lines.extend(
[
"",
"## Как ИИ должен использовать этот пакет",
"- Используйте `normalized_project.json` как основную модель объектов 1С.",
"- Для экономии токенов начинайте с `project-brief.md`, `project-layout.json`, `objects-compact.json` и `modules-compact.json`.",
"- Используйте `sir_snapshot.json`, `ai_objects.json`, `ai_modules.json` и `ai_edges.json` для навигации по коду и анализа влияния.",
"- Рассматривайте модули, формы и команды как части объектов 1С-владельцев, а не как отдельные текстовые файлы.",
]
)
return "\n".join(lines) + "\n"
def _source_layout_summary(root: Path) -> dict[str, Any]:
if root.is_file():
return {"kind": "file", "main_configuration_root": root.name, "extension_roots": []}
children = [path for path in sorted(root.iterdir()) if path.is_dir()]
config_dir = next((path for path in children if path.name.casefold() in {"configuration", "конфигурация"}), None)
extension_roots = [
path.name
for path in children
if path != config_dir and any(item.suffix.casefold() in {".xml", ".mdo", ".bsl"} for item in path.rglob("*") if item.is_file())
]
kind = "configuration_with_extensions" if config_dir else "flat_or_mixed"
return {
"kind": kind,
"main_configuration_root": config_dir.name if config_dir else root.name,
"extension_roots": extension_roots,
}
def _source_preview_summary(root: Path) -> list[dict[str, Any]]:
files = [root] if root.is_file() else sorted(path for path in root.rglob("*") if path.is_file())
object_files = _preview_relative_paths(
root,
sorted(
[path for path in files if path.suffix.casefold() in {".xml", ".mdo"}],
key=lambda path: (
0 if any(part.casefold() in {"configuration", "конфигурация"} for part in path.parts) else 1,
str(path).casefold(),
),
),
limit=6,
)
module_files = _preview_relative_paths(root, [path for path in files if path.suffix.casefold() == ".bsl"], limit=6)
layout = _source_layout_summary(root)
return [
{"label": "Главная конфигурация", "items": [layout["main_configuration_root"]]},
{"label": "Папки расширений", "items": layout["extension_roots"] or ["нет"]},
{"label": "Первые файлы объектов", "items": object_files or ["не найдены"]},
{"label": "Первые файлы модулей", "items": module_files or ["не найдены"]},
]
def _compact_objects(normalized: NormalizedProject | None) -> list[dict[str, Any]]:
if normalized is None:
return []
items: list[dict[str, Any]] = []
for group in normalized.configuration.groups:
for obj in group.objects:
items.append(_compact_object_entry(obj, group.name, extension_name=None))
for extension in normalized.configuration.extensions:
for group in extension.groups:
for obj in group.objects:
items.append(_compact_object_entry(obj, group.name, extension_name=extension.name))
return items
def _compact_object_entry(obj: Any, group_name: str, extension_name: str | None) -> dict[str, Any]:
return {
"qualified_name": obj.qualified_name,
"name": obj.name,
"object_kind": obj.object_kind,
"group": group_name,
"extension": extension_name,
"source_path": obj.source_path,
"forms": len(obj.forms),
"commands": len(obj.commands),
"modules": len(obj.modules),
"attributes": len(obj.attributes),
"tabular_sections": len(obj.tabular_sections),
"layouts": len(obj.layouts),
"rights": len(obj.rights),
}
def _compact_modules(normalized: NormalizedProject | None) -> list[dict[str, Any]]:
if normalized is None:
return []
items: list[dict[str, Any]] = []
for group in normalized.configuration.groups:
for obj in group.objects:
for module in obj.modules:
items.append(_compact_module_entry(obj, module, extension_name=None))
for extension in normalized.configuration.extensions:
for group in extension.groups:
for obj in group.objects:
for module in obj.modules:
items.append(_compact_module_entry(obj, module, extension_name=extension.name))
return items
def _compact_module_entry(owner: Any, module: Any, extension_name: str | None) -> dict[str, Any]:
return {
"qualified_name": module.qualified_name or module.name,
"name": module.name,
"module_kind": module.module_kind,
"owner": owner.qualified_name,
"owner_kind": owner.object_kind,
"extension": extension_name,
"source_path": module.source_path,
}
def _project_brief_markdown(manifest: dict[str, Any], compact_objects: list[dict[str, Any]], compact_modules: list[dict[str, Any]]) -> str:
layout = manifest.get("source_layout") or {}
top_objects = compact_objects[:40]
top_modules = compact_modules[:30]
lines = [
f"# Brief: {manifest['project_id']}",
"",
f"- Структура выгрузки: `{layout.get('kind') or 'unknown'}`",
f"- Основная конфигурация: `{layout.get('main_configuration_root') or 'не определена'}`",
f"- Расширения: {', '.join(layout.get('extension_roots') or []) or 'нет'}",
f"- Объектов в compact-индексе: {len(compact_objects)}",
f"- Модулей в compact-индексе: {len(compact_modules)}",
"",
"## Первые объекты",
]
lines.extend(
f"- `{item['qualified_name']}` [{item['object_kind']}] forms={item['forms']} modules={item['modules']} extension={item.get('extension') or 'main'}"
for item in top_objects
)
lines.extend(["", "## Первые модули"])
lines.extend(
f"- `{item['qualified_name']}` owner=`{item['owner']}` extension={item.get('extension') or 'main'}"
for item in top_modules
)
return "\n".join(lines) + "\n"
def _preview_relative_paths(root: Path, files: list[Path], *, limit: int) -> list[str]:
preview: list[str] = []
for path in files[:limit]:
if root.is_file():
preview.append(path.name)
else:
preview.append(path.relative_to(root).as_posix())
return preview
def _export_plan_markdown(project_id: str, input_path: Path, output_path: Path, binaries: list[dict[str, Any]], parseable: bool) -> str:
lines = [
f"# План выгрузки 1С для {project_id}",
"",
f"- Вход: `{input_path}`",
f"- Выход: `{output_path}`",
]
if parseable:
lines.append("- Найдены файлы метаданных; семантическая обработка выполнена напрямую.")
if binaries:
lines.extend(["", "## Бинарные файлы .cf/.cfe", ""])
for item in binaries:
lines.append(f"- `{item['relative_path']}`")
lines.extend(
[
"",
"Для полной структуры выполните экспорт через 1C Designer/Windows Agent:",
"- `/DumpConfigToFiles <output>/configuration -Format Hierarchical` для основной конфигурации",
"- `/DumpConfigToFiles <output>/extensions/<name> -Format Hierarchical -Extension <name>` для расширений",
"- затем повторно запустите подготовку AI-структуры на папке с выгруженными файлами.",
]
)
return "\n".join(lines) + "\n"
def _write_json(path: Path, payload: Any) -> None:
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
def _write_text(path: Path, payload: str) -> None:
path.write_text(payload, encoding="utf-8")
@@ -0,0 +1,34 @@
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(slots=True)
class FormEditorCommand:
lineage_id: str
name: str
caption: str
handler_name: str
@dataclass(slots=True)
class FormEditorElement:
lineage_id: str
name: str
qualified_name: str
caption: str
control_kind: str
binding: str
width: str = "stretch"
row: int = 0
@dataclass(slots=True)
class FormEditorDraft:
form_lineage_id: str
form_name: str
form_title: str
owner_name: str
layout_kind: str = "auto"
commands: list[FormEditorCommand] = field(default_factory=list)
elements: list[FormEditorElement] = field(default_factory=list)
@@ -0,0 +1,41 @@
from __future__ import annotations
from sir import NodeKind, SirSnapshot
def select_form_semantics(forms: list[object], form_id: str | None) -> object | None:
if form_id:
selected = next(
(
item
for item in forms
if getattr(getattr(item, "form", None), "lineage_id", None) == form_id
or getattr(getattr(item, "form", None), "qualified_name", None) == form_id
or getattr(getattr(item, "form", None), "name", None) == form_id
),
None,
)
if selected is not None:
return selected
return forms[0] if forms else None
def form_module_for_form(snapshot: SirSnapshot, form: object | None):
if form is None:
return None
form_lineage = str(getattr(form, "lineage_id", "") or "")
form_qualified = str(getattr(form, "qualified_name", "") or "")
form_name = str(getattr(form, "name", "") or "")
for node in snapshot.nodes:
if node.kind != NodeKind.MODULE:
continue
attributes = node.attributes or {}
if attributes.get("form_lineage_id") == form_lineage:
return node
if attributes.get("form_qualified_name") == form_qualified:
return node
if attributes.get("module_role") == "FORM_MODULE" and attributes.get("form_name") == form_name:
owner_name = str(attributes.get("owner_qualified_name") or "")
if form_qualified.startswith(owner_name):
return node
return None
@@ -0,0 +1,132 @@
from __future__ import annotations
from api_server.form_editor_models import FormEditorCommand, FormEditorDraft, FormEditorElement
def build_form_editor_draft(
form_semantics: object,
form_module: object | None,
form_data: dict[str, list[str]] | None = None,
) -> FormEditorDraft:
form = getattr(form_semantics, "form", None)
form_name = str(getattr(form, "qualified_name", None) or getattr(form, "name", "Форма"))
module_attrs = getattr(form_module, "attributes", {}) or {}
owner_name = str(module_attrs.get("owner_qualified_name") or _owner_name_from_form(form_name))
draft = FormEditorDraft(
form_lineage_id=str(getattr(form, "lineage_id", "") or ""),
form_name=form_name,
form_title=str(_first_value(form_data, "form_title") or getattr(form, "name", None) or "Форма"),
owner_name=owner_name,
layout_kind=str(_first_value(form_data, "layout_kind") or "auto"),
commands=_commands_from_semantics(form_semantics),
elements=_elements_from_semantics(form_semantics),
)
if form_data:
_apply_form_data(draft, form_data)
return draft
def _commands_from_semantics(form_semantics: object) -> list[FormEditorCommand]:
handlers = getattr(form_semantics, "command_handlers", {}) or {}
commands: list[FormEditorCommand] = []
for command in getattr(form_semantics, "commands", []) or []:
lineage_id = str(getattr(command, "lineage_id", "") or "")
handler = handlers.get(lineage_id)
handler_name = str(getattr(handler, "name", None) or getattr(handler, "qualified_name", None) or "")
name = str(getattr(command, "name", "Команда"))
commands.append(FormEditorCommand(lineage_id=lineage_id, name=name, caption=name, handler_name=handler_name))
return commands
def _elements_from_semantics(form_semantics: object) -> list[FormEditorElement]:
elements: list[FormEditorElement] = []
for index, element in enumerate(getattr(form_semantics, "elements", []) or []):
attributes = getattr(element, "attributes", {}) or {}
name = str(getattr(element, "name", None) or getattr(element, "qualified_name", "Элемент"))
elements.append(
FormEditorElement(
lineage_id=str(getattr(element, "lineage_id", "") or f"element.{index}"),
name=name,
qualified_name=str(getattr(element, "qualified_name", name)),
caption=str(attributes.get("caption") or attributes.get("synonym") or name),
control_kind=_control_kind_for(name, attributes),
binding=str(attributes.get("path") or attributes.get("dataPath") or attributes.get("binding") or name),
width=str(attributes.get("width") or "stretch"),
row=index,
)
)
return elements
def _apply_form_data(draft: FormEditorDraft, form_data: dict[str, list[str]]) -> None:
captions = _values(form_data, "element_caption")
kinds = _values(form_data, "element_kind")
bindings = _values(form_data, "element_binding")
widths = _values(form_data, "element_width")
for index, element in enumerate(draft.elements):
element.caption = _at(captions, index, element.caption)
element.control_kind = _safe_control_kind(_at(kinds, index, element.control_kind))
element.binding = _at(bindings, index, element.binding)
element.width = _safe_width(_at(widths, index, element.width))
command_captions = _values(form_data, "command_caption")
for index, command in enumerate(draft.commands):
command.caption = _at(command_captions, index, command.caption)
new_name = str(_first_value(form_data, "new_element_name") or "").strip()
if new_name:
draft.elements.append(
FormEditorElement(
lineage_id=f"draft.{len(draft.elements) + 1}",
name=new_name,
qualified_name=f"{draft.form_name}.{new_name}",
caption=new_name,
control_kind=_safe_control_kind(_first_value(form_data, "new_element_kind") or "input"),
binding=new_name,
row=len(draft.elements),
)
)
def _control_kind_for(name: str, attributes: dict) -> str:
raw = str(attributes.get("control") or attributes.get("control_kind") or attributes.get("type") or "").casefold()
if "table" in raw or "табли" in raw:
return "table"
if "checkbox" in raw or "boolean" in raw or "булево" in raw or name.casefold().startswith(("это", "пометка")):
return "checkbox"
if "date" in raw or "дата" in raw or "Дата" in name:
return "date"
if "group" in raw or "группа" in raw:
return "group"
return "input"
def _safe_control_kind(value: str) -> str:
return value if value in {"input", "date", "checkbox", "table", "group", "text"} else "input"
def _safe_width(value: str) -> str:
return value if value in {"stretch", "half", "third"} else "stretch"
def _owner_name_from_form(form_name: str) -> str:
parts = form_name.split(".")
if len(parts) >= 2:
return ".".join(parts[:2])
return form_name
def _values(form_data: dict[str, list[str]], key: str) -> list[str]:
return [str(value) for value in form_data.get(key, [])]
def _first_value(form_data: dict[str, list[str]] | None, key: str) -> str | None:
if not form_data:
return None
values = form_data.get(key)
return str(values[0]) if values else None
def _at(values: list[str], index: int, default: str) -> str:
value = values[index].strip() if index < len(values) else ""
return value or default
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,408 @@
from __future__ import annotations
from html import escape
import json
from typing import Iterable
from urllib.parse import quote
from api_server.html5 import _metric, _page, _project_link, _topbar
def render_html5_access_page(
*,
project_id: str,
projects: Iterable[object],
normalized: object | None,
selected_profile: str | None = None,
plan: object | None = None,
dry_run: object | None = None,
error: str | None = None,
) -> str:
project_nav = "\n".join(_project_link(project, project_id) for project in projects)
if error or normalized is None:
content = f"""
<main class="workspace" data-html5-page="access" data-project-id="{escape(project_id)}">
{_topbar(project_id, project_nav)}
<section class="empty-state" data-html5-error>
<h1>Права доступа не загружены</h1>
<p>{escape(error or "NormalizedProject не найден")}</p>
<a class="button" href="/html5/projects/{quote(project_id)}/setup">Открыть setup</a>
</section>
</main>
"""
return _page(f"SFERA Access - {project_id}", content)
access = getattr(normalized, "access", None)
profiles = list(getattr(access, "profiles", []) or [])
groups = list(getattr(access, "groups", []) or [])
users = list(getattr(access, "users", []) or [])
selected = _selected_profile(profiles, selected_profile)
plan_html = render_html5_access_publish_plan(project_id=project_id, profile=selected, plan=plan)
dry_run_html = render_html5_access_publish_result(project_id=project_id, result=dry_run)
content = f"""
<main class="workspace access-workspace" data-html5-page="access" data-project-id="{escape(project_id)}">
{_topbar(project_id, project_nav)}
<section class="access-layout">
<aside class="panel access-nav" data-html5-access-nav>
<div class="panel-title">Профили доступа</div>
<dl class="metrics">
{_metric("Профили", len(profiles))}
{_metric("Группы", len(groups))}
{_metric("Пользователи", len(users))}
{_metric("Назначения", _assignment_count(profiles, groups, users))}
</dl>
<nav>{''.join(_profile_link(project_id, item, selected) for item in profiles) or '<p class="muted padded">Профили не найдены</p>'}</nav>
</aside>
<section class="editor access-main" data-html5-access-main>
<div class="editor-head">
<div>
<p class="eyebrow">1C access model</p>
<h1>{escape(_profile_name(selected) if selected is not None else "Права доступа")}</h1>
</div>
<a class="button" href="/html5/projects/{quote(project_id)}/editor">Редактор</a>
</div>
{render_html5_access_profile(project_id=project_id, profile=selected)}
<div data-html5-access-plan>{plan_html}</div>
<div data-html5-access-result>{dry_run_html}</div>
{render_html5_access_profile_builder(project_id=project_id)}
</section>
<aside class="panel access-side" data-html5-access-side>
<div class="panel-title">Группы доступа</div>
<div class="access-list">{''.join(_group_card(item) for item in groups[:80]) or '<p class="muted padded">Группы не найдены</p>'}</div>
<div class="panel-title">Пользователи</div>
<div data-html5-access-user-detail>{render_html5_access_user_detail(project_id=project_id, user_payload=None)}</div>
<div class="access-list">{''.join(_user_card(project_id, item) for item in users[:80]) or '<p class="muted padded">Пользователи не найдены</p>'}</div>
</aside>
</section>
</main>
"""
return _page(f"SFERA Access - {project_id}", content)
def render_html5_access_profile_builder(*, project_id: str) -> str:
return f"""
<section class="access-builder" data-html5-access-builder>
<div class="panel-title">Новый профиль доступа</div>
<form class="access-builder-form">
<label>
<span>Имя профиля</span>
<input name="name" placeholder="ПрофильHTTP" />
</label>
<label>
<span>Объекты 1С</span>
<textarea name="target_objects" placeholder="HTTPСервис.ПубличныйAPI"></textarea>
</label>
<label>
<span>Права</span>
<input name="permissions" value="read" />
</label>
<label>
<span>Пользователь-источник</span>
<input name="source_user" placeholder="ivanov" />
</label>
<div class="access-builder-actions">
<button
type="submit"
hx-post="/html5/projects/{quote(project_id)}/access/profile-preview"
hx-target="[data-html5-access-builder-result]"
hx-swap="innerHTML"
>Предпросмотр</button>
<button
type="submit"
class="primary"
hx-post="/html5/projects/{quote(project_id)}/access/profiles"
hx-target="[data-html5-access-builder-result]"
hx-swap="innerHTML"
>Сохранить черновик</button>
</div>
</form>
<div data-html5-access-builder-result>
<p class="muted padded">Профиль будет построен сервером по ролям, правам и объектам 1С.</p>
</div>
</section>
"""
def render_html5_access_profile_preview(*, draft: object) -> str:
roles = list(getattr(draft, "roles", []) or [])
missing = list(getattr(draft, "missing_objects", []) or [])
warnings = list(getattr(draft, "warnings", []) or [])
proposed = dict(getattr(draft, "proposed_profile", {}) or {})
return f"""
<section class="access-builder-result" data-html5-access-builder-result-content>
<div class="access-plan-head">
<span class="status-pill">предпросмотр</span>
<strong>{escape(str(proposed.get("qualified_name") or getattr(draft, "name", "")))}</strong>
</div>
<div class="access-role-grid">{''.join(_role_card(role) for role in roles) or '<p class="muted padded">Роли не найдены</p>'}</div>
{_notice_list("Недостающие объекты", missing)}
{_notice_list("Предупреждения", warnings)}
</section>
"""
def render_html5_access_profile_apply_result(*, project_id: str, response: object, plan: object | None = None) -> str:
profile = getattr(response, "profile", {}) or {}
profile_name = str(profile.get("name") or profile.get("qualified_name") or "")
message = str(getattr(response, "message", ""))
return f"""
<section class="access-builder-result" data-html5-access-builder-result-content>
<div class="access-plan-head">
<span class="status-pill">сохранено</span>
<strong>{escape(profile_name)}</strong>
<a class="button" href="/html5/projects/{quote(project_id)}/access?profile={quote(profile_name)}">Открыть</a>
</div>
<p class="object-summary">{escape(message)}</p>
{render_html5_access_publish_plan(project_id=project_id, profile=_DictProfile(profile), plan=plan)}
</section>
"""
def render_html5_access_user_detail(*, project_id: str, user_payload: dict | None) -> str:
if user_payload is None:
return """
<section class="access-user-detail">
<p class="muted padded">Выберите пользователя, чтобы увидеть группы и эффективные роли.</p>
</section>
"""
user = dict(user_payload.get("user") or {})
roles = list(user_payload.get("effective_roles") or [])
groups = list(user.get("groups") or [])
name = str(user.get("name") or "")
full_name = str(user.get("full_name") or "")
return f"""
<section class="access-user-detail" data-html5-access-user="{escape(name)}">
<div class="access-plan-head">
<span class="status-pill">пользователь</span>
<strong>{escape(name)}</strong>
</div>
<p class="object-summary">{escape(full_name or "ФИО не загружено")}</p>
<div class="report-grid">
{_metric("Группы", len(groups))}
{_metric("Эффективные роли", len(roles))}
</div>
{_notice_list("Группы пользователя", groups)}
<div class="access-role-grid">{''.join(_role_card(_DictRole(item)) for item in roles) or '<p class="muted padded">Эффективные роли не найдены</p>'}</div>
</section>
"""
def render_html5_access_profile(*, project_id: str, profile: object | None) -> str:
if profile is None:
return """
<section class="access-empty">
<strong>Выберите профиль доступа</strong>
<span>План публикации и dry-run будут построены сервером по данным нормализованного объекта 1С.</span>
</section>
"""
roles = list(getattr(profile, "roles", []) or [])
attrs = dict(getattr(profile, "attributes", {}) or {})
target_objects = list(attrs.get("target_objects") or [])
permissions = list(attrs.get("permissions") or [])
profile_name = _profile_name(profile)
return f"""
<section class="access-profile" data-html5-access-profile="{escape(profile_name)}">
<div class="source-head">
<div>
<strong>{escape(profile_name)}</strong>
<small>{escape(str(getattr(profile, "qualified_name", "") or ""))}</small>
</div>
<dl>
{_metric("Роли", len(roles))}
{_metric("Объекты", len(target_objects))}
{_metric("Права", len(permissions))}
</dl>
</div>
<div class="access-summary">
<span>{escape(str(getattr(profile, "source", "") or "workspace"))}</span>
<span>{escape(str(attrs.get("status") or "loaded"))}</span>
</div>
<div class="access-role-grid">
{''.join(_role_card(role) for role in roles) or '<p class="muted padded">Роли не назначены</p>'}
</div>
</section>
"""
def render_html5_access_publish_plan(*, project_id: str, profile: object | None, plan: object | None) -> str:
if profile is None:
return '<section class="access-plan"><div class="panel-title">План публикации</div><p class="muted padded">Нет выбранного профиля</p></section>'
profile_name = _profile_name(profile)
if plan is None:
return f"""
<section class="access-plan">
<div class="panel-title">План публикации</div>
<div class="access-actions">
<button
hx-get="/html5/projects/{quote(project_id)}/access/profiles/{quote(profile_name, safe='')}/plan"
hx-target="[data-html5-access-plan]"
hx-swap="innerHTML"
>Построить план</button>
</div>
</section>
"""
operations = list(getattr(plan, "operations", []) or [])
warnings = list(getattr(plan, "warnings", []) or [])
ready = bool(getattr(plan, "ready_for_extension", False))
warning_html = "".join(f"<li>{escape(str(item))}</li>" for item in warnings)
dry_run_button = (
f"""
<form
hx-post="/html5/projects/{quote(project_id)}/access/profiles/{quote(profile_name, safe='')}/publish-dry-run"
hx-target="[data-html5-access-result]"
hx-swap="innerHTML"
>
<button type="submit" class="primary">Dry-run в 1С</button>
</form>
"""
if ready
else '<p class="muted padded">План не готов к отправке в расширение</p>'
)
return f"""
<section class="access-plan">
<div class="panel-title">План публикации</div>
<div class="access-plan-head">
<span class="status-pill">{'готов' if ready else 'требует проверки'}</span>
<strong>{len(operations)} операций</strong>
{dry_run_button}
</div>
<ul class="access-warnings">{warning_html}</ul>
<div class="access-operations">{''.join(_operation_card(item) for item in operations) or '<p class="muted padded">Операций нет</p>'}</div>
</section>
"""
def render_html5_access_publish_result(*, project_id: str, result: object | None) -> str:
if result is None:
return '<section class="access-result"><div class="panel-title">Ответ расширения</div><p class="muted padded">Dry-run еще не выполнялся</p></section>'
checks = list(getattr(result, "checks", []) or [])
payload = dict(getattr(result, "result", {}) or {})
status = str(getattr(result, "status", ""))
ready = bool(getattr(result, "ready", False))
return f"""
<section class="access-result" data-html5-access-result-status="{escape(status)}">
<div class="panel-title">Ответ расширения</div>
<div class="access-plan-head">
<span class="status-pill">{escape(status)}</span>
<strong>{'расширение ответило' if ready else 'требуется настройка публикации'}</strong>
</div>
<div class="access-operations">{''.join(_check_card(item) for item in checks)}</div>
<pre class="access-json">{escape(_short_json(payload))}</pre>
</section>
"""
def _selected_profile(profiles: list[object], selected_profile: str | None) -> object | None:
if not profiles:
return None
if not selected_profile:
return profiles[0]
wanted = selected_profile.casefold()
return next(
(
item
for item in profiles
if _profile_name(item).casefold() == wanted
or str(getattr(item, "qualified_name", "") or "").casefold() == wanted
),
profiles[0],
)
def _profile_link(project_id: str, profile: object, selected: object | None) -> str:
name = _profile_name(profile)
active = selected is profile
roles = list(getattr(profile, "roles", []) or [])
return f"""
<a class="tree-item" data-html5-access-profile-selected="{str(active).lower()}" href="/html5/projects/{quote(project_id)}/access?profile={quote(name)}">
<span>{escape(name)}</span>
<small>{len(roles)} ролей</small>
</a>
"""
def _profile_name(profile: object) -> str:
return str(getattr(profile, "name", None) or getattr(profile, "qualified_name", None) or "Профиль")
def _role_card(role: object) -> str:
name = str(getattr(role, "role_qualified_name", None) or getattr(role, "role", None) or role)
source = str(getattr(role, "source", "") or "")
return f'<article class="access-card"><strong>{escape(name)}</strong><small>{escape(source)}</small></article>'
def _group_card(group: object) -> str:
name = str(getattr(group, "name", ""))
profile = str(getattr(group, "profile_qualified_name", None) or getattr(group, "profile", None) or "без профиля")
users = list(getattr(group, "users", []) or [])
return f'<article class="access-card"><strong>{escape(name)}</strong><small>{escape(profile)} · {len(users)} пользователей</small></article>'
def _user_card(project_id: str, user: object) -> str:
name = str(getattr(user, "name", ""))
full_name = str(getattr(user, "full_name", "") or "")
groups = list(getattr(user, "groups", []) or [])
return f"""
<article
class="access-card"
hx-get="/html5/projects/{quote(project_id)}/access/users/{quote(name, safe='')}"
hx-target="[data-html5-access-user-detail]"
hx-swap="innerHTML"
>
<strong>{escape(name)}</strong>
<small>{escape(full_name)} · {len(groups)} групп</small>
</article>
"""
def _operation_card(operation: dict) -> str:
action = str(operation.get("action", "operation"))
target = str(operation.get("target", ""))
detail = str(operation.get("role") or operation.get("profile") or operation.get("name") or "")
return f'<article class="access-card"><strong>{escape(action)}</strong><small>{escape(target)} {escape(detail)}</small></article>'
def _check_card(check: object) -> str:
code = str(getattr(check, "code", ""))
status = str(getattr(check, "status", ""))
message = str(getattr(check, "message", ""))
return f'<article class="access-card"><strong>{escape(code)} · {escape(status)}</strong><small>{escape(message)}</small></article>'
def _notice_list(title: str, values: list[object]) -> str:
if not values:
return ""
return f"""
<div class="panel-title">{escape(title)}</div>
<ul class="access-warnings">{''.join(f'<li>{escape(str(item))}</li>' for item in values)}</ul>
"""
def _assignment_count(profiles: list[object], groups: list[object], users: list[object]) -> int:
return (
sum(len(getattr(item, "roles", []) or []) for item in profiles)
+ sum(len(getattr(item, "roles", []) or []) + len(getattr(item, "users", []) or []) for item in groups)
+ sum(len(getattr(item, "roles", []) or []) + len(getattr(item, "groups", []) or []) for item in users)
)
def _short_json(payload: dict) -> str:
if not payload:
return "{}"
return json.dumps({key: value for key, value in list(payload.items())[:12]}, ensure_ascii=False, indent=2, default=str)
class _DictProfile:
def __init__(self, payload: dict):
self.name = str(payload.get("name") or "")
self.qualified_name = str(payload.get("qualified_name") or self.name)
self.roles = payload.get("roles") or []
self.attributes = payload.get("attributes") or {}
self.source = payload.get("source") or "workspace"
class _DictRole:
def __init__(self, payload: dict):
self.role = str(payload.get("role") or payload.get("name") or "")
self.role_qualified_name = str(payload.get("role_qualified_name") or payload.get("qualified_name") or self.role)
self.source = str(payload.get("source") or "")
@@ -0,0 +1,144 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable, Iterable
from typing import Any
from fastapi import HTTPException
from api_server.html5_access import (
render_html5_access_page,
render_html5_access_profile_apply_result,
render_html5_access_profile_preview,
render_html5_access_publish_plan,
render_html5_access_publish_result,
render_html5_access_user_detail,
)
from api_server.html5_forms import form_value, html5_csv_values
def html5_access_page(
*,
project_id: str,
profile: str | None,
project_summaries: Callable[[], Iterable[object]],
normalized_project: Callable[[str], object],
access_profile_by_name: Callable[[object, str], object | None],
access_publish_plan: Callable[[object, object], object],
) -> str:
try:
normalized = normalized_project(project_id)
selected = _selected_profile(normalized, profile, access_profile_by_name)
plan = access_publish_plan(normalized, selected) if selected is not None else None
return render_html5_access_page(
project_id=project_id,
projects=project_summaries(),
normalized=normalized,
selected_profile=profile,
plan=plan,
)
except HTTPException as error:
return render_html5_access_page(
project_id=project_id,
projects=project_summaries(),
normalized=None,
error=str(error.detail),
)
def html5_access_publish_plan(
*,
project_id: str,
profile_name: str,
normalized_project: Callable[[str], object],
access_profile_by_name: Callable[[object, str], object | None],
access_publish_plan: Callable[[object, object], object],
) -> str:
normalized = normalized_project(project_id)
profile = access_profile_by_name(normalized, profile_name)
if profile is None:
raise HTTPException(status_code=404, detail="Access profile not found")
return render_html5_access_publish_plan(
project_id=project_id,
profile=profile,
plan=access_publish_plan(normalized, profile),
)
async def html5_access_publish_dry_run(
*,
project_id: str,
profile_name: str,
publish_dry_run: Callable[[str, str], Awaitable[Any]],
) -> str:
result = await publish_dry_run(project_id, profile_name)
return render_html5_access_publish_result(project_id=project_id, result=result)
async def html5_access_profile_preview(
*,
project_id: str,
form: dict[str, list[str]],
preview_profile: Callable[[str, object], Awaitable[Any]],
draft_request: Callable[..., object],
) -> str:
draft = await preview_profile(project_id, _draft_request_from_form(form, draft_request))
return render_html5_access_profile_preview(draft=draft)
async def html5_access_profile_apply(
*,
project_id: str,
form: dict[str, list[str]],
apply_profile: Callable[[str, object], Awaitable[Any]],
apply_request: Callable[..., object],
normalized_project: Callable[[str], object],
access_profile_by_name: Callable[[object, str], object | None],
access_publish_plan: Callable[[object, object], object],
) -> str:
request = _draft_request_from_form(form, apply_request, author="html5")
response = await apply_profile(project_id, request)
profile_name = str(response.profile.get("name") or response.profile.get("qualified_name") or "")
plan = None
if profile_name:
normalized = normalized_project(project_id)
profile = access_profile_by_name(normalized, profile_name)
if profile is not None:
plan = access_publish_plan(normalized, profile)
return render_html5_access_profile_apply_result(project_id=project_id, response=response, plan=plan)
async def html5_access_user_detail(
*,
project_id: str,
user_name: str,
access_user: Callable[[str, str], Awaitable[dict]],
) -> str:
return render_html5_access_user_detail(project_id=project_id, user_payload=await access_user(project_id, user_name))
def _selected_profile(
normalized: object,
profile_name: str | None,
access_profile_by_name: Callable[[object, str], object | None],
) -> object | None:
access = getattr(normalized, "access", None)
profiles = list(getattr(access, "profiles", []) or [])
if not profiles:
return None
if not profile_name:
return profiles[0]
return access_profile_by_name(normalized, profile_name) or profiles[0]
def _draft_request_from_form(form: dict[str, list[str]], request_factory: Callable[..., object], **extra: object) -> object:
name = form_value(form, "name")
if not name:
raise HTTPException(status_code=400, detail="Access profile name is required")
payload = {
"name": name,
"target_objects": html5_csv_values(form_value(form, "target_objects") or ""),
"permissions": html5_csv_values(form_value(form, "permissions") or "read"),
"source_user": form_value(form, "source_user"),
**extra,
}
return request_factory(**payload)
@@ -0,0 +1,389 @@
from __future__ import annotations
from html import escape
from typing import Iterable
from urllib.parse import quote
from api_server.html5 import _page, _project_link, _topbar
def render_html5_ai_structure_page(
*,
project_id: str,
projects: Iterable[object],
result: dict | None = None,
saved_credentials: dict[str, str] | None = None,
agent_info: dict[str, str] | None = None,
) -> str:
project_nav = "\n".join(_project_link(project, project_id) for project in projects)
return _page(
f"SFERA Структура для ИИ - {project_id}",
f"""
<main class="workspace ai-structure-workspace" data-html5-page="ai-structure" data-project-id="{escape(project_id)}">
{_topbar(project_id, project_nav)}
<section class="setup-layout">
<aside class="panel">
<div class="setup-card">
<p class="eyebrow">Подготовка контекста</p>
<h1>Структура для ИИ</h1>
<p class="muted">Сервер подготовит полный пакет SFERA: нормализованную модель, граф SIR, объекты, модули, связи и контекст для генерации кода.</p>
</div>
</aside>
<section class="panel setup-main">
<div class="panel-title">Подготовка структуры</div>
{render_html5_ai_structure_source_hint()}
{render_html5_ai_structure_form(project_id, saved_credentials=saved_credentials)}
<div data-html5-ai-structure-path-check>{render_html5_ai_structure_path_check(None)}</div>
<p class="object-summary">Пути должны быть доступны серверу SFERA/API. Для docker-test используйте папку, смонтированную или доступную внутри контейнера; локальные диски Windows и закрытые SMB-папки без учетных данных сервер не увидит.</p>
<div data-html5-ai-structure-result>{render_html5_ai_structure_result(result)}</div>
</section>
</section>
</main>
""",
)
def render_html5_ai_structure_agent_panel(project_id: str, *, agent_info: dict[str, str] | None = None) -> str:
info = agent_info or {}
agent_id = str(info.get("agent_id") or "")
status = str(info.get("status") or "not_configured")
title = _agent_status_title(status)
advice = _agent_status_advice(status, agent_id)
details = []
if agent_id:
details.append(("Windows Agent", agent_id))
if info.get("last_seen_at"):
details.append(("Последний heartbeat", str(info["last_seen_at"])))
if info.get("host"):
details.append(("Хост", str(info["host"])))
if info.get("version"):
details.append(("Версия", str(info["version"])))
if info.get("network_roots"):
details.append(("Доступные сетевые корни", str(info["network_roots"])))
detail_html = "".join(
f'<article class="access-card"><strong>{escape(label)}</strong><small>{escape(value)}</small></article>'
for label, value in details
) or '<article class="access-card"><strong>Windows Agent</strong><small>Пока не выбран</small></article>'
return f"""
<section class="ai-agent-panel">
<div class="access-plan-head">
<span class="status-pill">{escape(title)}</span>
<strong>Агент для CF/CFE</strong>
</div>
<p class="object-summary">{escape(advice)}</p>
<div class="access-operations">{detail_html}</div>
<p class="muted padded"><a class="button" href="/html5/projects/{quote(project_id)}/setup">Открыть setup проекта</a></p>
</section>
"""
def render_html5_ai_structure_source_hint() -> str:
return """
<section class="ai-structure-hint">
<div class="access-plan-head">
<span class="status-pill">xml/xdt</span>
<strong>Ожидаемый формат выгрузки 1С</strong>
</div>
<div class="ai-structure-hint-grid">
<article class="access-card">
<strong>Основная конфигурация</strong>
<small>Во входной папке должна быть папка <code>Конфигурация</code> с XML/BSL/MDO-файлами выгрузки 1С.</small>
</article>
<article class="access-card">
<strong>Расширения</strong>
<small>Каждое расширение кладите в отдельную соседнюю папку с его именем. SFERA загрузит их как части одного проекта.</small>
</article>
<article class="access-card">
<strong>Без лишнего сырья</strong>
<small>Для Codex будет собран compact-пакет: краткие индексы, brief-контекст, layout проекта и выборочные исходники вместо полного дубля всей выгрузки.</small>
</article>
</div>
<ul class="access-warnings">
<li>Для этой страницы используется только XML-выгрузка 1С. Сервер сам строит NormalizedProject, SIR и пакет для Codex.</li>
<li>Перед запуском можно проверить структуру выгрузки: главную папку, расширения и первые найденные файлы объектов и модулей.</li>
</ul>
</section>
"""
def render_html5_ai_structure_form(project_id: str, *, saved_credentials: dict[str, str] | None = None) -> str:
saved_credentials = saved_credentials or {}
saved_username = str(saved_credentials.get("username") or "")
saved_domain = str(saved_credentials.get("domain") or "")
password_hint = "Пароль сохранен, оставьте пустым чтобы использовать его" if saved_credentials.get("password") else "Пароль SMB"
return f"""
<form
class="ai-structure-form"
hx-post="/html5/projects/{quote(project_id)}/ai-structure/run"
hx-target="[data-html5-ai-structure-result]"
hx-swap="innerHTML"
hx-indicator="[data-ai-structure-progress]"
>
<label class="ai-structure-field ai-structure-field-wide">
<span>Папка с XML-выгрузкой 1С</span>
<input name="input_path" value="\\\\192.168.220.200\\mst\\1c\\MARKA\\CODEX\\CF" />
</label>
<label class="ai-structure-field ai-structure-field-wide">
<span>Папка результата</span>
<input name="output_path" value="\\\\192.168.220.200\\mst\\1c\\MARKA\\CODEX\\CODEX" />
</label>
<label class="ai-structure-field">
<span>Идентификатор проекта</span>
<input name="project_id" value="{escape(project_id)}" />
</label>
<label class="ai-structure-field">
<span>Домен</span>
<input name="smb_domain" value="{escape(saved_domain)}" autocomplete="username" />
</label>
<label class="ai-structure-field">
<span>Логин SMB</span>
<input name="smb_username" value="{escape(saved_username)}" autocomplete="username" />
</label>
<label class="ai-structure-field">
<span>Пароль SMB</span>
<input name="smb_password" type="password" placeholder="{escape(password_hint)}" autocomplete="current-password" />
</label>
<label class="checkbox-row ai-structure-field ai-structure-field-compact">
<input name="save_smb_credentials" type="checkbox" value="1" checked />
<span>Сохранить</span>
</label>
<button
class="button ai-structure-submit"
type="button"
hx-post="/html5/projects/{quote(project_id)}/ai-structure/check-path"
hx-include="closest form"
hx-target="[data-html5-ai-structure-path-check]"
hx-swap="innerHTML"
>Проверить структуру выгрузки</button>
<button class="primary ai-structure-submit" type="submit">Подготовить для ИИ</button>
</form>
<section class="ai-structure-progress" data-ai-structure-progress hidden aria-live="polite">
<div class="ai-progress-head">
<span class="ai-progress-spinner" aria-hidden="true"></span>
<strong>Подготовка выполняется</strong>
<small data-ai-structure-elapsed>00:00</small>
</div>
<div class="ai-progress-bar"><span data-ai-structure-bar></span></div>
<dl class="ai-progress-metrics">
<div><dt>Прошло</dt><dd data-ai-structure-elapsed-label>0 сек</dd></div>
<div><dt>Осталось примерно</dt><dd data-ai-structure-eta>считаем</dd></div>
<div><dt>Стадия</dt><dd data-ai-structure-stage>Запуск запроса</dd></div>
</dl>
<p class="muted padded">Окно не зависло: сервер копирует сетевые файлы, строит normalized/SIR модель и пишет Codex-пакет. Большие XML-выгрузки и SMB-папки могут выполняться несколько минут.</p>
</section>
"""
def render_html5_ai_structure_path_check(result: dict | None) -> str:
if result is None:
return '<p class="muted padded">Сначала можно проверить, что сервер видит XML-выгрузку, папку <code>Конфигурация</code>, расширения и первые найденные файлы.</p>'
status = str(result.get("status") or "info")
title_map = {
"ok": "Структура найдена",
"error": "Структура недоступна",
"info": "Проверка структуры",
}
title = title_map.get(status, "Проверка пути")
message = str(result.get("message") or "")
details = list(result.get("details") or [])
preview_tree = list(result.get("preview_tree") or [])
return f"""
<section class="ai-structure-result" data-html5-ai-structure-status="{escape(status)}">
<div class="access-plan-head">
<span class="status-pill">{escape(title.lower())}</span>
<strong>{escape(title)}</strong>
</div>
<ul class="access-warnings">
<li>{escape(message)}</li>
{''.join(f'<li>{escape(str(item))}</li>' for item in details)}
</ul>
{_render_ai_structure_preview_tree(preview_tree)}
</section>
"""
def render_html5_ai_structure_result(result: dict | None) -> str:
if result is None:
return '<p class="muted padded">Укажите входную и выходную папку. Файлы будут созданы сервером в указанном каталоге.</p>'
diagnostics = list(result.get("diagnostics") or [])
artifacts = list(result.get("artifacts") or [])
snapshot = result.get("snapshot") or {}
normalized = result.get("normalized") or {}
source_layout = result.get("source_layout") or {}
source_preview = list(result.get("source_preview") or [])
status = _status_text(result.get("status"))
return f"""
<section class="ai-structure-result" data-html5-ai-structure-status="{escape(str(result.get('status', '')))}">
<div class="access-plan-head">
<span class="status-pill">{escape(status)}</span>
<strong>{escape(str(result.get("codex_package_folder") or result.get("output_path", "")))}</strong>
</div>
<p class="object-summary">Папка для переноса в Codex: {escape(str(result.get("codex_package_path", "")))}</p>
<dl class="setup-metrics">
<div><dt>Файлы</dt><dd>{escape(str(result.get("files_count", 0)))}</dd></div>
<div><dt>Узлы</dt><dd>{escape(str(snapshot.get("nodes", 0)))}</dd></div>
<div><dt>Связи</dt><dd>{escape(str(snapshot.get("edges", 0)))}</dd></div>
<div><dt>Объекты</dt><dd>{escape(str(normalized.get("objects", 0)))}</dd></div>
</dl>
{render_html5_ai_structure_result_summary(source_layout, normalized)}
{_render_ai_structure_preview_tree(source_preview)}
<div class="panel-title">Артефакты</div>
<div class="access-operations">{''.join(f'<article class="access-card"><strong>{escape(_artifact_text(item))}</strong><small>Файл пакета структуры</small></article>' for item in artifacts)}</div>
{_diagnostics(diagnostics)}
</section>
"""
def render_html5_ai_structure_job(
*,
project_id: str,
job_id: str,
status: str,
source: str,
message: str,
logs: list[object] | None = None,
) -> str:
log_items = list(logs or [])
return f"""
<section
class="ai-structure-result"
data-html5-ai-structure-status="running"
hx-get="/html5/projects/{quote(project_id)}/ai-structure/jobs/{quote(job_id)}"
hx-trigger="every 2s"
hx-swap="outerHTML"
>
<div class="access-plan-head">
<span class="status-pill">выполняется</span>
<strong>{escape(message)}</strong>
</div>
<p class="object-summary">Задача агента: {escape(job_id)}. Источник: {escape(source)}. Текущий статус: {escape(status)}.</p>
<ul class="access-warnings">{''.join(f'<li>{escape(str(item))}</li>' for item in log_items[-8:]) or '<li>Ждем сообщения от Windows Agent.</li>'}</ul>
</section>
"""
def render_html5_ai_structure_error(message: str) -> str:
return f"""
<section class="ai-structure-result" data-html5-ai-structure-status="error">
<div class="access-plan-head">
<span class="status-pill">ошибка</span>
<strong>Подготовка не выполнена</strong>
</div>
<ul class="access-warnings">
<li>{escape(message)}</li>
</ul>
<p class="muted padded">Проверьте, что входная и выходная папки доступны именно серверу SFERA/API. Для этого сценария ожидается XML-выгрузка 1С с папкой <code>Конфигурация</code> и, при необходимости, отдельными папками расширений.</p>
</section>
"""
def render_html5_ai_structure_result_summary(source_layout: dict[str, object], normalized: dict[str, object]) -> str:
layout_kind = str(source_layout.get("kind") or "unknown")
main_root = str(source_layout.get("main_configuration_root") or "не определена")
extension_roots = [str(item) for item in list(source_layout.get("extension_roots") or []) if str(item).strip()]
extensions_count = escape(str(normalized.get("extensions", len(extension_roots) or 0)))
return f"""
<div class="panel-title">Структура проекта для Codex</div>
<div class="ai-structure-hint-grid">
<article class="access-card">
<strong>Тип раскладки</strong>
<small>{escape(_layout_kind_text(layout_kind))}</small>
</article>
<article class="access-card">
<strong>Главная папка</strong>
<small>{escape(main_root)}</small>
</article>
<article class="access-card">
<strong>Расширений</strong>
<small>{extensions_count}</small>
</article>
<article class="access-card">
<strong>Первый вход для Codex</strong>
<small><code>context/project-brief.md</code>, <code>indexes/project-layout.json</code>, <code>indexes/objects-compact.json</code>, <code>indexes/modules-compact.json</code></small>
</article>
<article class="access-card">
<strong>Исходники</strong>
<small>В <code>source/</code> попадает выборочная UTF-8-копия BSL/MDO и ключевых XML, а не полный дубль выгрузки.</small>
</article>
<article class="access-card">
<strong>Папки расширений</strong>
<small>{escape(", ".join(extension_roots) or "нет")}</small>
</article>
</div>
"""
def _diagnostics(items: list[object]) -> str:
if not items:
return ""
return f"""
<div class="panel-title">Диагностика</div>
<ul class="access-warnings">{''.join(f'<li>{escape(str(item))}</li>' for item in items)}</ul>
"""
def _render_ai_structure_preview_tree(items: list[object]) -> str:
if not items:
return ""
cards: list[str] = []
for item in items:
if not isinstance(item, dict):
continue
label = str(item.get("label") or "")
values = [str(value) for value in list(item.get("items") or []) if str(value).strip()]
body = "".join(f"<li>{escape(value)}</li>" for value in values) or "<li>нет</li>"
cards.append(
f"""
<article class="access-card ai-structure-tree-card">
<strong>{escape(label)}</strong>
<ul class="ai-structure-tree-list">{body}</ul>
</article>
"""
)
if not cards:
return ""
return f"""
<div class="panel-title">Предпросмотр структуры</div>
<div class="ai-structure-hint-grid ai-structure-tree-grid">
{''.join(cards)}
</div>
"""
def _status_text(value: object) -> str:
mapping = {
"ready": "готово",
"export_required": "нужна выгрузка",
"error": "ошибка",
}
return mapping.get(str(value or ""), str(value or ""))
def _artifact_text(value: object) -> str:
mapping = {
"manifest.json": "Описание результата",
"source_inventory.json": "Список исходных файлов",
"ai_context.md": "Контекст для ИИ",
"export_plan.md": "План выгрузки",
"codex_package": "Папка для Codex",
"project_layout.json": "Карта раскладки проекта",
"source_preview.json": "Краткий предпросмотр структуры",
"compact_objects.json": "Компактный индекс объектов",
"compact_modules.json": "Компактный индекс модулей",
"sir_snapshot.json": "Снимок графа SIR",
"ai_objects.json": "Индекс объектов",
"ai_modules.json": "Индекс модулей",
"ai_edges.json": "Индекс связей",
"normalized_project.json": "Нормализованный проект",
}
return mapping.get(str(value or ""), str(value or ""))
def _layout_kind_text(value: str) -> str:
mapping = {
"configuration_with_extensions": "Конфигурация + отдельные папки расширений",
"flat_or_mixed": "Плоская или смешанная выгрузка",
"file": "Отдельный входной файл",
"unknown": "Не определено",
}
return mapping.get(value, value)
@@ -0,0 +1,533 @@
from __future__ import annotations
from collections.abc import Callable, Iterable
from pathlib import Path
import shutil
from typing import Any
from uuid import uuid4
from fastapi import HTTPException
from api_server.html5_ai_structure import (
render_html5_ai_structure_error,
render_html5_ai_structure_job,
render_html5_ai_structure_path_check,
render_html5_ai_structure_page,
render_html5_ai_structure_result,
)
from api_server.html5_forms import form_value
from api_server.smb_paths import copy_local_tree_to_smb, copy_smb_tree_to_local, is_unc_path, remove_tree
SmbCredentials = dict[str, str]
def html5_ai_structure_page(
*,
project_id: str,
project_summaries: Callable[[], Iterable[object]],
load_credentials: Callable[[str], SmbCredentials | None] | None = None,
load_agent_info: Callable[[str], dict[str, str]] | None = None,
) -> str:
return render_html5_ai_structure_page(
project_id=project_id,
projects=project_summaries(),
saved_credentials=load_credentials(project_id) if load_credentials else None,
agent_info=load_agent_info(project_id) if load_agent_info else None,
)
async def html5_ai_structure_run(
*,
project_id: str,
form: dict[str, list[str]],
prepare: Callable[..., dict[str, Any]],
work_root: Path,
start_binary_job: Callable[..., Any] | None = None,
stage_binary_input: Callable[..., Any] | None = None,
save_run_state: Callable[[str, dict[str, Any]], None] | None = None,
load_credentials: Callable[[str], SmbCredentials | None] | None = None,
save_credentials: Callable[[str, SmbCredentials], None] | None = None,
) -> str:
effective_project_id = form_value(form, "project_id") or project_id
input_path = form_value(form, "input_path")
output_path = form_value(form, "output_path")
if not input_path:
return render_html5_ai_structure_error("Заполните входную папку с XML-выгрузкой 1С.")
if not output_path:
return render_html5_ai_structure_error("Заполните папку результата.")
saved = load_credentials(project_id) if load_credentials else None
username = form_value(form, "smb_username") or (saved or {}).get("username", "")
password = form_value(form, "smb_password") or (saved or {}).get("password", "")
domain = form_value(form, "smb_domain") or (saved or {}).get("domain", "")
should_save = bool(form_value(form, "save_smb_credentials"))
uses_smb = is_unc_path(input_path) or is_unc_path(output_path)
if uses_smb and (not username or not password):
return render_html5_ai_structure_error("Для сетевого UNC-пути укажите логин и пароль SMB.")
if should_save and save_credentials and username and password:
save_credentials(project_id, {"username": username, "password": password, "domain": domain})
work_dir = work_root / f"{effective_project_id}-{uuid4().hex}"
try:
work_dir.mkdir(parents=True, exist_ok=True)
direct_binary_match = _normalize_binary_match(_detect_binary_input(input_path))
direct_binary_file = input_path.strip().casefold().endswith((".cf", ".cfe"))
local_input = work_dir / "input" if is_unc_path(input_path) and not direct_binary_file else Path(input_path)
local_output = work_dir / "output" if is_unc_path(output_path) else Path(output_path)
if is_unc_path(input_path) and not direct_binary_file:
copy_smb_tree_to_local(
source=input_path,
target=local_input,
username=username,
password=password,
domain=domain or None,
)
binary_match = _detect_binary_tree(local_input) or direct_binary_match
if binary_match is not None:
if start_binary_job is None or save_run_state is None:
return render_html5_ai_structure_error("Сервис подготовки CF/CFE через Windows Agent не подключен.")
binary_input_path = input_path
if is_unc_path(input_path) and local_input.exists() and stage_binary_input is not None:
try:
binary_input_path = await stage_binary_input(
project_id=project_id,
effective_project_id=effective_project_id,
local_input=local_input,
username=username,
password=password,
domain=domain or None,
)
except HTTPException as error:
return render_html5_ai_structure_error(str(error.detail))
except RuntimeError as error:
return render_html5_ai_structure_error(str(error))
try:
job = await start_binary_job(
project_id=project_id,
effective_project_id=effective_project_id,
input_path=binary_input_path,
detected_binary_relative_path=binary_match.get("relative_path"),
detected_binary_relative_paths=binary_match.get("binary_relative_paths"),
)
except HTTPException as error:
return render_html5_ai_structure_error(str(error.detail))
save_run_state(
job.job_id,
{
**(getattr(job, "state", {}) or {}),
"project_id": project_id,
"effective_project_id": effective_project_id,
"input_path": input_path,
"output_path": output_path,
"username": username,
"password": password,
"domain": domain,
"display_input_path": input_path,
"display_output_path": output_path,
},
)
return render_html5_ai_structure_job(
project_id=project_id,
job_id=job.job_id,
status=_enum_text(job.status),
source=_enum_text(job.source),
message="Разбор CF/CFE запущен через Windows Agent",
logs=getattr(job, "logs", []),
)
result = prepare(
project_id=effective_project_id,
input_path=local_input,
output_path=local_output,
display_input_path=input_path,
display_output_path=output_path,
)
if is_unc_path(output_path):
copy_local_tree_to_smb(
source=local_output,
target=output_path,
username=username,
password=password,
domain=domain or None,
)
except FileNotFoundError as error:
return render_html5_ai_structure_error(str(error))
except PermissionError as error:
return render_html5_ai_structure_error(f"Нет доступа к папке: {error}")
except OSError as error:
return render_html5_ai_structure_error(f"Ошибка файловой системы: {error}")
except RuntimeError as error:
return render_html5_ai_structure_error(str(error))
finally:
if work_dir.exists():
remove_tree(work_dir, expected_parent=work_root)
return render_html5_ai_structure_result(result)
async def html5_ai_structure_check_path(
*,
project_id: str,
form: dict[str, list[str]],
check_path: Callable[..., dict[str, Any]],
work_root: Path,
load_credentials: Callable[[str], SmbCredentials | None] | None = None,
) -> str:
input_path = form_value(form, "input_path")
if not input_path:
return render_html5_ai_structure_path_check({"status": "error", "message": "Сначала укажите входной путь."})
saved = load_credentials(project_id) if load_credentials else None
username = form_value(form, "smb_username") or (saved or {}).get("username", "")
password = form_value(form, "smb_password") or (saved or {}).get("password", "")
domain = form_value(form, "smb_domain") or (saved or {}).get("domain", "")
if _detect_binary_input(input_path):
return render_html5_ai_structure_path_check(
{
"status": "error",
"message": "Для этой страницы поддерживается только XML-выгрузка 1С. Бинарные .cf/.cfe здесь не используются.",
"details": ["Подготовьте папку с `Конфигурация` и, при необходимости, с отдельными папками расширений."],
}
)
try:
result = _inspect_ai_structure_input(
raw_input_path=input_path,
work_root=work_root,
username=username,
password=password,
domain=domain,
)
except FileNotFoundError as error:
result = {"status": "error", "message": str(error)}
except PermissionError as error:
result = {"status": "error", "message": f"Нет доступа к папке: {error}"}
except OSError as error:
result = {"status": "error", "message": f"Ошибка файловой системы: {error}"}
except RuntimeError as error:
result = {"status": "error", "message": str(error)}
return render_html5_ai_structure_path_check(result)
async def html5_ai_structure_job(
*,
project_id: str,
job_id: str,
prepare: Callable[..., dict[str, Any]],
work_root: Path,
load_run_state: Callable[[str], dict[str, Any] | None],
save_run_state: Callable[[str, dict[str, Any]], None],
load_job: Callable[[str], object | None],
current_project_source_root: Callable[[str], Path | None],
advance_binary_run: Callable[[str, dict[str, Any]], Any] | None = None,
) -> str:
state = load_run_state(job_id)
if state is None:
return render_html5_ai_structure_error("Состояние подготовки для этой задачи не найдено. Запустите обработку заново.")
if state.get("result") is not None:
return render_html5_ai_structure_result(dict(state["result"]))
if state.get("agent_sequence"):
if advance_binary_run is None:
return render_html5_ai_structure_error("Сервис последовательной выгрузки CF/CFE не подключен.")
step_result = await advance_binary_run(job_id, dict(state))
if step_result.get("phase") == "error":
return render_html5_ai_structure_error(str(step_result.get("error") or "Windows Agent завершил задачу с ошибкой."))
if step_result.get("phase") == "running":
updated_state = dict(step_result.get("state") or state)
save_run_state(job_id, updated_state)
return render_html5_ai_structure_job(
project_id=project_id,
job_id=job_id,
status=str(step_result.get("status") or "RUNNING"),
source=str(step_result.get("source") or ""),
message=str(step_result.get("message") or "Windows Agent выгружает структуру"),
logs=list(step_result.get("logs") or []),
)
if step_result.get("phase") != "completed":
return render_html5_ai_structure_error("Не удалось определить состояние последовательной выгрузки CF/CFE.")
state = dict(step_result.get("state") or state)
save_run_state(job_id, state)
source_roots = [Path(path) for path in list(step_result.get("source_roots") or []) if str(path).strip()]
else:
job = load_job(job_id)
if job is None or str(getattr(job, "project_id", "")) != project_id:
return render_html5_ai_structure_error(f"Задача агента не найдена: {job_id}")
status = _enum_text(getattr(job, "status", "UNKNOWN"))
source = _enum_text(getattr(job, "source", ""))
logs = list(getattr(job, "logs", []) or [])
if status in {"QUEUED", "RUNNING"}:
return render_html5_ai_structure_job(
project_id=project_id,
job_id=job_id,
status=status,
source=source,
message="Windows Agent выгружает структуру и передает ее на сервер",
logs=logs,
)
if status != "SUCCEEDED":
error = str(getattr(job, "error", "") or "Windows Agent завершил задачу с ошибкой.")
if logs:
error = f"{error} Последние сообщения: {' | '.join(str(item) for item in logs[-4:])}"
return render_html5_ai_structure_error(error)
source_root = current_project_source_root(str(state.get("effective_project_id") or project_id))
if source_root is None:
import_summary = getattr(job, "import_summary", None) or {}
source_path = str(import_summary.get("source_path") or "")
source_root = Path(source_path) if source_path else None
if source_root is None or not source_root.exists():
return render_html5_ai_structure_error("После выгрузки агентом сервер не нашел папку с XML/BSL-структурой для подготовки пакета.")
source_roots = [source_root]
output_path = str(state.get("output_path") or "")
username = str(state.get("username") or "")
password = str(state.get("password") or "")
domain = str(state.get("domain") or "")
display_input_path = str(state.get("display_input_path") or source_root)
display_output_path = str(state.get("display_output_path") or output_path)
work_dir = work_root / f"{state.get('effective_project_id') or project_id}-{uuid4().hex}"
try:
work_dir.mkdir(parents=True, exist_ok=True)
local_output = work_dir / "output" if is_unc_path(output_path) else Path(output_path)
source_root = _compose_ai_structure_source_root(work_dir / "source", source_roots)
result = prepare(
project_id=str(state.get("effective_project_id") or project_id),
input_path=source_root,
output_path=local_output,
display_input_path=display_input_path,
display_output_path=display_output_path,
)
if is_unc_path(output_path):
copy_local_tree_to_smb(
source=local_output,
target=output_path,
username=username,
password=password,
domain=domain or None,
)
except FileNotFoundError as error:
return render_html5_ai_structure_error(str(error))
except PermissionError as error:
return render_html5_ai_structure_error(f"Нет доступа к папке: {error}")
except OSError as error:
return render_html5_ai_structure_error(f"Ошибка файловой системы: {error}")
except RuntimeError as error:
return render_html5_ai_structure_error(str(error))
finally:
if work_dir.exists():
remove_tree(work_dir, expected_parent=work_root)
state["result"] = result
save_run_state(job_id, state)
return render_html5_ai_structure_result(result)
def _detect_binary_input(raw_input_path: str) -> str | None:
lowered = raw_input_path.strip().casefold()
if lowered.endswith(".cf"):
return ".cf"
if lowered.endswith(".cfe"):
return ".cfe"
input_path = Path(raw_input_path)
suffixes = {".cf", ".cfe"}
if input_path.is_file() and input_path.suffix.casefold() in suffixes:
return input_path.suffix.casefold()
if not input_path.exists() or not input_path.is_dir():
return None
binary_files = sorted(path for path in input_path.rglob("*") if path.is_file() and path.suffix.casefold() in suffixes)
parseable_files = any(path.suffix.casefold() in {".xml", ".mdo", ".bsl"} for path in input_path.rglob("*") if path.is_file())
if parseable_files or not binary_files:
return None
return binary_files[0].suffix.casefold()
def _detect_binary_tree(input_path: Path) -> dict[str, str] | None:
if not input_path.exists():
return None
suffixes = {".cf", ".cfe"}
if input_path.is_file() and input_path.suffix.casefold() in suffixes:
return {"suffix": input_path.suffix.casefold(), "relative_path": input_path.name}
if not input_path.is_dir():
return None
files = sorted(path for path in input_path.rglob("*") if path.is_file())
parseable_files = any(path.suffix.casefold() in {".xml", ".mdo", ".bsl"} for path in files)
binary_files = [path for path in files if path.suffix.casefold() in suffixes]
if parseable_files or not binary_files:
return None
first = binary_files[0]
return {
"suffix": first.suffix.casefold(),
"relative_path": first.relative_to(input_path).as_posix(),
"binary_relative_paths": [path.relative_to(input_path).as_posix() for path in binary_files],
}
def _normalize_binary_match(value: str | dict[str, str] | None) -> dict[str, str] | None:
if value is None:
return None
if isinstance(value, dict):
return value
return {"suffix": value, "relative_path": "", "binary_relative_paths": []}
def _compose_ai_structure_source_root(target_root: Path, source_roots: list[Path]) -> Path:
existing_roots = [path for path in source_roots if path.exists()]
if not existing_roots:
raise FileNotFoundError("После выгрузки агентом сервер не нашел папки с XML/BSL-структурой для подготовки пакета.")
if len(existing_roots) == 1:
return existing_roots[0]
target_root.mkdir(parents=True, exist_ok=True)
base_root = next((path for path in existing_roots if path.name.casefold().endswith(".cf") is False and (path / "src").exists()), existing_roots[0])
if base_root.exists():
shutil.copytree(base_root, target_root, dirs_exist_ok=True)
used_names: set[str] = set()
for root in existing_roots:
if root == base_root:
continue
name = root.name or f"extension-{len(used_names) + 1}"
candidate = name
index = 2
while candidate in used_names or (target_root / candidate).exists():
candidate = f"{name}-{index}"
index += 1
used_names.add(candidate)
shutil.copytree(root, target_root / candidate, dirs_exist_ok=True)
return target_root
def _enum_text(value: object) -> str:
return str(getattr(value, "value", value or ""))
def _inspect_ai_structure_input(
*,
raw_input_path: str,
work_root: Path,
username: str,
password: str,
domain: str,
) -> dict[str, Any]:
input_path = str(raw_input_path or "").strip()
temp_root = work_root / f"inspect-{uuid4().hex}"
local_root = Path(input_path)
copied_from_unc = False
try:
if is_unc_path(input_path):
if not username or not password:
return {
"status": "error",
"message": "Для проверки сетевой XML-выгрузки укажите логин и пароль SMB.",
}
temp_root.mkdir(parents=True, exist_ok=True)
local_root = temp_root / "input"
copy_smb_tree_to_local(
source=input_path,
target=local_root,
username=username,
password=password,
domain=domain or None,
)
copied_from_unc = True
if not local_root.exists():
raise FileNotFoundError(f"Входная папка не найдена: {input_path}")
files = [local_root] if local_root.is_file() else sorted(path for path in local_root.rglob("*") if path.is_file())
parseable = [path for path in files if path.suffix.casefold() in {".xml", ".mdo", ".bsl"}]
binaries = [path for path in files if path.suffix.casefold() in {".cf", ".cfe"}]
layout = _inspect_source_layout(local_root)
xml_count = sum(1 for path in files if path.suffix.casefold() == ".xml")
mdo_count = sum(1 for path in files if path.suffix.casefold() == ".mdo")
bsl_count = sum(1 for path in files if path.suffix.casefold() == ".bsl")
object_files = _preview_relative_paths(
local_root,
sorted(
[path for path in files if path.suffix.casefold() in {".mdo", ".xml"}],
key=lambda path: (
0 if any(part.casefold() in {"configuration", "конфигурация"} for part in path.parts) else 1,
str(path).casefold(),
),
),
limit=5,
)
module_files = _preview_relative_paths(local_root, [path for path in files if path.suffix.casefold() == ".bsl"], limit=5)
details = [
f"Тип раскладки: {_layout_kind_text(layout['kind'])}",
f"Главная папка: {layout['main_configuration_root']}",
f"Папки расширений: {', '.join(layout['extension_roots']) or 'нет'}",
f"Файлов XML: {xml_count}",
f"Файлов MDO: {mdo_count}",
f"Файлов BSL: {bsl_count}",
]
if object_files:
details.append(f"Первые файлы объектов: {', '.join(object_files)}")
if module_files:
details.append(f"Первые файлы модулей: {', '.join(module_files)}")
if copied_from_unc:
details.append("Проверка выполнена сервером после чтения UNC-пути по SMB.")
if parseable:
warnings: list[str] = []
if layout["kind"] == "flat_or_mixed":
warnings.append("Папка `Конфигурация` не найдена. Сервер все равно попытается собрать проект по имеющимся XML/MDO/BSL.")
if binaries:
warnings.append("Во входной папке есть и бинарные .cf/.cfe, и XML-выгрузка. Для server-side подготовки будут использованы XML/MDO/BSL-файлы.")
preview_tree = [
{"label": "Главная конфигурация", "items": [layout["main_configuration_root"]]},
{"label": "Папки расширений", "items": layout["extension_roots"] or ["нет"]},
{"label": "Первые файлы объектов", "items": object_files or ["не найдены"]},
{"label": "Первые файлы модулей", "items": module_files or ["не найдены"]},
]
return {
"status": "ok",
"message": "Сервер видит выгрузку 1С и может готовить пакет для Codex без Windows Agent.",
"details": details + warnings,
"preview_tree": preview_tree,
}
if binaries:
return {
"status": "info",
"message": "Во входной папке найдены только бинарные .cf/.cfe. Для них потребуется Windows Agent или runtime 1С.",
"details": details + [f"Бинарных файлов: {len(binaries)}"],
}
return {
"status": "error",
"message": "Во входной папке не найдены XML/MDO/BSL-файлы выгрузки 1С.",
"details": details,
}
finally:
if copied_from_unc and temp_root.exists():
remove_tree(temp_root, expected_parent=work_root)
def _inspect_source_layout(root: Path) -> dict[str, Any]:
if root.is_file():
return {"kind": "file", "main_configuration_root": root.name, "extension_roots": []}
children = [path for path in sorted(root.iterdir()) if path.is_dir()]
config_dir = next((path for path in children if path.name.casefold() in {"configuration", "конфигурация"}), None)
extension_roots = [
path.name
for path in children
if path != config_dir and any(item.suffix.casefold() in {".xml", ".mdo", ".bsl"} for item in path.rglob("*") if item.is_file())
]
kind = "configuration_with_extensions" if config_dir else "flat_or_mixed"
return {
"kind": kind,
"main_configuration_root": config_dir.name if config_dir else root.name,
"extension_roots": extension_roots,
}
def _layout_kind_text(value: str) -> str:
mapping = {
"configuration_with_extensions": "Конфигурация + отдельные папки расширений",
"flat_or_mixed": "Плоская или смешанная выгрузка",
"file": "Отдельный входной файл",
}
return mapping.get(value, value)
def _preview_relative_paths(root: Path, files: list[Path], *, limit: int) -> list[str]:
preview: list[str] = []
for path in files[:limit]:
if root.is_file():
preview.append(path.name)
else:
preview.append(path.relative_to(root).as_posix())
return preview
@@ -0,0 +1,596 @@
from __future__ import annotations
from collections import Counter
from html import escape
from typing import Iterable
from urllib.parse import quote
def render_html5_authoring_changes(project_id: str, changes: Iterable[object] | None) -> str:
if changes is None:
return f"""
<div
class="authoring-panel"
data-html5-authoring-changes
hx-get="/html5/projects/{quote(project_id)}/authoring/changes"
hx-trigger="load"
sse-swap="authoring-changes"
hx-swap="outerHTML"
>
<div class="panel-title">Authoring</div>
<p class="muted padded">Сервер загружает историю рабочих изменений.</p>
</div>
"""
change_list = list(changes)
if not change_list:
body = '<p class="muted padded">Изменений пока нет</p>'
else:
body = "".join(_authoring_change_item(change) for change in change_list[:12])
return f"""
<div
class="authoring-panel"
data-html5-authoring-changes
hx-get="/html5/projects/{quote(project_id)}/authoring/changes"
sse-swap="authoring-changes"
hx-swap="outerHTML"
>
<div class="panel-title">Authoring · {len(change_list)}</div>
{_authoring_changes_summary(change_list)}
{_authoring_recent_change(change_list)}
<div class="review-list">{body}</div>
{render_html5_authoring_change_detail(project_id, None)}
</div>
"""
def render_html5_authoring_preview(project_id: str, preview: object | None, error: str | None = None) -> str:
if preview is None and error is None:
return f"""
<div class="authoring-preview" data-html5-authoring-preview>
<div class="panel-title">Authoring preview</div>
<form
class="authoring-preview-form"
data-html5-authoring-preview-form
method="post"
action="/html5/projects/{quote(project_id)}/authoring/completion-preview"
hx-post="/html5/projects/{quote(project_id)}/authoring/completion-preview"
hx-target="[data-html5-authoring-preview-result]"
hx-swap="outerHTML"
>
<input name="object_name" placeholder="object_name" />
<input name="routine_name" placeholder="routine_name" />
<input name="cursor_line" placeholder="line" />
<input name="intent" value="fill-check" />
<input name="user_id" placeholder="user_id" />
<textarea name="source_text" placeholder="BSL source"></textarea>
<button type="submit">Preview</button>
</form>
{render_html5_authoring_preview_result(project_id)}
<form
class="authoring-preview-form"
data-html5-authoring-diff-form
method="post"
action="/html5/projects/{quote(project_id)}/authoring/semantic-diff-preview"
hx-post="/html5/projects/{quote(project_id)}/authoring/semantic-diff-preview"
hx-target="[data-html5-authoring-diff-result]"
hx-swap="outerHTML"
>
<input name="routine_name" placeholder="routine_name" />
<input name="source_path" placeholder="source_path" />
<input name="task_id" placeholder="task_id" />
<input name="session_id" placeholder="session_id" />
<input name="user_id" placeholder="user_id" />
<textarea name="original_text" placeholder="Original BSL"></textarea>
<textarea name="proposed_text" placeholder="Proposed BSL"></textarea>
<button type="submit">Diff preview</button>
</form>
{render_html5_authoring_diff_result(project_id)}
</div>
"""
return render_html5_authoring_preview_result(project_id, preview, error)
def render_html5_authoring_preview_result(project_id: str, preview: object | None = None, error: str | None = None) -> str:
if preview is None and error is None:
return '<div class="authoring-preview-result" data-html5-authoring-preview-result></div>'
if error:
return f"""
<div class="authoring-preview-result" data-html5-authoring-preview-result>
<div class="panel-title">Preview result</div>
<p class="muted padded">{escape(error)}</p>
</div>
"""
allowed = bool(getattr(preview, "allowed", False))
insert_text = str(getattr(preview, "insert_text", ""))
checks = getattr(preview, "checks", []) or []
diff = getattr(preview, "semantic_diff", []) or []
context = getattr(preview, "context", None)
object_node = getattr(context, "object", None)
routine_node = getattr(context, "routine", None)
object_name = getattr(object_node, "qualified_name", None) or getattr(object_node, "name", None) or "object unavailable"
routine_name = getattr(routine_node, "name", None) or "routine unavailable"
check_rows = "".join(_authoring_check_item(check) for check in checks[:8])
diff_rows = "".join(_authoring_diff_item(line) for line in diff[:8]) or '<p class="muted padded">Diff пустой</p>'
return f"""
<div class="authoring-preview-result" data-html5-authoring-preview-result data-html5-project-id="{escape(project_id)}">
<div class="panel-title">Preview result · {'allowed' if allowed else 'blocked'}</div>
<article class="authoring-change">
<strong>{escape(str(object_name))}</strong>
<span>{escape(str(routine_name))}</span>
<small>{escape(insert_text[:180] or "insert text unavailable")}</small>
</article>
{_authoring_result_summary("allowed" if allowed else "blocked", diff, checks)}
<div class="check-list">{check_rows}</div>
<div class="diff-list">{diff_rows}</div>
</div>
"""
def render_html5_authoring_diff_result(
project_id: str,
preview: object | None = None,
error: str | None = None,
request_payload: dict | None = None,
) -> str:
if preview is None and error is None:
return '<div class="authoring-diff-result" data-html5-authoring-diff-result></div>'
if error:
return f"""
<div class="authoring-diff-result" data-html5-authoring-diff-result>
<div class="panel-title">Diff preview</div>
<p class="muted padded">{escape(error)}</p>
</div>
"""
changed = bool(getattr(preview, "changed", False))
added = getattr(preview, "added_lines", 0)
removed = getattr(preview, "removed_lines", 0)
target = getattr(preview, "target", None)
target_name = getattr(target, "qualified_name", None) or getattr(target, "name", None) or "target unavailable"
checks = getattr(preview, "checks", []) or []
diff = getattr(preview, "semantic_diff", []) or []
version_preview = getattr(preview, "version_preview", None)
next_version_id = str(getattr(version_preview, "next_version_id", ""))
check_rows = "".join(_authoring_check_item(check) for check in checks[:8])
diff_rows = "".join(_authoring_diff_item(line) for line in diff[:12]) or '<p class="muted padded">Diff пустой</p>'
apply_form = (
_authoring_apply_change_set_form(project_id, request_payload or {}, next_version_id)
if changed and next_version_id
else ""
)
return f"""
<div class="authoring-diff-result" data-html5-authoring-diff-result data-html5-project-id="{escape(project_id)}">
<div class="panel-title">Diff preview · {'changed' if changed else 'unchanged'}</div>
<article class="authoring-change">
<strong>{escape(str(target_name))}</strong>
<span>+{escape(str(added))} / -{escape(str(removed))}</span>
<small>{escape(next_version_id or "version preview unavailable")}</small>
</article>
{_authoring_result_summary("changed" if changed else "unchanged", diff, checks)}
<div class="check-list">{check_rows}</div>
<div class="diff-list">{diff_rows}</div>
{apply_form}
</div>
"""
def render_html5_authoring_apply_result(project_id: str, result: object | None = None, error: str | None = None) -> str:
if result is None and error is None:
return '<div class="authoring-apply-result" data-html5-authoring-apply-result></div>'
if error:
return f"""
<div class="authoring-apply-result" data-html5-authoring-apply-result>
<div class="panel-title">Apply change-set</div>
<p class="muted padded">{escape(error)}</p>
</div>
"""
status = str(getattr(result, "status", "UNKNOWN"))
change_id = str(getattr(result, "change_id", ""))
version = getattr(result, "version", None)
version_id = str(getattr(version, "version_id", ""))
return f"""
<div
class="authoring-apply-result"
data-html5-authoring-apply-result
data-html5-authoring-change="{escape(change_id)}"
data-html5-version-id="{escape(version_id)}"
>
<div class="panel-title">Apply change-set</div>
<article class="authoring-change">
<strong>{escape(status)}</strong>
<span>{escape(change_id)}</span>
<small>{escape(version_id)}</small>
</article>
{_authoring_apply_summary("change-set", status, change_id, version_id)}
<p class="muted padded">Change-set применен в workspace для проекта {escape(project_id)}.</p>
</div>
"""
def render_html5_metadata_authoring(project_id: str) -> str:
return f"""
<div class="authoring-preview" data-html5-metadata-authoring>
<div class="panel-title">Metadata draft</div>
<form
class="authoring-preview-form"
data-html5-metadata-preview-form
method="post"
action="/html5/projects/{quote(project_id)}/authoring/metadata-object-preview"
hx-post="/html5/projects/{quote(project_id)}/authoring/metadata-object-preview"
hx-target="[data-html5-metadata-preview-result]"
hx-swap="outerHTML"
>
<input name="object_kind" value="DOCUMENT" />
<input name="name" placeholder="Имя объекта" required />
<input name="synonym" placeholder="Синоним" />
<input name="attributes" placeholder="Реквизиты: Имя:Тип, ..." />
<input name="tabular_sections" placeholder="ТЧ: Товары[Номенклатура:Строка;Количество:Число]" />
<input name="forms" placeholder="Формы через запятую" />
<input name="commands" placeholder="Команды: Имя:Обработчик" />
<input name="task_id" placeholder="task_id" />
<input name="session_id" placeholder="session_id" />
<input name="user_id" placeholder="user_id" />
<button type="submit">Metadata preview</button>
</form>
{render_html5_metadata_preview_result(project_id)}
</div>
"""
def render_html5_metadata_preview_result(
project_id: str,
preview: object | None = None,
error: str | None = None,
request_payload: dict | None = None,
) -> str:
if preview is None and error is None:
return '<div class="metadata-preview-result" data-html5-metadata-preview-result></div>'
if error:
return f"""
<div class="metadata-preview-result" data-html5-metadata-preview-result>
<div class="panel-title">Metadata preview</div>
<p class="muted padded">{escape(error)}</p>
</div>
"""
changed = bool(getattr(preview, "changed", False))
added = getattr(preview, "added_lines", 0)
target = getattr(preview, "target", None)
target_name = getattr(target, "qualified_name", None) or getattr(target, "name", None) or "target unavailable"
checks = getattr(preview, "checks", []) or []
diff = getattr(preview, "semantic_diff", []) or []
version_preview = getattr(preview, "version_preview", None)
next_version_id = str(getattr(version_preview, "next_version_id", ""))
check_rows = "".join(_authoring_check_item(check) for check in checks[:8])
diff_rows = "".join(_authoring_diff_item(line) for line in diff[:12]) or '<p class="muted padded">Diff пустой</p>'
apply_form = (
_metadata_apply_form(project_id, request_payload or {}, next_version_id)
if changed and next_version_id
else ""
)
return f"""
<div class="metadata-preview-result" data-html5-metadata-preview-result data-html5-project-id="{escape(project_id)}">
<div class="panel-title">Metadata preview · {'changed' if changed else 'unchanged'}</div>
<article class="authoring-change">
<strong>{escape(str(target_name))}</strong>
<span>+{escape(str(added))} / -0</span>
<small>{escape(next_version_id or "version preview unavailable")}</small>
</article>
{_authoring_result_summary("changed" if changed else "unchanged", diff, checks)}
<div class="check-list">{check_rows}</div>
<div class="diff-list">{diff_rows}</div>
{apply_form}
</div>
"""
def render_html5_metadata_apply_result(project_id: str, result: object | None = None, error: str | None = None) -> str:
if result is None and error is None:
return '<div class="metadata-apply-result" data-html5-metadata-apply-result></div>'
if error:
return f"""
<div class="metadata-apply-result" data-html5-metadata-apply-result>
<div class="panel-title">Metadata apply</div>
<p class="muted padded">{escape(error)}</p>
</div>
"""
status = str(getattr(result, "status", "UNKNOWN"))
change_id = str(getattr(result, "change_id", ""))
version = getattr(result, "version", None)
version_id = str(getattr(version, "version_id", ""))
return f"""
<div
class="metadata-apply-result"
data-html5-metadata-apply-result
data-html5-authoring-change="{escape(change_id)}"
data-html5-version-id="{escape(version_id)}"
>
<div class="panel-title">Metadata apply</div>
<article class="authoring-change">
<strong>{escape(status)}</strong>
<span>{escape(change_id)}</span>
<small>{escape(version_id)}</small>
</article>
{_authoring_apply_summary("metadata", status, change_id, version_id)}
<p class="muted padded">Metadata draft применен в workspace для проекта {escape(project_id)}.</p>
</div>
"""
def render_html5_authoring_change_detail(project_id: str, preview: object | None) -> str:
if preview is None:
return f"""
<div class="authoring-detail" data-html5-authoring-detail>
<div class="panel-title">Rollback preview</div>
<p class="muted padded">Выберите изменение, чтобы сервер рассчитал rollback diff для проекта {escape(project_id)}.</p>
</div>
"""
change_id = str(getattr(preview, "change_id", ""))
original_version_id = str(getattr(preview, "original_version_id", ""))
rollback_version_id = str(getattr(preview, "rollback_version_id", ""))
target = getattr(preview, "target", None)
target_name = getattr(target, "qualified_name", None) or getattr(target, "name", None) or "target unavailable"
diff = getattr(preview, "semantic_diff", []) or []
checks = getattr(preview, "checks", []) or []
apply_available = bool(getattr(preview, "apply_available", False))
diff_rows = "".join(_authoring_diff_item(line) for line in diff[:12]) or '<p class="muted padded">Diff пустой</p>'
check_rows = "".join(_authoring_check_item(check) for check in checks[:8])
apply_form = _authoring_rollback_form(project_id, change_id, rollback_version_id) if apply_available else ""
return f"""
<div
class="authoring-detail"
data-html5-authoring-detail
data-html5-authoring-change="{escape(change_id)}"
>
<div class="panel-title">Rollback preview · {'ready' if apply_available else 'blocked'}</div>
<article class="authoring-change">
<strong>{escape(str(target_name))}</strong>
<span>{escape(original_version_id)} -> {escape(rollback_version_id)}</span>
<small>{escape(change_id)}</small>
</article>
{_authoring_detail_summary(diff, checks, apply_available)}
<div class="check-list">{check_rows}</div>
<div class="diff-list">{diff_rows}</div>
{apply_form}
</div>
"""
def render_html5_authoring_rollback_result(project_id: str, result: object | None = None, error: str | None = None) -> str:
if result is None and error is None:
return '<div class="authoring-result" data-html5-authoring-result></div>'
if error:
return f"""
<div class="authoring-result" data-html5-authoring-result>
<div class="panel-title">Rollback apply</div>
<p class="muted padded">{escape(error)}</p>
</div>
"""
status = str(getattr(result, "status", "UNKNOWN"))
change_id = str(getattr(result, "change_id", ""))
rollback_change_id = str(getattr(result, "rollback_change_id", ""))
version = getattr(result, "version", None)
version_id = str(getattr(version, "version_id", ""))
return f"""
<div
class="authoring-result"
data-html5-authoring-result
data-html5-authoring-change="{escape(change_id)}"
data-html5-version-id="{escape(version_id)}"
>
<div class="panel-title">Rollback apply</div>
<article class="authoring-change">
<strong>{escape(status)}</strong>
<span>{escape(rollback_change_id)}</span>
<small>{escape(version_id)}</small>
</article>
{_authoring_apply_summary("rollback", status, rollback_change_id or change_id, version_id)}
<p class="muted padded">Rollback применен в workspace для проекта {escape(project_id)}.</p>
</div>
"""
def _authoring_change_item(change: object) -> str:
change_id = str(getattr(change, "change_id", ""))
status = str(getattr(change, "status", ""))
target = getattr(change, "target", None)
target_name = getattr(target, "qualified_name", None) or getattr(target, "name", None) or "target unavailable"
approved_by = str(getattr(change, "approved_by", "") or "not approved")
task_id = str(getattr(change, "task_id", "") or "no task")
added = getattr(change, "added_lines", 0)
removed = getattr(change, "removed_lines", 0)
production = "production" if bool(getattr(change, "production_applied", False)) else "workspace"
project_id = str(getattr(change, "project_id", ""))
detail_attrs = (
f'hx-get="/html5/projects/{quote(project_id)}/authoring/changes/{quote(change_id, safe="")}" '
'hx-target="[data-html5-authoring-detail]" hx-swap="outerHTML"'
if change_id and project_id
else ""
)
return f"""
<article class="authoring-change" data-html5-authoring-change="{escape(change_id)}" {detail_attrs}>
<strong>{escape(str(target_name))}</strong>
<span>{escape(status)} · +{escape(str(added))} / -{escape(str(removed))} · {escape(production)}</span>
<small>{escape(task_id)} · {escape(approved_by)} · {escape(change_id)}</small>
</article>
"""
def _authoring_changes_summary(changes: Iterable[object]) -> str:
change_list = list(changes)
statuses = Counter(str(getattr(change, "status", "") or "UNKNOWN") for change in change_list)
production = sum(1 for change in change_list if bool(getattr(change, "production_applied", False)))
workspace = len(change_list) - production
added = sum(int(getattr(change, "added_lines", 0) or 0) for change in change_list)
removed = sum(int(getattr(change, "removed_lines", 0) or 0) for change in change_list)
status_text = ", ".join(f"{name}: {count}" for name, count in sorted(statuses.items())) or "no changes"
return f"""
<p class="authoring-summary" data-html5-authoring-summary>
{escape(str(len(change_list)))} changes · {escape(status_text)} · {escape(str(workspace))} workspace · {escape(str(production))} production · +{escape(str(added))} / -{escape(str(removed))}
</p>
"""
def _authoring_recent_change(changes: Iterable[object]) -> str:
change_list = list(changes)
if not change_list:
return ""
latest = change_list[0]
change_id = str(getattr(latest, "change_id", ""))
status = str(getattr(latest, "status", "") or "UNKNOWN")
target = getattr(latest, "target", None)
target_name = getattr(target, "qualified_name", None) or getattr(target, "name", None) or "target unavailable"
version = getattr(latest, "version", None)
version_id = str(getattr(latest, "version_id", "") or getattr(version, "version_id", "") or "version unavailable")
approved_by = str(getattr(latest, "approved_by", "") or "not approved")
return f"""
<article class="authoring-change" data-html5-authoring-recent-change="{escape(change_id)}">
<strong>{escape(status)} · {escape(str(target_name))}</strong>
<span>{escape(version_id)}</span>
<small>{escape(approved_by)} · {escape(change_id)}</small>
</article>
"""
def _authoring_result_summary(state: str, diff: Iterable[object], checks: Iterable[object]) -> str:
diff_list = list(diff)
check_list = list(checks)
diff_kinds = Counter(str(getattr(line, "kind", "") or "CHANGE") for line in diff_list)
check_statuses = Counter(str(getattr(check, "status", "") or "UNKNOWN") for check in check_list)
added = sum(1 for line in diff_list if str(getattr(line, "kind", "")) == "ADD")
removed = sum(1 for line in diff_list if str(getattr(line, "kind", "")) == "REMOVE")
diff_text = ", ".join(f"{name}: {count}" for name, count in sorted(diff_kinds.items())) or "empty diff"
check_text = ", ".join(f"{name}: {count}" for name, count in sorted(check_statuses.items())) or "no checks"
return f"""
<p class="authoring-summary" data-html5-authoring-result-summary>
{escape(state)} · {escape(str(len(diff_list)))} diff lines · +{escape(str(added))} / -{escape(str(removed))} · {escape(diff_text)} · {escape(check_text)}
</p>
"""
def _authoring_detail_summary(diff: Iterable[object], checks: Iterable[object], apply_available: bool) -> str:
diff_list = list(diff)
check_list = list(checks)
diff_kinds = Counter(str(getattr(line, "kind", "") or "CHANGE") for line in diff_list)
check_statuses = Counter(str(getattr(check, "status", "") or "UNKNOWN") for check in check_list)
added = sum(1 for line in diff_list if str(getattr(line, "kind", "")) == "ADD")
removed = sum(1 for line in diff_list if str(getattr(line, "kind", "")) == "REMOVE")
diff_text = ", ".join(f"{name}: {count}" for name, count in sorted(diff_kinds.items())) or "empty diff"
check_text = ", ".join(f"{name}: {count}" for name, count in sorted(check_statuses.items())) or "no checks"
state = "rollback ready" if apply_available else "rollback blocked"
return f"""
<p class="authoring-summary" data-html5-authoring-detail-summary>
{escape(state)} · {escape(str(len(diff_list)))} diff lines · +{escape(str(added))} / -{escape(str(removed))} · {escape(diff_text)} · {escape(check_text)}
</p>
"""
def _authoring_apply_summary(kind: str, status: str, change_id: str, version_id: str) -> str:
return f"""
<p
class="authoring-summary"
data-html5-authoring-apply-summary
data-html5-authoring-apply-kind="{escape(kind)}"
>
{escape(kind)} · {escape(status or "UNKNOWN")} · {escape(change_id or "change unavailable")} · {escape(version_id or "version unavailable")}
</p>
"""
def _authoring_diff_item(line: object) -> str:
kind = str(getattr(line, "kind", ""))
text = str(getattr(line, "text", ""))
return f"""
<article class="diff-item" data-html5-authoring-diff="{escape(kind)}">
<span>{escape(kind)}</span>
<code>{escape(text)}</code>
</article>
"""
def _authoring_check_item(check: object) -> str:
name = str(getattr(check, "name", "check"))
status = str(getattr(check, "status", "UNKNOWN"))
message = str(getattr(check, "message", ""))
return f"""
<article class="check-item" data-html5-authoring-check="{escape(status)}">
<strong>{escape(name)}</strong>
<span>{escape(status)}</span>
<small>{escape(message)}</small>
</article>
"""
def _authoring_rollback_form(project_id: str, change_id: str, rollback_version_id: str) -> str:
return f"""
<form
class="rollback-form"
data-html5-authoring-rollback-form
method="post"
action="/html5/projects/{quote(project_id)}/authoring/changes/{quote(change_id, safe="")}/apply-rollback"
hx-post="/html5/projects/{quote(project_id)}/authoring/changes/{quote(change_id, safe="")}/apply-rollback"
hx-target="[data-html5-authoring-result]"
hx-swap="outerHTML"
>
<input type="hidden" name="expected_rollback_version_id" value="{escape(rollback_version_id)}" />
<input name="approved_by" placeholder="approved_by" required />
<input name="task_id" placeholder="task_id" />
<input name="session_id" placeholder="session_id" />
<input name="approval_note" placeholder="Комментарий" />
<button type="submit">Apply rollback</button>
</form>
{render_html5_authoring_rollback_result(project_id)}
"""
def _authoring_apply_change_set_form(project_id: str, payload: dict, next_version_id: str) -> str:
return f"""
<form
class="authoring-preview-form"
data-html5-authoring-apply-form
method="post"
action="/html5/projects/{quote(project_id)}/authoring/apply-change-set"
hx-post="/html5/projects/{quote(project_id)}/authoring/apply-change-set"
hx-target="[data-html5-authoring-apply-result]"
hx-swap="outerHTML"
>
<input type="hidden" name="routine_name" value="{escape(str(payload.get("routine_name") or ""))}" />
<input type="hidden" name="source_path" value="{escape(str(payload.get("source_path") or ""))}" />
<textarea hidden name="original_text">{escape(str(payload.get("original_text") or ""))}</textarea>
<textarea hidden name="proposed_text">{escape(str(payload.get("proposed_text") or ""))}</textarea>
<input type="hidden" name="task_id" value="{escape(str(payload.get("task_id") or ""))}" />
<input type="hidden" name="session_id" value="{escape(str(payload.get("session_id") or ""))}" />
<input type="hidden" name="user_id" value="{escape(str(payload.get("user_id") or ""))}" />
<input type="hidden" name="expected_next_version_id" value="{escape(next_version_id)}" />
<input name="approved_by" placeholder="approved_by" required />
<input name="approval_note" placeholder="Комментарий" />
<button type="submit">Apply change-set</button>
</form>
{render_html5_authoring_apply_result(project_id)}
"""
def _metadata_apply_form(project_id: str, payload: dict, next_version_id: str) -> str:
return f"""
<form
class="authoring-preview-form"
data-html5-metadata-apply-form
method="post"
action="/html5/projects/{quote(project_id)}/authoring/apply-metadata-object"
hx-post="/html5/projects/{quote(project_id)}/authoring/apply-metadata-object"
hx-target="[data-html5-metadata-apply-result]"
hx-swap="outerHTML"
>
<input type="hidden" name="object_kind" value="{escape(str(payload.get("object_kind") or ""))}" />
<input type="hidden" name="name" value="{escape(str(payload.get("name") or ""))}" />
<input type="hidden" name="synonym" value="{escape(str(payload.get("synonym") or ""))}" />
<input type="hidden" name="attributes" value="{escape(str(payload.get("_raw_attributes") or ""))}" />
<input type="hidden" name="tabular_sections" value="{escape(str(payload.get("_raw_tabular_sections") or ""))}" />
<input type="hidden" name="forms" value="{escape(str(payload.get("_raw_forms") or ""))}" />
<input type="hidden" name="commands" value="{escape(str(payload.get("_raw_commands") or ""))}" />
<input type="hidden" name="task_id" value="{escape(str(payload.get("task_id") or ""))}" />
<input type="hidden" name="session_id" value="{escape(str(payload.get("session_id") or ""))}" />
<input type="hidden" name="user_id" value="{escape(str(payload.get("user_id") or ""))}" />
<input type="hidden" name="expected_next_version_id" value="{escape(next_version_id)}" />
<input name="approved_by" placeholder="approved_by" required />
<input name="approval_note" placeholder="Комментарий" />
<button type="submit">Apply metadata draft</button>
</form>
{render_html5_metadata_apply_result(project_id)}
"""
@@ -0,0 +1,180 @@
from __future__ import annotations
from collections.abc import Callable, Iterable
from typing import Any
from fastapi import HTTPException
from api_server.html5_authoring import (
render_html5_authoring_apply_result,
render_html5_authoring_change_detail,
render_html5_authoring_changes,
render_html5_authoring_diff_result,
render_html5_authoring_preview_result,
render_html5_authoring_rollback_result,
render_html5_metadata_apply_result,
render_html5_metadata_preview_result,
)
from api_server.html5_forms import form_value, html5_metadata_payload, html5_metadata_request_payload
def html5_authoring_changes(project_id: str, changes: Iterable[object]) -> str:
return render_html5_authoring_changes(project_id, changes)
def html5_authoring_change_detail(project_id: str, rollback_preview: object) -> str:
return render_html5_authoring_change_detail(project_id, rollback_preview)
async def html5_authoring_apply_rollback(
*,
project_id: str,
change_id: str,
form: dict[str, list[str]],
request_model: Callable[..., object],
apply_rollback: Callable[[str, str, object], Any],
) -> str:
payload = request_model(
expected_rollback_version_id=form_value(form, "expected_rollback_version_id") or "",
approved_by=form_value(form, "approved_by") or "",
approval_note=form_value(form, "approval_note"),
task_id=form_value(form, "task_id"),
session_id=form_value(form, "session_id"),
)
try:
result = await apply_rollback(project_id, change_id, payload)
return render_html5_authoring_rollback_result(project_id, result)
except HTTPException as error:
return render_html5_authoring_rollback_result(project_id, error=str(error.detail))
async def html5_authoring_completion_preview(
*,
project_id: str,
form: dict[str, list[str]],
request_model: Callable[..., object],
completion_preview: Callable[[str, object], Any],
) -> str:
cursor_line = _optional_int(form_value(form, "cursor_line"))
payload = request_model(
object_name=form_value(form, "object_name"),
routine_name=form_value(form, "routine_name"),
cursor_line=cursor_line,
source_text=form_value(form, "source_text"),
intent=form_value(form, "intent") or "guarded-return",
user_id=form_value(form, "user_id"),
)
try:
preview = await completion_preview(project_id, payload)
return render_html5_authoring_preview_result(project_id, preview)
except HTTPException as error:
return render_html5_authoring_preview_result(project_id, error=str(error.detail))
def html5_authoring_semantic_diff_preview(
*,
project_id: str,
form: dict[str, list[str]],
request_model: Callable[..., object],
semantic_diff_preview: Callable[[str, object], object],
) -> str:
payload = request_model(**_semantic_diff_payload(form))
try:
preview = semantic_diff_preview(project_id, payload)
return render_html5_authoring_diff_result(
project_id,
preview,
request_payload=_semantic_diff_payload_from_request(payload),
)
except HTTPException as error:
return render_html5_authoring_diff_result(project_id, error=str(error.detail))
async def html5_authoring_apply_change_set(
*,
project_id: str,
form: dict[str, list[str]],
request_model: Callable[..., object],
apply_change_set: Callable[[str, object], Any],
) -> str:
payload = request_model(
**_semantic_diff_payload(form),
expected_next_version_id=form_value(form, "expected_next_version_id") or "",
approved_by=form_value(form, "approved_by") or "",
approval_note=form_value(form, "approval_note"),
)
try:
result = await apply_change_set(project_id, payload)
return render_html5_authoring_apply_result(project_id, result)
except HTTPException as error:
return render_html5_authoring_apply_result(project_id, error=str(error.detail))
def html5_authoring_metadata_object_preview(
*,
project_id: str,
form: dict[str, list[str]],
request_model: Callable[..., object],
metadata_preview: Callable[[str, object], object],
) -> str:
raw_payload = html5_metadata_payload(form)
payload = request_model(**html5_metadata_request_payload(raw_payload))
try:
preview = metadata_preview(project_id, payload)
return render_html5_metadata_preview_result(project_id, preview, request_payload=raw_payload)
except (HTTPException, ValueError) as error:
detail = getattr(error, "detail", str(error))
return render_html5_metadata_preview_result(project_id, error=str(detail))
async def html5_authoring_apply_metadata_object(
*,
project_id: str,
form: dict[str, list[str]],
request_model: Callable[..., object],
apply_metadata_object: Callable[[str, object], Any],
) -> str:
raw_payload = html5_metadata_payload(form)
payload = request_model(
**html5_metadata_request_payload(raw_payload),
expected_next_version_id=form_value(form, "expected_next_version_id") or "",
approved_by=form_value(form, "approved_by") or "",
approval_note=form_value(form, "approval_note"),
)
try:
result = await apply_metadata_object(project_id, payload)
return render_html5_metadata_apply_result(project_id, result)
except (HTTPException, ValueError) as error:
detail = getattr(error, "detail", str(error))
return render_html5_metadata_apply_result(project_id, error=str(detail))
def _semantic_diff_payload(form: dict[str, list[str]]) -> dict[str, object]:
return {
"routine_name": form_value(form, "routine_name"),
"source_path": form_value(form, "source_path"),
"original_text": form_value(form, "original_text") or "",
"proposed_text": form_value(form, "proposed_text") or "",
"task_id": form_value(form, "task_id"),
"session_id": form_value(form, "session_id"),
"user_id": form_value(form, "user_id"),
}
def _semantic_diff_payload_from_request(payload: object) -> dict[str, object]:
return {
"routine_name": getattr(payload, "routine_name", None),
"source_path": getattr(payload, "source_path", None),
"original_text": getattr(payload, "original_text", ""),
"proposed_text": getattr(payload, "proposed_text", ""),
"task_id": getattr(payload, "task_id", None),
"session_id": getattr(payload, "session_id", None),
"user_id": getattr(payload, "user_id", None),
}
def _optional_int(value: str | None) -> int | None:
try:
return int(value) if value else None
except ValueError:
return None
@@ -0,0 +1,401 @@
from __future__ import annotations
from collections import Counter
from html import escape
from typing import Iterable
from urllib.parse import quote
from api_server.html5 import (
_enum_text,
_metric,
_page,
_project_link,
_topbar,
)
from api_server.html5_inspector import (
render_html5_flowchart,
render_html5_object_context,
render_html5_project_report,
render_html5_review,
)
from api_server.html5_authoring import (
render_html5_authoring_changes,
render_html5_authoring_preview,
render_html5_metadata_authoring,
)
from sir import NodeKind, SirSnapshot
def render_html5_editor(
*,
project_id: str,
projects: Iterable[object],
snapshot: SirSnapshot | None,
error: str | None = None,
q: str = "",
) -> str:
project_nav = "\n".join(_project_link(project, project_id) for project in projects)
if error or snapshot is None:
content = f"""
<main class="workspace" data-html5-page="editor" data-project-id="{escape(project_id)}">
{_topbar(project_id, project_nav)}
<section class="empty-state" data-html5-error>
<h1>Проект не готов к HTML5 IDE</h1>
<p>{escape(error or "Snapshot не найден")}</p>
<a class="button" href="/html5">К списку проектов</a>
</section>
</main>
"""
return _page(f"SFERA HTML5 - {project_id}", content)
counts = Counter(str(node.kind.value if hasattr(node.kind, "value") else node.kind) for node in snapshot.nodes)
modules = [node for node in snapshot.nodes if node.kind == NodeKind.MODULE]
objects = [
node
for node in snapshot.nodes
if node.kind
in {
NodeKind.CATALOG,
NodeKind.DOCUMENT,
NodeKind.REGISTER,
NodeKind.COMMON_MODULE,
NodeKind.REPORT,
NodeKind.DATA_PROCESSOR,
}
]
tree_nodes = objects[:120] or modules[:120]
selected_module = modules[0] if modules else None
content = f"""
<main
class="workspace"
data-html5-page="editor"
data-project-id="{escape(project_id)}"
hx-ext="sse"
sse-connect="/html5/projects/{quote(project_id)}/events"
>
{_topbar(project_id, project_nav)}
<section class="layout">
<aside class="panel tree" data-html5-tree>
<div class="panel-title">Дерево объектов</div>
<nav>{''.join(_tree_item(project_id, node) for node in tree_nodes) or '<p class="muted">Объекты не найдены</p>'}</nav>
</aside>
<section class="editor" data-html5-editor>
<div class="editor-head">
<div>
<p class="eyebrow">HTML5 editor</p>
<h1>{escape(selected_module.qualified_name if selected_module else project_id)}</h1>
</div>
<form
class="search"
action="/html5/projects/{quote(project_id)}/editor"
method="get"
data-html5-search
hx-get="/html5/projects/{quote(project_id)}/symbols"
hx-target="[data-html5-symbol-results]"
hx-swap="innerHTML"
hx-push-url="false"
>
<input name="q" value="{escape(q)}" placeholder="Найти символ на сервере" />
<button type="submit">Найти</button>
</form>
</div>
{render_html5_source(selected_module)}
</section>
<aside class="panel inspector" data-html5-inspector>
<div class="panel-title">Серверный контекст</div>
<dl class="metrics">
<div><dt>Nodes</dt><dd>{len(snapshot.nodes)}</dd></div>
<div><dt>Edges</dt><dd>{len(snapshot.edges)}</dd></div>
<div><dt>Diagnostics</dt><dd>{len(snapshot.diagnostics)}</dd></div>
<div><dt>Modules</dt><dd>{len(modules)}</dd></div>
</dl>
{render_html5_object_context(project_id, None, None)}
<div class="panel-title">Типы</div>
<ul class="compact">{''.join(f'<li><span>{escape(kind)}</span><b>{count}</b></li>' for kind, count in counts.most_common(10))}</ul>
<div class="panel-title">Результаты</div>
<div data-html5-symbol-results>
{render_html5_symbols(snapshot, q, project_id)}
</div>
{render_html5_symbol_detail(project_id, None)}
{render_html5_flowchart(project_id, None)}
{render_html5_project_report(project_id, None)}
{render_html5_review(project_id, None)}
{render_html5_authoring_preview(project_id, None)}
{render_html5_metadata_authoring(project_id)}
{render_html5_authoring_changes(project_id, None)}
</aside>
</section>
<footer class="status" data-html5-status sse-swap="status">
{render_html5_status(project_id, snapshot)}
</footer>
</main>
"""
return _page(f"SFERA HTML5 - {project_id}", content)
def render_html5_status(project_id: str, snapshot: SirSnapshot) -> str:
return (
f'<span>project: {escape(project_id)}</span>'
f'<span>snapshot: {escape(snapshot.snapshot_id)}</span>'
f'<span>nodes: {len(snapshot.nodes)}</span>'
f'<span>edges: {len(snapshot.edges)}</span>'
f'<span>diagnostics: {len(snapshot.diagnostics)}</span>'
'<span>server-rendered</span>'
'<span>client-js: htmx+sse only</span>'
)
def html5_symbol_results(snapshot: SirSnapshot, q: str) -> list[object]:
query = q.strip().lower()
if not query:
modules = [node for node in snapshot.nodes if node.kind == NodeKind.MODULE]
return (modules[:12] or snapshot.nodes[:12])
return [
node for node in snapshot.nodes
if query in (node.qualified_name or node.name).lower()
][:30]
def render_html5_symbols(snapshot: SirSnapshot, q: str, project_id: str | None = None) -> str:
results = html5_symbol_results(snapshot, q)
if not results:
return '<p class="muted">Нет результатов</p>'
return "".join(_symbol_result(node, project_id) for node in results)
def render_html5_symbol_detail(project_id: str, references: object | None, *, oob: bool = False) -> str:
oob_attr = ' hx-swap-oob="outerHTML"' if oob else ""
if references is None:
return f"""
<div class="symbol-detail" data-html5-symbol-detail{oob_attr}>
<div class="panel-title">Символ</div>
<p class="muted padded">Выберите результат поиска для server-side definition/references по проекту {escape(project_id)}.</p>
</div>
"""
symbol = getattr(references, "symbol", None)
node = getattr(symbol, "node", None)
source = getattr(symbol, "source", None)
name = getattr(node, "qualified_name", None) or getattr(node, "name", "symbol")
kind = getattr(node, "kind", "")
source_path = getattr(source, "source_path", None) or ""
line = getattr(source, "line_start", None)
location = f"{source_path}:{line}" if source_path and line else source_path or "source unavailable"
refs = getattr(references, "references", []) or []
ref_items = (
"".join(_symbol_reference_item(project_id, ref) for ref in refs[:10])
or '<p class="muted padded">References не найдены</p>'
)
lineage_id = str(getattr(node, "lineage_id", ""))
return f"""
<div
class="symbol-detail"
data-html5-symbol-detail
data-html5-lineage-id="{escape(lineage_id)}"
{oob_attr}
>
<div class="panel-title">Символ · {escape(str(kind))}</div>
<article class="symbol-focus">
<strong>{escape(str(name))}</strong>
<small>{escape(str(location))}</small>
</article>
{_symbol_summary(refs)}
<div class="review-list">{ref_items}</div>
</div>
"""
def render_html5_source(node: object | None, *, oob: bool = False) -> str:
name = "source" if node is None else getattr(node, "qualified_name", None) or getattr(node, "name", "source")
kind = "" if node is None else _enum_text(getattr(node, "kind", ""))
lineage_id = "" if node is None else str(getattr(node, "lineage_id", ""))
attributes = {} if node is None else getattr(node, "attributes", {}) or {}
source_path = "" if node is None else str(
getattr(node, "source_path", None) or getattr(getattr(node, "source_ref", None), "source_path", None) or ""
)
line = "" if node is None else str(
getattr(node, "line_start", None) or getattr(getattr(node, "source_ref", None), "line_start", None) or ""
)
location = f"{source_path}:{line}" if source_path and line else source_path or "source unavailable"
source_text = _node_source_text(node)
line_count = len(source_text.splitlines()) or 1
source_size = len(source_text)
oob_attr = ' hx-swap-oob="outerHTML"' if oob else ""
owner_name = str(attributes.get("owner_qualified_name") or "")
owner_kind = str(attributes.get("owner_kind") or "")
object_part = str(attributes.get("object_part") or attributes.get("module_role") or "")
form_name = str(attributes.get("form_name") or "")
cache_attrs = (
f'data-html5-object-cache="warm" data-html5-owner="{escape(owner_name)}" '
f'data-html5-object-part="{escape(object_part)}"'
if owner_name
else 'data-html5-object-cache="cold"'
)
return f"""
<article
class="source-panel"
data-html5-source
data-html5-source-name="{escape(str(name))}"
data-html5-lineage-id="{escape(lineage_id)}"
{cache_attrs}
{oob_attr}
>
<header class="source-head">
<div>
<strong>{escape(str(name))}</strong>
<small>{escape(_source_kind_label(kind or "source", owner_name, object_part, form_name))}</small>
</div>
<dl>
{_metric("Lines", line_count)}
{_metric("Location", location)}
</dl>
</header>
{_source_object_cache_summary(owner_name, owner_kind, object_part, form_name)}
{_source_summary(kind or "source", line_count, source_size, location)}
<pre class="code">{escape(source_text)}</pre>
</article>
"""
def _source_kind_label(kind: str, owner_name: str, object_part: str, form_name: str) -> str:
if not owner_name:
return kind
if object_part.startswith("form."):
return f"{kind} · часть формы {form_name or 'форма'}"
if object_part == "object.manager":
return f"{kind} · менеджер объекта"
if object_part == "object.record_set":
return f"{kind} · набор записей объекта"
if object_part == "object.module":
return f"{kind} · модуль объекта"
return f"{kind} · часть объекта"
def _source_object_cache_summary(owner_name: str, owner_kind: str, object_part: str, form_name: str) -> str:
if not owner_name:
return ""
form_text = f" · форма {form_name}" if form_name else ""
return f"""
<p class="object-cache" data-html5-object-cache-summary>
Открыт программный текст части объекта 1С: {escape(owner_name)} · {escape(owner_kind or "OBJECT")} · {escape(object_part)}{escape(form_text)}.
Объектный контекст подгружен сервером в cache-warm режим для быстрых переходов по формам, модулям и обработчикам.
</p>
"""
def _source_summary(kind: str, line_count: int, source_size: int, location: str) -> str:
return f"""
<p class="source-summary" data-html5-source-summary>
{escape(kind)} · {escape(str(line_count))} lines · {escape(str(source_size))} chars · {escape(location)}
</p>
"""
def _tree_item(project_id: str, node: object) -> str:
name = getattr(node, "qualified_name", None) or getattr(node, "name", "")
kind = getattr(node, "kind", "")
kind_value = str(kind.value if hasattr(kind, "value") else kind)
lineage_id = str(getattr(node, "lineage_id", ""))
object_kinds = {
NodeKind.CATALOG.value,
NodeKind.DOCUMENT.value,
NodeKind.REGISTER.value,
NodeKind.COMMON_MODULE.value,
NodeKind.REPORT.value,
NodeKind.DATA_PROCESSOR.value,
}
if kind_value in object_kinds:
htmx_attrs = (
f'hx-get="/html5/projects/{quote(project_id)}/objects/context/{quote(str(name), safe="")}" '
'hx-target="[data-html5-object-context]" hx-swap="outerHTML"'
)
else:
htmx_attrs = (
f'hx-get="/html5/projects/{quote(project_id)}/source/{quote(lineage_id, safe="")}" '
'hx-target="[data-html5-source]" hx-swap="outerHTML"'
)
return (
f'<a class="tree-item" href="#{quote(str(name))}" '
f'data-html5-node-kind="{escape(kind_value)}" '
f'data-html5-lineage-id="{escape(lineage_id)}" '
f'{htmx_attrs}>'
f'<span>{escape(str(name))}</span><small>{escape(kind_value)}</small></a>'
)
def _symbol_result(node: object, project_id: str | None = None) -> str:
name = getattr(node, "qualified_name", None) or getattr(node, "name", "")
kind = getattr(node, "kind", "")
kind_value = str(kind.value if hasattr(kind, "value") else kind)
lineage_id = str(getattr(node, "lineage_id", ""))
source_path = getattr(node, "source_path", None) or getattr(getattr(node, "source_ref", None), "source_path", None) or ""
line = getattr(node, "line_start", None) or getattr(getattr(node, "source_ref", None), "line_start", None)
location = f"{source_path}:{line}" if source_path and line else source_path or "source unavailable"
htmx_attrs = (
f'hx-get="/html5/projects/{quote(project_id)}/symbols/{quote(lineage_id, safe="")}/detail" '
'hx-target="[data-html5-symbol-detail]" hx-swap="outerHTML"'
if project_id and lineage_id
else ""
)
return f"""
<article class="symbol" data-html5-symbol data-html5-lineage-id="{escape(lineage_id)}" {htmx_attrs}>
<strong>{escape(str(name))}</strong>
<span>{escape(kind_value)}</span>
<small>{escape(str(location))}</small>
</article>"""
def _symbol_summary(references: Iterable[object]) -> str:
refs = list(references)
directions = Counter(str(getattr(ref, "direction", "") or "UNKNOWN") for ref in refs)
kinds = Counter(str(getattr(ref, "kind", "") or "REFERENCE") for ref in refs)
direction_text = ", ".join(f"{name}: {count}" for name, count in sorted(directions.items())) or "no directions"
kind_text = ", ".join(f"{name}: {count}" for name, count in sorted(kinds.items())) or "no kinds"
return f"""
<p class="symbol-summary" data-html5-symbol-summary>
{escape(str(len(refs)))} references · {escape(direction_text)} · {escape(kind_text)}
</p>
"""
def _symbol_reference_item(project_id: str, reference: object) -> str:
kind = str(getattr(reference, "kind", ""))
direction = str(getattr(reference, "direction", ""))
source = getattr(reference, "source", None)
target = getattr(reference, "target", None)
location = getattr(reference, "location", None)
source_name = getattr(source, "qualified_name", None) or getattr(source, "name", "")
target_name = getattr(target, "qualified_name", None) or getattr(target, "name", "")
source_path = getattr(location, "source_path", None) or ""
line = getattr(location, "line_start", None)
place = f"{source_path}:{line}" if source_path and line else source_path
label = f"{source_name} -> {target_name}".strip(" ->")
source_lineage = str(getattr(source, "lineage_id", "") or "")
source_link = (
f"""
<a
href="/html5/projects/{quote(project_id)}/source/{quote(source_lineage, safe='')}"
hx-get="/html5/projects/{quote(project_id)}/source/{quote(source_lineage, safe='')}"
hx-target="[data-html5-source]"
hx-swap="outerHTML"
data-html5-symbol-source="{escape(source_lineage)}"
>Source</a>
"""
if source_lineage
else ""
)
return f"""
<article class="symbol-reference" data-html5-symbol-reference>
<strong>{escape(label or kind)}</strong>
<span>{escape(direction)} · {escape(kind)}</span>
<small>{escape(place or "source unavailable")}</small>
<span class="inline-actions">{source_link}</span>
</article>
"""
def _node_source_text(node: object | None) -> str:
if node is None:
return "// Модуль не найден в snapshot.\n// HTML5 IDE показывает серверный fallback без клиентского JS."
attributes = getattr(node, "attributes", {}) or {}
source_text = attributes.get("source_text") or attributes.get("text")
if isinstance(source_text, str) and source_text.strip():
return source_text
name = getattr(node, "qualified_name", None) or getattr(node, "name", "Module")
return f"// {name}\n// Исходный текст не сохранен в snapshot.\n// Сервер уже отрисовал контекст, дерево, поиск и метрики."
@@ -0,0 +1,157 @@
from __future__ import annotations
from collections.abc import Callable, Iterable
from typing import Any
from fastapi import HTTPException
from api_server.form_editor_service import form_module_for_form, select_form_semantics
from api_server.form_editor_view_model import build_form_editor_draft
from api_server.html5_editor import render_html5_editor, render_html5_source, render_html5_symbol_detail
from api_server.html5_form_editor import render_html5_form_designer, render_html5_form_editor
from api_server.html5_inspector import (
render_html5_flowchart,
render_html5_object_context,
render_html5_object_report,
render_html5_review,
)
from sir import SirSnapshot
def html5_editor_page(
*,
project_id: str,
q: str,
project_summaries: Callable[[], Iterable[object]],
project_snapshot: Callable[[str], SirSnapshot],
) -> str:
try:
snapshot = project_snapshot(project_id)
return render_html5_editor(
project_id=project_id,
projects=project_summaries(),
snapshot=snapshot,
q=q,
)
except HTTPException as error:
return render_html5_editor(
project_id=project_id,
projects=project_summaries(),
snapshot=None,
error=str(error.detail),
q=q,
)
def html5_form_editor_page(
*,
project_id: str,
form_id: str | None,
project_summaries: Callable[[], Iterable[object]],
project_snapshot: Callable[[str], SirSnapshot],
form_semantics_items: Callable[[SirSnapshot], Iterable[object]],
form_semantics_response: Callable[[object], Any],
) -> str:
try:
snapshot = project_snapshot(project_id)
forms = [form_semantics_response(item) for item in form_semantics_items(snapshot)]
selected = select_form_semantics(forms, form_id)
form_module = form_module_for_form(snapshot, selected.form if selected is not None else None)
return render_html5_form_editor(
project_id=project_id,
projects=project_summaries(),
snapshot=snapshot,
forms=forms,
selected_form_id=form_id,
form_module=form_module,
)
except HTTPException as error:
return render_html5_form_editor(
project_id=project_id,
projects=project_summaries(),
snapshot=None,
forms=[],
error=str(error.detail),
)
def html5_form_editor_preview(
*,
project_id: str,
form: dict[str, list[str]],
project_snapshot: Callable[[str], SirSnapshot],
form_semantics_items: Callable[[SirSnapshot], Iterable[object]],
form_semantics_response: Callable[[object], Any],
) -> str:
snapshot = project_snapshot(project_id)
form_id = _form_value(form, "form")
forms = [form_semantics_response(item) for item in form_semantics_items(snapshot)]
selected = select_form_semantics(forms, form_id)
form_module = form_module_for_form(snapshot, selected.form if selected is not None else None)
draft = build_form_editor_draft(selected, form_module, form) if selected is not None else None
return render_html5_form_designer(project_id, selected, form_module, draft=draft)
def _form_value(form: dict[str, list[str]], key: str) -> str | None:
values = form.get(key)
return str(values[0]) if values else None
async def html5_object_context_fragment(
*,
project_id: str,
object_name: str,
mode: str,
object_schema: Callable[[str, str], Any],
object_impact: Callable[[str, str], Any],
object_access: Callable[[str, str], Any],
object_ui: Callable[[str, str], Any],
object_privacy: Callable[[str, str], Any],
project_flowchart: Callable[..., Any],
symbol_references: Callable[..., Any],
integrations_for_context: Callable[[str, Any], Iterable[object]],
runtime_for_context: Callable[[str, Any], Iterable[object]],
knowledge_for_context: Callable[[Any, Any, Any], Iterable[object]],
source_node_for_context: Callable[[str, Any], object | None],
review_for_context: Callable[[str, Any, Any, Any], Iterable[object]],
) -> str:
schema = await object_schema(project_id, object_name)
impact = await object_impact(project_id, object_name)
access = await object_access(project_id, object_name)
ui = await object_ui(project_id, object_name)
privacy = await object_privacy(project_id, object_name)
integrations = integrations_for_context(project_id, impact)
flowchart = await project_flowchart(project_id, focus=object_name, depth=1, limit=40)
runtime = runtime_for_context(project_id, impact)
knowledge = knowledge_for_context(schema, impact, ui)
source_node = source_node_for_context(project_id, impact)
references = await symbol_references(project_id, schema.object.lineage_id, direction="both")
findings = review_for_context(project_id, schema, impact, ui)
return (
render_html5_object_context(
project_id,
schema,
impact,
access,
ui,
runtime,
knowledge,
privacy,
integrations,
flowchart,
mode,
)
+ render_html5_flowchart(project_id, flowchart, focus=object_name, depth=1, oob=True)
+ (render_html5_source(source_node, oob=True) if source_node is not None else "")
+ render_html5_symbol_detail(project_id, references, oob=True)
+ render_html5_object_report(
project_id,
impact,
access=access,
privacy=privacy,
runtime=runtime,
integrations=integrations,
oob=True,
)
+ render_html5_review(project_id, findings, title="Review объекта", oob=True)
)
@@ -0,0 +1,401 @@
from __future__ import annotations
from html import escape
from typing import Iterable
from urllib.parse import quote
from api_server.form_editor_models import FormEditorCommand, FormEditorDraft, FormEditorElement
from api_server.form_editor_view_model import build_form_editor_draft
from api_server.html5 import _metric, _page, _project_link, _topbar
from api_server.html5_editor import render_html5_source
from sir import SirSnapshot
def render_html5_form_editor(
*,
project_id: str,
projects: Iterable[object],
snapshot: SirSnapshot | None,
forms: Iterable[object],
selected_form_id: str | None = None,
form_module: object | None = None,
error: str | None = None,
) -> str:
project_nav = "\n".join(_project_link(project, project_id) for project in projects)
if error or snapshot is None:
content = f"""
<main class="workspace" data-html5-page="form-editor" data-project-id="{escape(project_id)}">
{_topbar(project_id, project_nav)}
<section class="empty-state" data-html5-error>
<h1>Редактор форм недоступен</h1>
<p>{escape(error or "Snapshot не найден")}</p>
<a class="button" href="/html5/projects/{quote(project_id)}/editor">Открыть IDE</a>
</section>
</main>
"""
return _page(f"SFERA Forms - {project_id}", content)
form_items = list(forms)
selected = _selected_form(form_items, selected_form_id)
selected_form = getattr(selected, "form", None)
selected_lineage = str(getattr(selected_form, "lineage_id", "") or "")
commands = getattr(selected, "commands", []) if selected is not None else []
elements = getattr(selected, "elements", []) if selected is not None else []
handlers = getattr(selected, "command_handlers", {}) if selected is not None else {}
module_lineage = str(getattr(form_module, "lineage_id", "") or "")
form_name = getattr(selected_form, "qualified_name", None) or getattr(selected_form, "name", "Форма")
draft = build_form_editor_draft(selected, form_module) if selected is not None else None
content = f"""
<main
class="workspace"
data-html5-page="form-editor"
data-project-id="{escape(project_id)}"
data-html5-form-editor
>
{_topbar(project_id, project_nav)}
<section class="layout form-editor-layout">
<aside class="panel tree" data-html5-form-tree>
<div class="panel-title">Формы объекта</div>
<nav>{''.join(_form_tree_item(project_id, item, selected_lineage) for item in form_items) or '<p class="muted padded">Формы не найдены</p>'}</nav>
</aside>
<section class="editor form-editor" data-html5-form-workspace>
<div class="editor-head">
<div>
<p class="eyebrow">HTML5 form editor</p>
<h1>{escape(str(form_name))}</h1>
</div>
<nav class="form-editor-actions">
<a class="button" href="/html5/projects/{quote(project_id)}/editor">IDE</a>
{_module_button(project_id, module_lineage)}
</nav>
</div>
{render_html5_form_designer(project_id, selected, form_module, draft=draft)}
</section>
<aside class="panel inspector" data-html5-form-inspector>
<div class="panel-title">Состав формы</div>
<dl class="metrics">
{_metric("Forms", len(form_items))}
{_metric("Commands", len(commands))}
{_metric("Elements", len(elements))}
{_metric("Handlers", len(handlers))}
</dl>
{_form_object_summary(selected, form_module)}
<div class="panel-title">Команды</div>
{_command_list(project_id, commands, handlers)}
<div class="panel-title">Элементы</div>
{_element_list(elements)}
<div class="panel-title">Модуль формы</div>
{render_html5_source(form_module)}
</aside>
</section>
</main>
"""
return _page(f"SFERA Forms - {project_id}", content)
def render_html5_form_designer(
project_id: str,
form_semantics: object | None,
form_module: object | None,
*,
draft: FormEditorDraft | None = None,
) -> str:
if form_semantics is None:
return '<section class="form-designer" data-html5-form-designer><p class="muted padded">Выберите форму объекта.</p></section>'
draft = draft or build_form_editor_draft(form_semantics, form_module)
return _form_designer_surface(project_id, form_semantics, form_module, draft)
def _selected_form(forms: list[object], selected_form_id: str | None) -> object | None:
if selected_form_id:
selected = next(
(
item
for item in forms
if str(getattr(getattr(item, "form", None), "lineage_id", "")) == selected_form_id
or str(getattr(getattr(item, "form", None), "qualified_name", "")) == selected_form_id
),
None,
)
if selected is not None:
return selected
return forms[0] if forms else None
def _form_tree_item(project_id: str, item: object, selected_lineage: str) -> str:
form = getattr(item, "form", None)
lineage = str(getattr(form, "lineage_id", "") or "")
name = getattr(form, "qualified_name", None) or getattr(form, "name", "Форма")
commands = getattr(item, "commands", []) or []
elements = getattr(item, "elements", []) or []
selected_attr = ' aria-current="page" data-html5-form-selected="true"' if lineage == selected_lineage else ""
return f"""
<a
class="tree-item"
href="/html5/projects/{quote(project_id)}/forms/editor?form={quote(lineage, safe='')}"
hx-get="/html5/projects/{quote(project_id)}/forms/editor?form={quote(lineage, safe='')}"
hx-target="body"
hx-swap="outerHTML"
data-html5-form-tree-item
{selected_attr}
>
<span>Форма</span>
<strong>{escape(str(name))}</strong>
<small>{len(commands)} команд · {len(elements)} элементов</small>
</a>
"""
def _module_button(project_id: str, module_lineage: str) -> str:
if not module_lineage:
return ""
quoted_project = quote(project_id)
quoted_lineage = quote(module_lineage, safe="")
return f"""
<a
class="button"
href="/html5/projects/{quoted_project}/source/{quoted_lineage}"
hx-get="/html5/projects/{quoted_project}/source/{quoted_lineage}"
hx-target="[data-html5-source]"
hx-swap="outerHTML"
data-html5-form-module-action
>Модуль формы</a>
"""
def _form_designer_surface(project_id: str, form_semantics: object, form_module: object | None, draft: FormEditorDraft) -> str:
form = getattr(form_semantics, "form", None)
form_name = getattr(form, "qualified_name", None) or getattr(form, "name", "Форма")
quoted_project = quote(project_id)
return f"""
<section class="form-designer" data-html5-form-designer data-html5-form="{escape(str(form_name))}">
<header class="form-designer-head">
<div>
<strong>{escape(draft.form_title)}</strong>
<small>{escape(draft.owner_name)} · форма как часть объекта 1С · visual layout</small>
</div>
<span data-html5-object-cache="warm">cache-warm</span>
</header>
<form
class="form-designer-body"
hx-post="/html5/projects/{quoted_project}/forms/editor/preview"
hx-target="[data-html5-form-designer]"
hx-swap="outerHTML"
data-html5-form-edit-form
>
<input type="hidden" name="form" value="{escape(draft.form_lineage_id)}" />
<div class="form-canvas" data-html5-form-canvas>
{_canvas_window(draft)}
</div>
<aside class="form-property-panel" data-html5-form-properties>
<div class="property-row">
<label>Заголовок формы<input name="form_title" value="{escape(draft.form_title)}" /></label>
<label>Размещение
<select name="layout_kind">
{_option("auto", "Авто 1С", draft.layout_kind)}
{_option("columns", "Колонки", draft.layout_kind)}
{_option("compact", "Компактно", draft.layout_kind)}
</select>
</label>
</div>
<div class="panel-title">Командная панель</div>
{_command_editor(draft.commands)}
<div class="panel-title">Элементы формы</div>
{_element_editor(draft.elements)}
<div class="form-add-row">
<input name="new_element_name" placeholder="Новый реквизит формы" />
<select name="new_element_kind">
<option value="input">Поле ввода</option>
<option value="date">Дата</option>
<option value="checkbox">Флажок</option>
<option value="table">Таблица</option>
<option value="group">Группа</option>
</select>
</div>
<button type="submit" class="button primary" data-html5-form-preview-action>Применить в макет</button>
</aside>
</form>
<footer class="form-designer-foot">
<a class="button" href="/html5/projects/{quoted_project}/objects/context/{quote(draft.owner_name, safe='')}">Контекст объекта</a>
<small>Черновое редактирование: структура и свойства пересобираются сервером, модуль формы остается частью объекта.</small>
</footer>
</section>
"""
def _canvas_window(draft: FormEditorDraft) -> str:
layout_attr = escape(draft.layout_kind)
return f"""
<div class="form-window" data-html5-form-window data-html5-form-layout="{layout_attr}">
<div class="form-window-title">
<span>{escape(draft.form_title)}</span>
<small>1C:Enterprise 8.5-style managed form</small>
</div>
<div class="form-command-bar">
{''.join(_canvas_command(command) for command in draft.commands) or '<span class="muted">Команды не описаны</span>'}
</div>
<div class="form-fields">
{''.join(_canvas_element(element) for element in draft.elements) or _empty_form_structure(draft)}
</div>
</div>
"""
def _canvas_command(command: FormEditorCommand) -> str:
handler_name = command.handler_name or "handler?"
return f"""
<button type="button" data-html5-form-command="{escape(command.lineage_id)}" title="{escape(handler_name)}">
{escape(command.caption)}
</button>
"""
def _canvas_element(element: FormEditorElement) -> str:
control = _control_markup(element)
return f"""
<div
class="form-field"
data-html5-form-element="{escape(element.lineage_id)}"
data-html5-form-control="{escape(element.control_kind)}"
data-html5-form-width="{escape(element.width)}"
>
<label>{escape(element.caption)}</label>
{control}
</div>
"""
def _empty_form_structure(draft: FormEditorDraft) -> str:
metadata = f"{draft.form_name} · {draft.owner_name}"
return f"""
<div class="form-empty-structure" data-html5-form-empty>
<strong>Структура элементов формы не загружена</strong>
<span>{escape(metadata)}</span>
</div>
"""
def _control_markup(element: FormEditorElement) -> str:
if element.control_kind == "table":
return f"""
<div class="form-table-control">
<div><span>{escape(element.binding)}</span><span>Количество</span><span>Сумма</span></div>
<div><span></span><span></span><span></span></div>
<div><span></span><span></span><span></span></div>
</div>
"""
if element.control_kind == "checkbox":
return f'<label class="form-check-control"><input type="checkbox" checked /> <span>{escape(element.binding)}</span></label>'
if element.control_kind == "date":
return f'<input class="form-input-control" value="{escape(element.binding)}" readonly />'
if element.control_kind == "group":
return f'<div class="form-group-control">{escape(element.binding)}</div>'
if element.control_kind == "text":
return '<textarea class="form-text-control" readonly></textarea>'
return f'<input class="form-input-control" value="{escape(element.binding)}" readonly />'
def _command_editor(commands: list[FormEditorCommand]) -> str:
if not commands:
return '<p class="muted padded">Команды можно добавить после импорта структуры формы.</p>'
return "".join(
f"""
<label class="form-editor-row" data-html5-form-command-editor>
<span>{escape(command.name)}</span>
<input type="hidden" name="command_lineage" value="{escape(command.lineage_id)}" />
<input name="command_caption" value="{escape(command.caption)}" />
<small>{escape(command.handler_name or "handler?")}</small>
</label>
"""
for command in commands
)
def _element_editor(elements: list[FormEditorElement]) -> str:
return "".join(
f"""
<article class="form-editor-row" data-html5-form-element-editor>
<input type="hidden" name="element_lineage" value="{escape(element.lineage_id)}" />
<label><span>Заголовок</span><input name="element_caption" value="{escape(element.caption)}" /></label>
<label><span>Вид</span><select name="element_kind">
{_option("input", "Поле ввода", element.control_kind)}
{_option("date", "Дата", element.control_kind)}
{_option("checkbox", "Флажок", element.control_kind)}
{_option("table", "Таблица", element.control_kind)}
{_option("group", "Группа", element.control_kind)}
{_option("text", "Текст", element.control_kind)}
</select></label>
<label><span>Данные</span><input name="element_binding" value="{escape(element.binding)}" /></label>
<label><span>Ширина</span><select name="element_width">
{_option("stretch", "Вся строка", element.width)}
{_option("half", "1/2", element.width)}
{_option("third", "1/3", element.width)}
</select></label>
</article>
"""
for element in elements
)
def _option(value: str, label: str, selected: str) -> str:
selected_attr = ' selected' if value == selected else ""
return f'<option value="{escape(value)}"{selected_attr}>{escape(label)}</option>'
def _form_object_summary(form_semantics: object | None, form_module: object | None) -> str:
if form_semantics is None:
return '<p class="object-summary">Форма не выбрана</p>'
form = getattr(form_semantics, "form", None)
module_attrs = getattr(form_module, "attributes", {}) or {}
owner = module_attrs.get("owner_qualified_name") or _owner_name_from_form(str(getattr(form, "qualified_name", "")))
object_part = module_attrs.get("object_part") or "form.module"
return f"""
<p class="object-summary" data-html5-form-summary>
Форма является частью объекта 1С: {escape(str(owner))} · {escape(str(object_part))}.
Редактор работает с формой целиком: элементы, команды, обработчики и модуль формы.
</p>
"""
def _command_list(project_id: str, commands: Iterable[object], handlers: dict[str, object]) -> str:
items = []
for command in commands:
lineage = str(getattr(command, "lineage_id", "") or "")
handler = handlers.get(lineage)
handler_lineage = str(getattr(handler, "lineage_id", "") or "")
handler_link = (
f'<a href="/html5/projects/{quote(project_id)}/source/{quote(handler_lineage, safe="")}" '
f'hx-get="/html5/projects/{quote(project_id)}/source/{quote(handler_lineage, safe="")}" '
'hx-target="[data-html5-source]" hx-swap="outerHTML">handler</a>'
if handler_lineage
else "<span>handler?</span>"
)
items.append(
f"""
<article class="object-context-item" data-html5-form-command-item>
<strong>{escape(str(getattr(command, "name", "Команда")))}</strong>
<small>{handler_link}</small>
</article>
"""
)
return "".join(items) or '<p class="muted padded">Команды не найдены</p>'
def _element_list(elements: Iterable[object]) -> str:
items = [
f"""
<article class="object-context-item" data-html5-form-element-item>
<strong>{escape(str(getattr(element, "name", "Элемент")))}</strong>
<small>{escape(str(getattr(element, "qualified_name", "")))}</small>
</article>
"""
for element in elements
]
return "".join(items) or '<p class="muted padded">Элементы не найдены</p>'
def _owner_name_from_form(form_name: str) -> str:
parts = form_name.split(".")
if len(parts) >= 2:
return ".".join(parts[:2])
return form_name
@@ -0,0 +1,86 @@
from __future__ import annotations
from urllib.parse import parse_qs
from fastapi import Request
async def html5_form_data(request: Request) -> dict[str, list[str]]:
content_type = request.headers.get("content-type", "")
if content_type.startswith("multipart/form-data"):
parsed = await request.form()
form: dict[str, list[str]] = {}
for key, value in parsed.multi_items():
text = getattr(value, "filename", None) if not isinstance(value, str) else value
form.setdefault(key, []).append(str(text or ""))
return form
body = (await request.body()).decode("utf-8")
return parse_qs(body, keep_blank_values=True)
def form_value(form: dict[str, list[str]], key: str) -> str | None:
values = form.get(key)
if not values:
return None
value = values[0].strip()
return value or None
def html5_metadata_payload(form: dict[str, list[str]]) -> dict:
return {
"object_kind": form_value(form, "object_kind") or "DOCUMENT",
"name": form_value(form, "name") or "",
"synonym": form_value(form, "synonym"),
"attributes": html5_metadata_attributes(form_value(form, "attributes") or ""),
"tabular_sections": html5_metadata_tabular_sections(form_value(form, "tabular_sections") or ""),
"forms": html5_csv_values(form_value(form, "forms") or ""),
"commands": html5_metadata_commands(form_value(form, "commands") or ""),
"task_id": form_value(form, "task_id"),
"session_id": form_value(form, "session_id"),
"user_id": form_value(form, "user_id"),
"_raw_attributes": form_value(form, "attributes") or "",
"_raw_tabular_sections": form_value(form, "tabular_sections") or "",
"_raw_forms": form_value(form, "forms") or "",
"_raw_commands": form_value(form, "commands") or "",
}
def html5_metadata_request_payload(payload: dict) -> dict:
return {key: value for key, value in payload.items() if not key.startswith("_raw_")}
def html5_csv_values(raw: str) -> list[str]:
return [item.strip() for item in raw.replace("\n", ",").split(",") if item.strip()]
def html5_metadata_attributes(raw: str) -> list[dict]:
attributes: list[dict] = []
for item in html5_csv_values(raw):
name, _, type_name = item.partition(":")
if name.strip():
attributes.append({"name": name.strip(), "type": type_name.strip() or "Строка"})
return attributes
def html5_metadata_commands(raw: str) -> list[dict]:
commands: list[dict] = []
for item in html5_csv_values(raw):
name, _, handler = item.partition(":")
if name.strip():
commands.append({"name": name.strip(), "handler": handler.strip() or None})
return commands
def html5_metadata_tabular_sections(raw: str) -> list[dict]:
sections: list[dict] = []
for item in html5_csv_values(raw):
name, _, attrs = item.partition("[")
if not name.strip():
continue
attributes = []
for attr in attrs.rstrip("]").split(";"):
attr_name, _, attr_type = attr.partition(":")
if attr_name.strip():
attributes.append({"name": attr_name.strip(), "type": attr_type.strip() or "Строка"})
sections.append({"name": name.strip(), "attributes": attributes})
return sections
@@ -0,0 +1,862 @@
from __future__ import annotations
from collections import Counter
from html import escape
from typing import Iterable
from urllib.parse import quote
from api_server.html5 import _enum_text, _metric
from sir import NodeKind
_HTML5_OBJECT_CONTEXT_KINDS = {
NodeKind.CATALOG.value,
NodeKind.DOCUMENT.value,
NodeKind.REGISTER.value,
NodeKind.COMMON_MODULE.value,
NodeKind.CONSTANT.value,
NodeKind.DOCUMENT_JOURNAL.value,
NodeKind.ENUM.value,
NodeKind.REPORT.value,
NodeKind.DATA_PROCESSOR.value,
NodeKind.FORM.value,
NodeKind.CHART_OF_CHARACTERISTIC_TYPES.value,
NodeKind.CHART_OF_ACCOUNTS.value,
NodeKind.CHART_OF_CALCULATION_TYPES.value,
NodeKind.EXCHANGE_PLAN.value,
NodeKind.EXTERNAL_DATA_SOURCE.value,
NodeKind.SCHEDULED_JOB.value,
NodeKind.BUSINESS_PROCESS.value,
NodeKind.TASK.value,
}
if hasattr(NodeKind, "EVENT_SUBSCRIPTION"):
_HTML5_OBJECT_CONTEXT_KINDS.add(NodeKind.EVENT_SUBSCRIPTION.value)
def render_html5_flowchart(
project_id: str,
flowchart: object | None,
*,
focus: str | None = None,
depth: int = 1,
oob: bool = False,
) -> str:
normalized_depth = min(max(depth, 1), 3)
hx_url = _flowchart_url(project_id, focus, normalized_depth)
oob_attr = ' hx-swap-oob="outerHTML"' if oob else ""
live_attr = ' sse-swap="project-flowchart"' if focus is None else ""
if flowchart is None:
return f"""
<div
class="flowchart-panel"
data-html5-flowchart
hx-get="{hx_url}"
hx-trigger="load"
hx-swap="outerHTML"
{oob_attr}
{live_attr}
>
<div class="panel-title">Карта связей</div>
<p class="muted padded">Сервер собирает граф проекта.</p>
</div>
"""
nodes = getattr(flowchart, "nodes", []) or []
edges = getattr(flowchart, "edges", []) or []
mode = str(getattr(flowchart, "mode", "overview"))
body = "".join(_flowchart_edge_item(project_id, item, nodes, normalized_depth) for item in edges[:10])
if not body:
body = "".join(_flowchart_node_item(project_id, item, normalized_depth) for item in nodes[:10])
if not body:
body = '<p class="muted padded">Связи проекта не найдены</p>'
return f"""
<div
class="flowchart-panel"
data-html5-flowchart
hx-get="{hx_url}"
{live_attr}
hx-swap="outerHTML"
{oob_attr}
>
<div class="panel-title">Карта связей · {escape(mode)}</div>
{_flowchart_depth_actions(project_id, focus, normalized_depth)}
<dl class="report-grid">
{_metric("Nodes", len(nodes))}
{_metric("Edges", len(edges))}
{_metric("Total nodes", getattr(flowchart, "total_nodes", 0))}
{_metric("Total edges", getattr(flowchart, "total_edges", 0))}
</dl>
<div class="compact-list">{body}</div>
</div>
"""
def render_html5_project_report(project_id: str, report: dict | None) -> str:
if report is None:
return f"""
<div
class="report-panel"
data-html5-project-report
hx-get="/html5/projects/{quote(project_id)}/report"
hx-trigger="load"
sse-swap="project-report"
hx-swap="outerHTML"
>
<div class="panel-title">Отчет проекта</div>
<p class="muted padded">Сервер готовит сводку проекта.</p>
</div>
"""
metrics = [
("Objects", report.get("node_count", 0)),
("Edges", report.get("edge_count", 0)),
("Procedures", report.get("procedure_count", 0)),
("Queries", report.get("query_count", 0)),
("Writes", report.get("write_count", 0)),
("Roles", report.get("role_count", 0)),
("Unowned", report.get("unowned_object_count", 0)),
("Sensitive", report.get("unclassified_sensitive_count", 0)),
]
return f"""
<div
class="report-panel"
data-html5-project-report
hx-get="/html5/projects/{quote(project_id)}/report"
sse-swap="project-report"
hx-swap="outerHTML"
>
<div class="panel-title">Отчет проекта</div>
{_project_summary(report)}
<dl class="report-grid">{''.join(_metric(label, value) for label, value in metrics)}</dl>
</div>
"""
def _project_summary(report: dict) -> str:
objects = int(report.get("node_count", 0) or 0)
procedures = int(report.get("procedure_count", 0) or 0)
queries = int(report.get("query_count", 0) or 0)
writes = int(report.get("write_count", 0) or 0)
unowned = int(report.get("unowned_object_count", 0) or 0)
sensitive = int(report.get("unclassified_sensitive_count", 0) or 0)
risk_total = unowned + sensitive
bits = [
f"{objects} objects",
f"{procedures} procedures",
f"{queries} queries",
f"{writes} writes",
f"{risk_total} risk signals",
]
if unowned:
bits.append(f"{unowned} unowned")
if sensitive:
bits.append(f"{sensitive} sensitive")
return f"""
<p class="project-summary" data-html5-project-summary>
{escape(" · ".join(bits))}
</p>
"""
def render_html5_object_report(
project_id: str,
impact: object,
*,
access: object | None = None,
privacy: object | None = None,
runtime: Iterable[object] | None = None,
integrations: Iterable[object] | None = None,
oob: bool = False,
) -> str:
obj = getattr(impact, "object", None)
name = getattr(obj, "qualified_name", None) or getattr(obj, "name", None) or getattr(impact, "object_name", "object")
grants = getattr(access, "grants", []) if access is not None else []
markers = getattr(privacy, "markers", []) if privacy is not None else []
runtime_items = list(runtime or [])
integration_items = list(integrations or [])
metrics = [
("Routines", len(getattr(impact, "routines", []) or [])),
("Commands", len(getattr(impact, "commands", []) or [])),
("Reads", len(getattr(impact, "query_tables", []) or [])),
("Writes", len(getattr(impact, "writes", []) or [])),
("Roles", len(grants) or len(getattr(impact, "roles", []) or [])),
("Runtime", len(runtime_items)),
("Privacy", len(markers)),
("Integrations", len(integration_items)),
]
oob_attr = ' hx-swap-oob="outerHTML"' if oob else ""
return f"""
<div
class="report-panel"
data-html5-project-report
{oob_attr}
>
<div class="panel-title">Отчет объекта</div>
<article class="object-focus">
<strong>{escape(str(name))}</strong>
<span>server focused summary</span>
</article>
{_object_report_summary(
len(getattr(impact, "routines", []) or []),
len(getattr(impact, "commands", []) or []),
len(getattr(impact, "query_tables", []) or []),
len(getattr(impact, "writes", []) or []),
len(grants) or len(getattr(impact, "roles", []) or []),
len(runtime_items),
len(markers),
len(integration_items),
)}
<dl class="report-grid">{''.join(_metric(label, value) for label, value in metrics)}</dl>
</div>
"""
def _object_report_summary(
routines: int,
commands: int,
reads: int,
writes: int,
roles: int,
runtime: int,
privacy: int,
integrations: int,
) -> str:
impact_links = reads + writes
bits = [
f"{routines} routines",
f"{commands} commands",
f"{impact_links} data links",
f"{roles} roles",
]
if runtime:
bits.append(f"{runtime} runtime signals")
if privacy:
bits.append(f"{privacy} privacy markers")
if integrations:
bits.append(f"{integrations} integrations")
return f"""
<p class="object-report-summary" data-html5-object-report-summary>
{escape(" · ".join(bits))}
</p>
"""
def render_html5_review(
project_id: str,
findings: list[dict] | None,
*,
title: str = "Review",
oob: bool = False,
) -> str:
oob_attr = ' hx-swap-oob="outerHTML"' if oob else ""
live_attr = ' sse-swap="project-review"' if title == "Review" and not oob else ""
if findings is None:
return f"""
<div
class="review-panel"
data-html5-review
hx-get="/html5/projects/{quote(project_id)}/review"
hx-trigger="load"
{live_attr}
hx-swap="outerHTML"
{oob_attr}
>
<div class="panel-title">{escape(title)}</div>
<p class="muted padded">Сервер готовит findings.</p>
</div>
"""
if not findings:
body = '<p class="muted padded">Findings не найдены</p>'
else:
body = "".join(_review_item(project_id, finding) for finding in findings[:12])
return f"""
<div
class="review-panel"
data-html5-review
hx-get="/html5/projects/{quote(project_id)}/review"
{live_attr}
hx-swap="outerHTML"
{oob_attr}
>
<div class="panel-title">{escape(title)} · {len(findings)}</div>
{_review_summary(findings)}
<div class="review-list">{body}</div>
</div>
"""
def render_html5_object_context(
project_id: str,
schema: object | None,
impact: object | None,
access: object | None = None,
ui: object | None = None,
runtime: Iterable[object] | None = None,
knowledge: Iterable[object] | None = None,
privacy: object | None = None,
integrations: Iterable[object] | None = None,
flowchart: object | None = None,
mode: str = "overview",
) -> str:
if schema is None or impact is None:
return f"""
<div class="object-context" data-html5-object-context>
<div class="panel-title">Object context</div>
<p class="muted padded">Выберите объект метаданных, чтобы сервер собрал schema/impact контекст проекта {escape(project_id)}.</p>
</div>
"""
obj = getattr(schema, "object", None) or getattr(impact, "object", None)
name = getattr(obj, "qualified_name", None) or getattr(obj, "name", None) or "object"
attributes = getattr(schema, "attributes", []) or []
sections = getattr(schema, "tabular_sections", []) or []
modules = getattr(impact, "modules", []) or []
routines = getattr(impact, "routines", []) or []
forms = getattr(impact, "forms", []) or []
commands = getattr(impact, "commands", []) or []
roles = getattr(impact, "roles", []) or []
jobs = getattr(impact, "jobs", []) or []
callees = getattr(impact, "callees", []) or []
query_tables = getattr(impact, "query_tables", []) or []
writes = getattr(impact, "writes", []) or []
grants = getattr(access, "grants", []) if access is not None else []
ui_forms = getattr(ui, "forms", []) if ui is not None else []
runtime_items = list(runtime or [])
knowledge_items = list(knowledge or [])
privacy_markers = getattr(privacy, "markers", []) if privacy is not None else []
integration_items = list(integrations or [])
flow_nodes = getattr(flowchart, "nodes", []) if flowchart is not None else []
flow_edges = getattr(flowchart, "edges", []) if flowchart is not None else []
normalized_mode = mode if mode in {"overview", "schema", "impact", "privacy"} else "overview"
if normalized_mode == "schema":
compact_body = (
''.join(_named_node_item("attr", item) for item in attributes[:12])
or '<p class="muted padded">Реквизиты не найдены</p>'
)
compact_body += ''.join(_tabular_section_item(item) for item in sections[:8])
compact_body += ''.join(_ui_form_item(project_id, item) for item in ui_forms[:8])
elif normalized_mode == "impact":
compact_body = ''.join(_integration_endpoint_item(item) for item in integration_items[:8])
compact_body += ''.join(_named_node_item("command", item) for item in commands[:8])
compact_body += ''.join(_named_node_item("read", item) for item in query_tables[:8])
compact_body += ''.join(_named_node_item("write", item) for item in writes[:8])
compact_body += ''.join(_named_node_item("call", item) for item in callees[:8])
compact_body += ''.join(_flowchart_edge_item(project_id, item, flow_nodes, 1) for item in flow_edges[:8])
compact_body += ''.join(_named_node_item("routine", item) for item in routines[:8])
compact_body += ''.join(_named_node_item("job", item) for item in jobs[:6])
compact_body = compact_body or '<p class="muted padded">Impact-связи не найдены</p>'
elif normalized_mode == "privacy":
compact_body = ''.join(_role_access_item(item) for item in grants[:12])
compact_body += ''.join(_privacy_marker_item(item) for item in privacy_markers[:12])
compact_body = compact_body or '<p class="muted padded">Доступы и privacy-маркеры не найдены</p>'
else:
compact_body = (
''.join(_named_node_item("attr", item) for item in attributes[:6])
or '<p class="muted padded">Реквизиты не найдены</p>'
)
compact_body += ''.join(_tabular_section_item(item) for item in sections[:4])
compact_body += ''.join(_ui_form_item(project_id, item) for item in ui_forms[:4])
compact_body += ''.join(_role_access_item(item) for item in grants[:6])
compact_body += ''.join(_integration_endpoint_item(item) for item in integration_items[:4])
compact_body += ''.join(_named_node_item("command", item) for item in commands[:6])
compact_body += ''.join(_named_node_item("read", item) for item in query_tables[:4])
compact_body += ''.join(_named_node_item("write", item) for item in writes[:4])
compact_body += ''.join(_named_node_item("call", item) for item in callees[:6])
compact_body += ''.join(_flowchart_edge_item(project_id, item, flow_nodes, 1) for item in flow_edges[:8])
compact_body += ''.join(_runtime_summary_item(item) for item in runtime_items[:6])
compact_body += ''.join(_knowledge_record_item(item) for item in knowledge_items[:6])
compact_body += ''.join(_privacy_marker_item(item) for item in privacy_markers[:6])
compact_body += ''.join(_named_node_item("routine", item) for item in routines[:6])
compact_body += ''.join(_named_node_item("job", item) for item in jobs[:4])
return f"""
<div class="object-context" data-html5-object-context data-html5-object-name="{escape(str(name))}" data-html5-object-mode="{escape(normalized_mode)}">
<div class="panel-title">Object context · {escape(normalized_mode)}</div>
{_object_breadcrumb(str(name))}
<article class="object-focus">
<strong>{escape(str(name))}</strong>
<span>{escape(str(getattr(obj, "kind", "object")))}</span>
</article>
{_object_action_links(project_id, str(name), getattr(obj, "lineage_id", ""), modules, normalized_mode)}
{_object_summary(
len(attributes),
len(sections),
len(commands),
len(query_tables),
len(writes),
len(callees),
len(integration_items),
len(grants) or len(roles),
len(runtime_items),
len(privacy_markers),
)}
<dl class="report-grid">
{_metric("Attrs", len(attributes))}
{_metric("Tables", len(sections))}
{_metric("Modules", len(modules))}
{_metric("Routines", len(routines))}
{_metric("Forms", len(ui_forms) or len(forms))}
{_metric("Commands", len(commands))}
{_metric("Roles", len(grants) or len(roles))}
{_metric("Reads", len(query_tables))}
{_metric("Writes", len(writes))}
{_metric("Calls", len(callees))}
{_metric("Integrations", len(integration_items))}
{_metric("Graph nodes", len(flow_nodes))}
{_metric("Graph edges", len(flow_edges))}
{_metric("Runtime", len(runtime_items))}
{_metric("Knowledge", len(knowledge_items))}
{_metric("Privacy", len(privacy_markers))}
</dl>
<div class="compact-list">
{compact_body}
</div>
</div>
"""
def _review_summary(findings: list[dict]) -> str:
severities = Counter(str(item.get("severity") or item.get("level") or "INFO") for item in findings)
titles = Counter(str(item.get("title") or item.get("code") or "Finding") for item in findings)
severity_text = ", ".join(f"{name}: {count}" for name, count in sorted(severities.items())) or "no severities"
title_text = ", ".join(f"{name}: {count}" for name, count in sorted(titles.items())[:4]) or "no findings"
return f"""
<p class="review-summary" data-html5-review-summary>
{escape(str(len(findings)))} findings · {escape(severity_text)} · {escape(title_text)}
</p>
"""
def _review_item(project_id: str, finding: dict) -> str:
title = str(finding.get("title") or finding.get("code") or "Finding")
severity = str(finding.get("severity") or finding.get("level") or "INFO")
message = str(finding.get("message") or finding.get("description") or "")
source_path = str(finding.get("source_path") or finding.get("path") or "")
line = finding.get("line_start") or finding.get("line")
location = f"{source_path}:{line}" if source_path and line else source_path
source_link = (
f"""
<a
href="/html5/projects/{quote(project_id)}/source/by-path?path={quote(source_path, safe='')}"
hx-get="/html5/projects/{quote(project_id)}/source/by-path?path={quote(source_path, safe='')}"
hx-target="[data-html5-source]"
hx-swap="outerHTML"
data-html5-review-source="{escape(source_path)}"
>Source</a>
"""
if source_path
else ""
)
return f"""
<article class="review-item" data-html5-review-finding="{escape(severity)}">
<strong>{escape(title)}</strong>
<span>{escape(severity)}</span>
<small>{escape(message or location or "no details")}</small>
<span class="inline-actions">{source_link}</span>
</article>
"""
def _named_node_item(label: str, node: object) -> str:
name = getattr(node, "qualified_name", None) or getattr(node, "name", "")
kind = getattr(node, "kind", label)
return f"""
<article class="object-context-item" data-html5-object-context-item="{escape(label)}">
<strong>{escape(str(name))}</strong>
<small>{escape(str(kind))}</small>
</article>
"""
def _object_breadcrumb(object_name: str) -> str:
parts = [part for part in object_name.split(".") if part]
if not parts:
return ""
items = "".join(f"<span>{escape(part)}</span>" for part in parts)
return f'<nav class="object-breadcrumb" data-html5-object-breadcrumb>{items}</nav>'
def _object_summary(
attributes: int,
sections: int,
commands: int,
reads: int,
writes: int,
calls: int,
integrations: int,
access_rules: int,
runtime_signals: int,
privacy_markers: int,
) -> str:
impact_total = reads + writes + calls
status_bits = [
f"{attributes} attrs",
f"{sections} tables",
f"{commands} commands",
f"{impact_total} impact links",
f"{access_rules} access rules",
]
if integrations:
status_bits.append(f"{integrations} integrations")
if runtime_signals:
status_bits.append(f"{runtime_signals} runtime signals")
if privacy_markers:
status_bits.append(f"{privacy_markers} privacy markers")
return f"""
<p class="object-summary" data-html5-object-summary>
{escape(" · ".join(status_bits))}
</p>
"""
def _object_action_links(
project_id: str,
object_name: str,
lineage_id: object,
modules: Iterable[object],
active_mode: str,
) -> str:
quoted_project = quote(project_id)
quoted_object = quote(object_name, safe="")
lineage = str(lineage_id or "")
module_links = "".join(_module_action_link(quoted_project, module) for module in _sorted_object_modules(modules))
symbol_link = (
f'<a class="button" href="/html5/projects/{quoted_project}/symbols/{quote(lineage, safe="")}/detail" '
'hx-get="/html5/projects/{project}/symbols/{lineage}/detail" hx-target="[data-html5-symbol-detail]" hx-swap="outerHTML">Symbol</a>'.format(
project=quoted_project,
lineage=quote(lineage, safe=""),
)
if lineage
else ""
)
def active_attrs(mode: str) -> str:
return ' aria-current="page" data-html5-object-action-active="true"' if mode == active_mode else ""
return f"""
<nav class="object-actions" data-html5-object-actions>
<a
class="button"
href="/html5/projects/{quoted_project}/objects/context/{quoted_object}"
hx-get="/html5/projects/{quoted_project}/objects/context/{quoted_object}"
hx-target="[data-html5-object-context]"
hx-swap="outerHTML"
{active_attrs("overview")}
>Overview</a>
<a
class="button"
href="/projects/{quoted_project}/objects/schema/{quoted_object}"
hx-get="/html5/projects/{quoted_project}/objects/context/{quoted_object}?mode=schema"
hx-target="[data-html5-object-context]"
hx-swap="outerHTML"
{active_attrs("schema")}
>Schema</a>
<a
class="button"
href="/projects/{quoted_project}/objects/impact/{quoted_object}"
hx-get="/html5/projects/{quoted_project}/objects/context/{quoted_object}?mode=impact"
hx-target="[data-html5-object-context]"
hx-swap="outerHTML"
{active_attrs("impact")}
>Impact</a>
<a
class="button"
href="/projects/{quoted_project}/objects/privacy/{quoted_object}"
hx-get="/html5/projects/{quoted_project}/objects/context/{quoted_object}?mode=privacy"
hx-target="[data-html5-object-context]"
hx-swap="outerHTML"
{active_attrs("privacy")}
>Privacy</a>
<a
class="button"
href="/html5/projects/{quoted_project}/flowchart?focus={quoted_object}&depth=1"
hx-get="/html5/projects/{quoted_project}/flowchart?focus={quoted_object}&depth=1"
hx-target="[data-html5-flowchart]"
hx-swap="outerHTML"
>Flowchart</a>
{module_links}
{symbol_link}
</nav>
"""
def _sorted_object_modules(modules: Iterable[object]) -> list[object]:
priority = {
"OBJECT_MODULE": 0,
"MANAGER_MODULE": 1,
"RECORD_SET_MODULE": 2,
"FORM_MODULE": 3,
"MODULE": 9,
}
return sorted(
list(modules),
key=lambda module: (
priority.get(_module_role(module), 8),
str((getattr(module, "attributes", {}) or {}).get("form_name") or ""),
str(getattr(module, "qualified_name", "") or getattr(module, "name", "")),
),
)
def _module_action_link(quoted_project: str, module: object) -> str:
lineage = str(getattr(module, "lineage_id", "") or "")
if not lineage:
return ""
quoted_lineage = quote(lineage, safe="")
label = _module_action_label(module)
return f"""
<a
class="button"
href="/html5/projects/{quoted_project}/source/{quoted_lineage}"
hx-get="/html5/projects/{quoted_project}/source/{quoted_lineage}"
hx-target="[data-html5-source]"
hx-swap="outerHTML"
data-html5-module-action="{escape(_module_role(module))}"
>{escape(label)}</a>
"""
def _module_action_label(module: object) -> str:
attributes = getattr(module, "attributes", {}) or {}
role = _module_role(module)
if role == "OBJECT_MODULE":
return "Модуль объекта"
if role == "MANAGER_MODULE":
return "Модуль менеджера"
if role == "RECORD_SET_MODULE":
return "Модуль набора"
if role == "FORM_MODULE":
form_name = str(attributes.get("form_name") or "")
return f"Модуль формы {form_name}" if form_name else "Модуль формы"
return str(getattr(module, "name", None) or "Модуль")
def _module_role(module: object) -> str:
attributes = getattr(module, "attributes", {}) or {}
return str(attributes.get("module_role") or attributes.get("role") or getattr(module, "module_role", "") or "MODULE")
def _tabular_section_item(section: object) -> str:
tabular_section = getattr(section, "tabular_section", None)
columns = getattr(section, "columns", []) or []
name = getattr(tabular_section, "qualified_name", None) or getattr(tabular_section, "name", "")
return f"""
<article class="object-context-item" data-html5-object-context-item="tabular-section">
<strong>{escape(str(name))}</strong>
<small>{escape(str(len(columns)))} columns</small>
</article>
"""
def _role_access_item(grant: object) -> str:
role = getattr(grant, "role", None)
permissions = getattr(grant, "permissions", {}) or {}
role_name = getattr(role, "qualified_name", None) or getattr(role, "name", "role")
enabled = [
str(key)
for key, value in sorted(permissions.items())
if str(value).lower() in {"true", "1", "yes", "да"}
]
permission_text = ", ".join(enabled) if enabled else "permissions unavailable"
return f"""
<article class="object-context-item" data-html5-object-context-item="role-access">
<strong>{escape(str(role_name))}</strong>
<small>{escape(permission_text)}</small>
</article>
"""
def _ui_form_item(project_id: str, form_semantics: object) -> str:
form = getattr(form_semantics, "form", None)
commands = getattr(form_semantics, "commands", []) or []
elements = getattr(form_semantics, "elements", []) or []
handlers = getattr(form_semantics, "command_handlers", {}) or {}
form_name = getattr(form, "qualified_name", None) or getattr(form, "name", "form")
form_lineage = str(getattr(form, "lineage_id", "") or "")
command_names = [
str(getattr(command, "name", getattr(command, "qualified_name", "")))
for command in commands[:3]
]
handler_names = [
str(getattr(handler, "name", getattr(handler, "qualified_name", "")))
for handler in list(handlers.values())[:3]
]
details = []
if command_names:
details.append("cmd: " + ", ".join(command_names))
if handler_names:
details.append("handler: " + ", ".join(handler_names))
if elements:
details.append(f"{len(elements)} elements")
return f"""
<article class="object-context-item" data-html5-object-context-item="ui-form">
<strong>{escape(str(form_name))}</strong>
<small>{escape(" · ".join(details) or "UI metadata")}</small>
<span class="inline-actions">
<a
href="/html5/projects/{quote(project_id)}/forms/editor?form={quote(form_lineage, safe='')}"
hx-get="/html5/projects/{quote(project_id)}/forms/editor?form={quote(form_lineage, safe='')}"
hx-target="body"
hx-swap="outerHTML"
data-html5-form-editor-link
>Редактор формы</a>
</span>
</article>
"""
def _runtime_summary_item(item: object) -> str:
node = getattr(item, "node", None)
name = getattr(node, "qualified_name", None) or getattr(node, "name", "runtime")
signal_count = getattr(item, "signal_count", 0)
error_count = getattr(item, "error_count", 0)
max_duration = getattr(item, "max_duration_ms", None)
duration_text = f" · max {max_duration} ms" if max_duration is not None else ""
return f"""
<article class="object-context-item" data-html5-object-context-item="runtime">
<strong>{escape(str(name))}</strong>
<small>{escape(str(signal_count))} signals · {escape(str(error_count))} errors{escape(duration_text)}</small>
</article>
"""
def _knowledge_record_item(record: object) -> str:
title = str(getattr(record, "title", "knowledge"))
scope = _enum_text(getattr(record, "scope", ""))
body = str(getattr(record, "body", "") or "")
record_id = str(getattr(record, "record_id", ""))
return f"""
<article class="object-context-item" data-html5-object-context-item="knowledge">
<strong>{escape(title)}</strong>
<small>{escape(scope)} · {escape(record_id)} · {escape(body[:120])}</small>
</article>
"""
def _privacy_marker_item(marker: object) -> str:
classification = _enum_text(getattr(marker, "classification", ""))
reason = str(getattr(marker, "reason", "") or "")
target_id = str(getattr(marker, "target_id", "") or "target unavailable")
return f"""
<article class="object-context-item" data-html5-object-context-item="privacy">
<strong>{escape(classification or "privacy")}</strong>
<small>{escape(reason or target_id)}</small>
</article>
"""
def _integration_endpoint_item(endpoint: object) -> str:
name = str(getattr(endpoint, "name", "") or "integration")
kind = _enum_text(getattr(endpoint, "kind", "UNKNOWN"))
direction = str(getattr(endpoint, "direction", "UNKNOWN") or "UNKNOWN")
owner = str(getattr(endpoint, "owner", "") or "owner unavailable")
return f"""
<article class="object-context-item" data-html5-object-context-item="integration">
<strong>{escape(name)}</strong>
<small>{escape(kind)} · {escape(direction)} · {escape(owner)}</small>
</article>
"""
def _object_context_url(project_id: str, name: str) -> str:
return f"/html5/projects/{quote(project_id)}/objects/context/{quote(name, safe='')}"
def _flowchart_focus_link(project_id: str, name: str, depth: int) -> str:
url = _flowchart_url(project_id, name, depth)
return f"""
<a
href="{url}"
hx-get="{url}"
hx-target="[data-html5-flowchart]"
hx-swap="outerHTML"
data-html5-flowchart-focus="{escape(name)}"
>{escape(name)}</a>
"""
def _flowchart_context_link(project_id: str, name: str, kind: str) -> str:
if kind not in _HTML5_OBJECT_CONTEXT_KINDS:
return ""
url = _object_context_url(project_id, name)
return f"""
<a
href="{url}"
hx-get="{url}"
hx-target="[data-html5-object-context]"
hx-swap="outerHTML"
data-html5-flowchart-context="{escape(name)}"
>Context</a>
"""
def _flowchart_edge_item(project_id: str, edge: object, nodes: Iterable[object], depth: int) -> str:
node_names = {
str(getattr(node, "id", "")): str(getattr(node, "qualified_name", "") or getattr(node, "label", ""))
for node in nodes
}
node_kinds = {
str(getattr(node, "id", "")): str(getattr(node, "kind", "") or "NODE")
for node in nodes
}
source = node_names.get(str(getattr(edge, "source", "")), str(getattr(edge, "source", "")))
target = node_names.get(str(getattr(edge, "target", "")), str(getattr(edge, "target", "")))
source_kind = node_kinds.get(str(getattr(edge, "source", "")), "")
target_kind = node_kinds.get(str(getattr(edge, "target", "")), "")
label = str(getattr(edge, "label", "") or getattr(edge, "kind", "") or "link")
kind = str(getattr(edge, "kind", "") or "FLOW")
return f"""
<article class="object-context-item" data-html5-object-context-item="flow-edge">
<strong>{escape(label)}</strong>
<small>{_flowchart_focus_link(project_id, source, depth)} -> {_flowchart_focus_link(project_id, target, depth)} · {escape(kind)}</small>
<span class="inline-actions">
{_flowchart_context_link(project_id, source, source_kind)}
{_flowchart_context_link(project_id, target, target_kind)}
</span>
</article>
"""
def _flowchart_url(project_id: str, focus: str | None, depth: int) -> str:
params = []
if focus:
params.append(f"focus={quote(focus, safe='')}")
params.append(f"depth={depth}")
return f"/html5/projects/{quote(project_id)}/flowchart?{'&'.join(params)}"
def _flowchart_depth_actions(project_id: str, focus: str | None, active_depth: int) -> str:
buttons = []
for depth in [1, 2, 3]:
active = ' aria-current="page" data-html5-object-action-active="true"' if depth == active_depth else ""
url = _flowchart_url(project_id, focus, depth)
buttons.append(
f"""
<a
class="button"
href="{url}"
hx-get="{url}"
hx-target="[data-html5-flowchart]"
hx-swap="outerHTML"
{active}
>Depth {depth}</a>
"""
)
return f'<nav class="object-actions" data-html5-flowchart-actions>{"".join(buttons)}</nav>'
def _flowchart_node_item(project_id: str, node: object, depth: int) -> str:
name = str(getattr(node, "qualified_name", "") or getattr(node, "label", "") or "node")
kind = str(getattr(node, "kind", "") or "NODE")
level = getattr(node, "level", 0)
count = getattr(node, "count", 1)
url = _flowchart_url(project_id, name, depth)
return f"""
<article
class="object-context-item"
data-html5-flowchart-node="{escape(kind)}"
data-html5-flowchart-focus="{escape(name)}"
hx-get="{url}"
hx-target="[data-html5-flowchart]"
hx-swap="outerHTML"
>
<strong>{escape(name)}</strong>
<small>{escape(kind)} · level {escape(str(level))} · count {escape(str(count))}</small>
<span class="inline-actions">
{_flowchart_context_link(project_id, name, kind)}
</span>
</article>
"""
@@ -0,0 +1,223 @@
from __future__ import annotations
from collections import Counter
from html import escape
from typing import Iterable
from urllib.parse import quote
from api_server.html5 import _enum_text, _metric, _page
def render_html5_operations(
jobs: Iterable[object],
*,
project_id: str = "",
status: str = "",
kind: str = "",
) -> str:
job_list = list(jobs)
filter_query = _operation_filter_query(project_id=project_id, status=status, kind=kind)
return _page(
"SFERA HTML5 operations",
f"""
<main class="shell" data-html5-page="operations">
<section class="hero">
<div>
<p class="eyebrow">SFERA HTML5</p>
<h1>Операции сервера</h1>
<p class="lead">Очередь фоновых задач отрисовывается API-сервером и обновляется SSE без React runtime.</p>
</div>
<div class="hero-metrics">
<strong>{len(job_list)}</strong>
<span>jobs</span>
</div>
</section>
<section class="band" hx-ext="sse" sse-connect="/html5/operations/events{filter_query}">
<div class="section-title">
<h2>Очередь</h2>
<a class="button" href="/html5">Проекты</a>
</div>
{_operation_filter_form(project_id=project_id, status=status, kind=kind)}
<div data-html5-operations-summary-stream sse-swap="operations-summary" hx-swap="innerHTML">
{render_html5_operation_summary(job_list)}
</div>
<div class="table-wrap">
<table data-html5-operations>
<thead>
<tr><th>Job</th><th>Проект</th><th>Статус</th><th>Stage</th><th>Сообщение</th><th></th></tr>
</thead>
<tbody
data-html5-operations-body
sse-swap="operations-jobs"
hx-swap="innerHTML"
>{render_html5_operation_rows(job_list)}</tbody>
</table>
</div>
{render_html5_operation_detail(None)}
</section>
</main>
""",
)
def render_html5_operation_summary(jobs: Iterable[object]) -> str:
job_list = list(jobs)
counts = Counter(_enum_text(getattr(job, "status", "unknown")) for job in job_list)
running = counts.get("RUNNING", 0)
queued = counts.get("QUEUED", 0)
succeeded = counts.get("SUCCEEDED", 0)
failed = counts.get("FAILED", 0)
return f"""
<div
class="ops-summary"
data-html5-operations-summary
>
{_metric("Всего", len(job_list))}
{_metric("В работе", running)}
{_metric("В очереди", queued)}
{_metric("Успешно", succeeded)}
{_metric("Ошибки", failed)}
</div>
"""
def render_html5_operation_rows(jobs: Iterable[object]) -> str:
rows = "\n".join(_operation_row(job) for job in jobs)
if not rows:
return '<tr><td colspan="6" class="muted">Фоновые операции пока не запускались</td></tr>'
return rows
def render_html5_operation_detail(job: object | None) -> str:
if job is None:
return """
<div class="operation-detail" data-html5-operation-detail>
<div class="panel-title flush">Детали операции</div>
<p class="muted padded">Выберите job в таблице, чтобы сервер отрисовал payload, result и логи.</p>
</div>
"""
job_id = str(getattr(job, "job_id", ""))
kind = str(getattr(job, "kind", ""))
status = _enum_text(getattr(job, "status", ""))
payload = getattr(job, "payload", {}) or {}
result = getattr(job, "result", {}) or {}
error = str(getattr(job, "error", "") or "")
logs = payload.get("logs") if isinstance(payload.get("logs"), list) else []
return f"""
<div class="operation-detail" data-html5-operation-detail data-html5-operation-detail-id="{escape(job_id)}">
<div class="panel-title flush">Детали операции</div>
<article class="setup-detail">
<strong>{escape(kind)} · {escape(status)}</strong>
<span>{escape(job_id)}</span>
<small>{escape(error or "no error")}</small>
</article>
<div class="report-grid">
{_metric("Payload keys", len(payload))}
{_metric("Result keys", len(result))}
{_metric("Logs", len(logs))}
{_metric("Updated", str(getattr(job, "updated_at", ""))[:19])}
</div>
<pre class="code">{escape(_compact_mapping(payload))}</pre>
<pre class="code">{escape(_compact_mapping(result))}</pre>
<ul class="job-log">{"".join(f"<li>{escape(str(item))}</li>" for item in logs[-8:]) or "<li>Лог пока пустой</li>"}</ul>
</div>
"""
def filter_html5_operation_jobs(
jobs: Iterable[object],
*,
project_id: str = "",
status: str = "",
kind: str = "",
limit: int = 50,
) -> list[object]:
normalized_project = project_id.strip().casefold()
normalized_status = status.strip().casefold()
normalized_kind = kind.strip().casefold()
filtered = []
for job in jobs:
payload = getattr(job, "payload", {}) or {}
if normalized_project and str(payload.get("project_id") or "").casefold() != normalized_project:
continue
if normalized_status and _operation_value(getattr(job, "status", "")).casefold() != normalized_status:
continue
if normalized_kind and _operation_value(getattr(job, "kind", "")).casefold() != normalized_kind:
continue
filtered.append(job)
return sorted(filtered, key=lambda job: getattr(job, "updated_at", ""), reverse=True)[:limit]
def latest_html5_import_job(jobs: Iterable[object], project_id: str) -> object | None:
import_jobs = [
job
for job in jobs
if (getattr(job, "payload", {}) or {}).get("project_id") == project_id
and _operation_value(getattr(job, "kind", "")) == "SERVER_IMPORT"
]
return max(import_jobs, key=lambda job: getattr(job, "updated_at", "")) if import_jobs else None
def _operation_row(job: object) -> str:
job_id = str(getattr(job, "job_id", ""))
kind = str(getattr(job, "kind", ""))
status = _enum_text(getattr(job, "status", ""))
payload = getattr(job, "payload", {}) or {}
project_id = str(payload.get("project_id") or "")
stage = str(payload.get("stage") or "")
message = str(payload.get("message") or getattr(job, "error", "") or "")
project_link = (
f'<a href="/html5/projects/{quote(project_id)}/setup">{escape(project_id)}</a>'
if project_id
else '<span class="muted">-</span>'
)
return f"""
<tr data-html5-operation="{escape(job_id)}">
<td><strong>{escape(kind)}</strong><small>{escape(job_id)}</small></td>
<td>{project_link}</td>
<td>{escape(status)}</td>
<td>{escape(stage or "-")}</td>
<td>{escape(message or "-")}</td>
<td>
<button
type="button"
hx-get="/html5/operations/jobs/{quote(job_id)}/detail"
hx-target="[data-html5-operation-detail]"
hx-swap="outerHTML"
>Открыть</button>
</td>
</tr>"""
def _compact_mapping(value: dict) -> str:
if not value:
return "{}"
rows = [f"{key}: {value[key]}" for key in sorted(value)[:20]]
return "\n".join(rows)
def _operation_filter_form(*, project_id: str, status: str, kind: str) -> str:
return f"""
<form class="ops-filter" data-html5-operations-filter method="get" action="/html5/operations">
<input name="project_id" value="{escape(project_id)}" placeholder="project_id" />
<input name="status" value="{escape(status)}" placeholder="status" />
<input name="kind" value="{escape(kind)}" placeholder="kind" />
<button type="submit">Фильтр</button>
<a class="button" href="/html5/operations">Сброс</a>
</form>
"""
def _operation_filter_query(*, project_id: str, status: str, kind: str) -> str:
params = []
if project_id:
params.append(f"project_id={quote(project_id)}")
if status:
params.append(f"status={quote(status)}")
if kind:
params.append(f"kind={quote(kind)}")
return f"?{'&'.join(params)}" if params else ""
def _operation_value(value: object) -> str:
return str(getattr(value, "value", value))
@@ -0,0 +1,94 @@
from __future__ import annotations
import time
from collections.abc import Callable, Iterable, Iterator
from fastapi import HTTPException
from api_server.html5_operations import (
filter_html5_operation_jobs,
render_html5_operation_detail,
render_html5_operation_rows,
render_html5_operation_summary,
render_html5_operations,
)
from api_server.html5_sse import html5_sse_comment, html5_sse_if_changed
def html5_operations_page(
*,
jobs: Iterable[object],
project_id: str = "",
status: str = "",
kind: str = "",
) -> str:
return render_html5_operations(
_filtered_jobs(jobs, project_id=project_id, status=status, kind=kind),
project_id=project_id,
status=status,
kind=kind,
)
def html5_operation_rows(
*,
jobs: Iterable[object],
project_id: str = "",
status: str = "",
kind: str = "",
) -> str:
return render_html5_operation_rows(_filtered_jobs(jobs, project_id=project_id, status=status, kind=kind))
def html5_operation_detail(*, jobs_by_id: dict[str, object], job_id: str) -> str:
job = jobs_by_id.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"Unknown operation job: {job_id}")
return render_html5_operation_detail(job)
def html5_operation_summary(
*,
jobs: Iterable[object],
project_id: str = "",
status: str = "",
kind: str = "",
) -> str:
return render_html5_operation_summary(_filtered_jobs(jobs, project_id=project_id, status=status, kind=kind))
def html5_operations_event_stream(
*,
jobs: Callable[[], Iterable[object]],
once: bool = False,
project_id: str = "",
status: str = "",
kind: str = "",
) -> Iterator[str]:
last_fragments: dict[str, str] = {}
while True:
yield html5_sse_comment("operations heartbeat")
filtered = _filtered_jobs(jobs(), project_id=project_id, status=status, kind=kind)
yield from html5_sse_if_changed(
last_fragments,
"operations-summary",
render_html5_operation_summary(filtered),
)
yield from html5_sse_if_changed(
last_fragments,
"operations-jobs",
render_html5_operation_rows(filtered),
)
if once:
break
time.sleep(3)
def _filtered_jobs(
jobs: Iterable[object],
*,
project_id: str = "",
status: str = "",
kind: str = "",
) -> list[object]:
return filter_html5_operation_jobs(jobs, project_id=project_id, status=status, kind=kind)
@@ -0,0 +1,136 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Callable, Iterable
from typing import Any
from fastapi import HTTPException
from api_server.html5_authoring import render_html5_authoring_changes
from api_server.html5_editor import (
render_html5_source,
render_html5_status,
render_html5_symbol_detail,
render_html5_symbols,
)
from api_server.html5_forms import form_value
from api_server.html5_inspector import render_html5_flowchart, render_html5_project_report, render_html5_review
from api_server.html5_projects import render_html5_index, render_html5_project_rows
from api_server.html5_sse import html5_sse_comment, html5_sse_if_changed
from sir import SirSnapshot
def html5_index_page(projects: Iterable[object]) -> str:
return render_html5_index(projects)
async def html5_create_project_rows(
*,
form: dict[str, list[str]],
create_project: Callable[[object], Any],
create_request: Callable[..., object],
project_summaries: Callable[[], Iterable[object]],
) -> str:
project_id = form_value(form, "project_id")
if not project_id:
raise HTTPException(status_code=400, detail="project_id is required.")
await create_project(create_request(project_id=project_id, name=form_value(form, "name")))
return render_html5_project_rows(project_summaries())
async def html5_delete_project_rows(
*,
project_id: str,
form: dict[str, list[str]],
delete_project: Callable[[str, object], Any],
delete_request: Callable[..., object],
project_summaries: Callable[[], Iterable[object]],
) -> str:
await delete_project(project_id, delete_request(confirmation=form_value(form, "confirmation") or ""))
return render_html5_project_rows(project_summaries())
async def html5_project_event_stream(
*,
project_id: str,
project_snapshot: Callable[[str], SirSnapshot],
project_report: Callable[[str], Any],
review: Callable[[str], Any],
flowchart: Callable[..., Any],
authoring_changes: Callable[[str], Iterable[object]],
once: bool = False,
) -> AsyncIterator[str]:
last_fragments: dict[str, str] = {}
while True:
yield html5_sse_comment(f"project {project_id} heartbeat")
try:
snapshot = project_snapshot(project_id)
status = render_html5_status(project_id, snapshot)
report = await project_report(project_id)
findings = await review(project_id)
graph = await flowchart(project_id, focus=None, depth=1, limit=80)
except HTTPException as error:
status = f'<span>project: {project_id}</span><span>error: {error.detail}</span>'
report = None
findings = None
graph = None
for event_text in html5_sse_if_changed(last_fragments, "status", status):
yield event_text
for event_text in html5_sse_if_changed(
last_fragments,
"authoring-changes",
render_html5_authoring_changes(project_id, authoring_changes(project_id)),
):
yield event_text
if report is not None:
for event_text in html5_sse_if_changed(last_fragments, "project-report", render_html5_project_report(project_id, report)):
yield event_text
if findings is not None:
for event_text in html5_sse_if_changed(last_fragments, "project-review", render_html5_review(project_id, findings)):
yield event_text
if graph is not None:
for event_text in html5_sse_if_changed(last_fragments, "project-flowchart", render_html5_flowchart(project_id, graph)):
yield event_text
if once:
break
await asyncio.sleep(5)
def html5_project_symbols(*, snapshot: SirSnapshot, q: str, project_id: str) -> str:
return render_html5_symbols(snapshot, q, project_id)
def html5_project_symbol_detail(*, project_id: str, references: object) -> str:
return render_html5_symbol_detail(project_id, references)
def html5_project_source_by_path(*, snapshot: SirSnapshot, path: str) -> str:
node = next(
(
item
for item in snapshot.nodes
if item.source_ref is not None and item.source_ref.source_path == path
),
None,
)
if node is None:
raise HTTPException(status_code=404, detail=f"Source not found: {path}")
return render_html5_source(node)
def html5_project_source_by_lineage(*, node: object | None, lineage_id: str) -> str:
if node is None:
raise HTTPException(status_code=404, detail=f"Lineage not found: {lineage_id}")
return render_html5_source(node)
def html5_project_report_fragment(*, project_id: str, report: object) -> str:
return render_html5_project_report(project_id, report)
def html5_project_review_fragment(*, project_id: str, findings: object) -> str:
return render_html5_review(project_id, findings)
def html5_project_flowchart_fragment(*, project_id: str, flowchart: object, focus: str | None, depth: int) -> str:
return render_html5_flowchart(project_id, flowchart, focus=focus, depth=depth)
@@ -0,0 +1,100 @@
from __future__ import annotations
from html import escape
from typing import Iterable
from urllib.parse import quote
from api_server.html5 import _page
def render_html5_index(projects: Iterable[object]) -> str:
project_list = list(projects)
return _page(
"SFERA HTML5",
f"""
<main class="shell" data-html5-page="projects">
<section class="hero">
<div>
<p class="eyebrow">SFERA HTML5</p>
<h1>Server-first рабочее место 1С</h1>
<p class="lead">Основной HTML собирает API-сервер. Браузер получает готовую страницу без React/Next runtime.</p>
</div>
<div class="hero-metrics">
<strong>{len(project_list)}</strong>
<span>проектов</span>
</div>
</section>
<section class="band">
<div class="section-title">
<h2>Проекты</h2>
<div class="toolbar-links">
<a class="button" href="/html5/operations">Операции</a>
<a class="button" href="/docs">API docs</a>
</div>
</div>
{render_html5_project_create_form()}
<div class="table-wrap">
<table data-html5-projects>
<thead>
<tr><th>Проект</th><th>Статус</th><th>Snapshot</th><th></th></tr>
</thead>
<tbody data-html5-projects-body>{render_html5_project_rows(project_list)}</tbody>
</table>
</div>
</section>
</main>
""",
)
def render_html5_project_create_form() -> str:
return """
<form
class="create-project"
method="post"
action="/html5/projects"
data-html5-project-create
hx-post="/html5/projects"
hx-target="[data-html5-projects-body]"
hx-swap="innerHTML"
>
<input name="project_id" placeholder="project_id" required />
<input name="name" placeholder="Название проекта" />
<button type="submit">Создать</button>
</form>
"""
def render_html5_project_rows(projects: Iterable[object]) -> str:
project_rows = "\n".join(_project_row(project) for project in projects)
if not project_rows:
return '<tr><td colspan="4" class="muted">Проекты пока не настроены</td></tr>'
return project_rows
def _project_row(project: object) -> str:
project_id = str(getattr(project, "project_id", ""))
name = str(getattr(project, "name", project_id))
status = str(getattr(project, "status", "unknown"))
has_snapshot = bool(getattr(project, "has_snapshot", False))
return f"""
<tr data-html5-project="{escape(project_id)}">
<td><strong>{escape(name)}</strong><small>{escape(project_id)}</small></td>
<td>{escape(status)}</td>
<td>{'yes' if has_snapshot else 'no'}</td>
<td>
<a class="button" href="/html5/projects/{quote(project_id)}/editor">IDE</a>
<a class="button" href="/html5/projects/{quote(project_id)}/setup">Setup</a>
<form
class="delete-project"
method="post"
action="/html5/projects/{quote(project_id)}/delete"
hx-post="/html5/projects/{quote(project_id)}/delete"
hx-target="[data-html5-projects-body]"
hx-swap="innerHTML"
>
<input name="confirmation" value="{escape(project_id)}" aria-label="confirmation" />
<button type="submit">Удалить</button>
</form>
</td>
</tr>"""
@@ -0,0 +1,54 @@
from __future__ import annotations
from typing import Any
from fastapi.responses import Response, StreamingResponse
from fastapi.staticfiles import StaticFiles
HTML5_SECURITY_HEADERS = {"X-Content-Type-Options": "nosniff"}
HTML5_CONTENT_SECURITY_POLICY = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self'; "
"connect-src 'self'; "
"img-src 'self' data:; "
"base-uri 'self'; "
"form-action 'self'"
)
class Html5StaticFiles(StaticFiles):
def file_response(self, *args, **kwargs):
response = super().file_response(*args, **kwargs)
response.headers.setdefault("Cache-Control", "public, max-age=31536000, immutable")
response.headers.setdefault("X-Content-Type-Options", "nosniff")
return response
def html5_sse_headers() -> dict[str, str]:
return {
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
**HTML5_SECURITY_HEADERS,
}
def html5_response(fragment: str) -> Response:
return Response(
fragment,
media_type="text/html; charset=utf-8",
headers={
"Cache-Control": "no-cache, no-transform",
"Content-Security-Policy": HTML5_CONTENT_SECURITY_POLICY,
**HTML5_SECURITY_HEADERS,
},
)
def html5_sse_response(content: Any) -> StreamingResponse:
return StreamingResponse(
content,
media_type="text/event-stream",
headers=html5_sse_headers(),
)
@@ -0,0 +1,314 @@
from __future__ import annotations
from html import escape
from typing import Iterable
from urllib.parse import quote
from api_server.html5 import _enum_text, _metric, _page, _project_link, _topbar
def render_html5_project_setup(*, project_id: str, projects: Iterable[object], setup: object) -> str:
project_nav = "\n".join(_project_link(project, project_id) for project in projects)
name = _setup_name(setup)
sources = getattr(setup, "import_sources", []) or []
source_cards = "".join(_import_source_card(source) for source in sources)
content = f"""
<main
class="workspace setup-workspace"
data-html5-page="setup"
data-project-id="{escape(project_id)}"
hx-ext="sse"
sse-connect="/html5/projects/{quote(project_id)}/setup/events"
>
{_topbar(project_id, project_nav)}
<section class="setup-layout">
<aside class="panel">
<div class="panel-title">Проект</div>
<div class="setup-card">
<p class="eyebrow">HTML5 setup</p>
<h1>{escape(name)}</h1>
<p class="muted">{escape(project_id)}</p>
<div class="setup-actions">
<a class="button" href="/html5/projects/{quote(project_id)}/editor">Открыть IDE</a>
<a class="button" href="/project-settings?project={quote(project_id)}">Legacy setup</a>
</div>
</div>
<div class="panel-title">Источники</div>
<div class="source-list">{source_cards or '<p class="muted padded">Источники импорта не настроены</p>'}</div>
</aside>
<section class="panel setup-main">
{render_html5_settings_panel(project_id, setup)}
{render_html5_setup_actions(project_id, setup)}
{render_html5_setup_summary(project_id, setup)}
</section>
</section>
</main>
"""
return _page(f"SFERA HTML5 setup - {project_id}", content)
def render_html5_settings_panel(project_id: str, setup: object, saved: bool = False) -> str:
settings = getattr(setup, "settings", None)
name = str(getattr(settings, "name", "") or "")
platform_version = str(getattr(settings, "platform_version", "") or "")
compatibility_mode = str(getattr(settings, "compatibility_mode", "") or "")
notice = '<span class="saved">Сохранено</span>' if saved else ""
return f"""
<div class="settings-panel" data-html5-settings-panel>
<div class="panel-title flush">Базовые настройки {notice}</div>
<form
class="settings-form"
method="post"
action="/html5/projects/{quote(project_id)}/setup/settings"
hx-post="/html5/projects/{quote(project_id)}/setup/settings"
hx-target="[data-html5-settings-panel]"
hx-swap="outerHTML"
>
<label>Название<input name="name" value="{escape(name)}" /></label>
<label>Платформа<input name="platform_version" value="{escape(platform_version)}" placeholder="8.3.24" /></label>
<label>Совместимость<input name="compatibility_mode" value="{escape(compatibility_mode)}" placeholder="8.3.20" /></label>
<button type="submit">Сохранить настройки</button>
</form>
</div>
"""
def render_html5_setup_actions(project_id: str, setup: object) -> str:
sources = getattr(setup, "import_sources", []) or []
current_source = _enum_text(getattr(setup, "current_source", None) or "")
source_options = "".join(_source_option(source, current_source) for source in sources)
if not source_options:
source_options = f'<option value="{escape(current_source or "XML_DUMP")}">{escape(current_source or "XML_DUMP")}</option>'
return f"""
<div class="setup-actions-panel" data-html5-setup-actions>
<form
class="inline-form"
method="post"
action="/html5/projects/{quote(project_id)}/setup/source"
hx-post="/html5/projects/{quote(project_id)}/setup/source"
hx-target="[data-html5-setup-summary]"
hx-swap="outerHTML"
>
<label>Источник</label>
<select name="source">{source_options}</select>
<button type="submit">Сохранить</button>
</form>
<form
class="inline-form"
method="post"
action="/html5/projects/{quote(project_id)}/setup/check"
hx-post="/html5/projects/{quote(project_id)}/setup/check"
hx-target="[data-html5-import-check]"
hx-swap="outerHTML"
>
<button type="submit">Проверить</button>
</form>
<form
class="inline-form"
method="post"
action="/html5/projects/{quote(project_id)}/setup/import-job"
hx-post="/html5/projects/{quote(project_id)}/setup/import-job"
hx-target="[data-html5-import-job]"
hx-swap="outerHTML"
>
<button type="submit">Импорт в фоне</button>
</form>
<form
class="inline-form"
method="post"
action="/html5/projects/{quote(project_id)}/setup/import"
hx-post="/html5/projects/{quote(project_id)}/setup/import"
hx-target="[data-html5-setup-summary]"
hx-swap="outerHTML"
>
<button type="submit">Запустить импорт</button>
</form>
<form
class="inline-form"
method="post"
action="/html5/projects/{quote(project_id)}/setup/reindex"
hx-post="/html5/projects/{quote(project_id)}/setup/reindex"
hx-target="[data-html5-setup-summary]"
hx-swap="outerHTML"
>
<button type="submit">Переиндексировать</button>
</form>
</div>
{render_html5_import_check(project_id)}
{render_html5_import_job(project_id)}
"""
def render_html5_import_check(project_id: str, check: object | None = None) -> str:
if check is None:
return f"""
<div class="import-check" data-html5-import-check>
<div class="panel-title flush">Проверка импорта</div>
<p class="muted padded">Запустите server-side preflight перед импортом проекта {escape(project_id)}.</p>
</div>
"""
status = str(getattr(check, "status", "UNKNOWN"))
source = _enum_text(getattr(check, "source", ""))
ready = bool(getattr(check, "ready", False))
checks = getattr(check, "checks", []) or []
items = "".join(_preflight_item(item) for item in checks)
return f"""
<div class="import-check" data-html5-import-check>
<div class="panel-title flush">Проверка импорта</div>
<div class="check-head">
<strong>{escape(status)}</strong>
<span>{escape(source)}</span>
<small>{'ready' if ready else 'needs attention'}</small>
</div>
<div class="check-list">{items or '<p class="muted padded">Проверки не вернули результатов</p>'}</div>
</div>
"""
def render_html5_import_job(project_id: str, job: object | None = None) -> str:
if job is None:
return f"""
<div class="import-job" data-html5-import-job sse-swap="setup-import-job" hx-swap="outerHTML">
<div class="panel-title flush">Фоновый импорт</div>
<p class="muted padded">Фоновая задача импорта для проекта {escape(project_id)} еще не запускалась.</p>
</div>
"""
job_id = str(getattr(job, "job_id", ""))
status = _enum_text(getattr(job, "status", "unknown"))
payload = getattr(job, "payload", {}) or {}
message = str(payload.get("message") or "")
source = str(payload.get("source") or "")
stage = str(payload.get("stage") or "")
logs = payload.get("logs") if isinstance(payload.get("logs"), list) else []
logs_html = "".join(f"<li>{escape(str(item))}</li>" for item in logs[-6:])
return f"""
<div
class="import-job"
data-html5-import-job
hx-get="/html5/projects/{quote(project_id)}/setup/jobs/{quote(job_id)}"
sse-swap="setup-import-job"
hx-swap="outerHTML"
>
<div class="panel-title flush">Фоновый импорт</div>
<div class="check-head">
<strong>{escape(status)}</strong>
<span>{escape(source)}</span>
<small>{escape(stage or job_id)}</small>
</div>
<p class="muted padded">{escape(message or "Ожидание обновления статуса")}</p>
<ul class="job-log">{logs_html or '<li>Лог пока пустой</li>'}</ul>
</div>
"""
def render_html5_setup_summary(project_id: str, setup: object) -> str:
status = _enum_text(getattr(setup, "status", "unknown"))
message = str(getattr(setup, "message", ""))
current_source = _enum_text(getattr(setup, "current_source", None) or "не выбран")
last_import = getattr(setup, "last_import", None)
history = getattr(setup, "import_history", []) or []
return f"""
<div
class="setup-summary"
data-html5-setup-summary
hx-get="/html5/projects/{quote(project_id)}/setup/summary"
sse-swap="setup-summary"
hx-swap="outerHTML"
>
<div class="section-title">
<div>
<p class="eyebrow">Server-rendered status</p>
<h2>{escape(status)}</h2>
</div>
<span class="status-pill">{escape(current_source)}</span>
</div>
<p class="lead compact-lead">{escape(message)}</p>
<dl class="setup-metrics">
{_metric("Объекты", _import_value(last_import, "object_count"))}
{_metric("Модули", _import_value(last_import, "module_count"))}
{_metric("Формы", _import_value(last_import, "form_count"))}
{_metric("Роли", _import_value(last_import, "role_count"))}
</dl>
<div class="panel-title flush">Последняя загрузка</div>
{_last_import_block(last_import)}
<div class="panel-title flush">История</div>
<div class="history-list">
{''.join(_history_item(item) for item in history[:6]) or '<p class="muted padded">История импорта пока пустая</p>'}
</div>
</div>
"""
def _setup_name(setup: object) -> str:
settings = getattr(setup, "settings", None)
return str(getattr(settings, "name", None) or getattr(setup, "project_id", "SFERA Project"))
def _import_value(import_summary: object | None, field: str) -> int | str:
if import_summary is None:
return "0"
return getattr(import_summary, field, 0)
def _last_import_block(import_summary: object | None) -> str:
if import_summary is None:
return '<p class="muted padded">Загрузка еще не выполнялась</p>'
source = _enum_text(getattr(import_summary, "source", ""))
status = str(getattr(import_summary, "status", ""))
source_path = str(getattr(import_summary, "source_path", "") or "source path unavailable")
runtime = str(getattr(import_summary, "runtime_mode", "") or "runtime unavailable")
return f"""
<div class="setup-detail" data-html5-last-import>
<strong>{escape(status)}</strong>
<span>{escape(source)} · {escape(runtime)}</span>
<small>{escape(source_path)}</small>
</div>
"""
def _history_item(item: object) -> str:
source = _enum_text(getattr(item, "source", ""))
status = str(getattr(item, "status", ""))
objects = getattr(item, "object_count", 0)
modules = getattr(item, "module_count", 0)
return f"""
<article class="history-item" data-html5-import-history>
<strong>{escape(status)}</strong>
<span>{escape(source)}</span>
<small>{escape(str(objects))} objects · {escape(str(modules))} modules</small>
</article>
"""
def _import_source_card(source: object) -> str:
kind = _enum_text(getattr(source, "kind", ""))
title = str(getattr(source, "title", kind))
description = str(getattr(source, "description", ""))
readiness = str(getattr(source, "readiness", ""))
return f"""
<article class="source-card" data-html5-import-source="{escape(kind)}">
<strong>{escape(title)}</strong>
<span>{escape(kind)}</span>
<small>{escape(readiness or description)}</small>
</article>
"""
def _source_option(source: object, current_source: str) -> str:
kind = _enum_text(getattr(source, "kind", ""))
title = str(getattr(source, "title", kind))
selected = " selected" if kind == current_source else ""
return f'<option value="{escape(kind)}"{selected}>{escape(title)} · {escape(kind)}</option>'
def _preflight_item(item: object) -> str:
title = str(getattr(item, "title", "Check"))
status = str(getattr(item, "status", "UNKNOWN"))
message = str(getattr(item, "message", ""))
return f"""
<article class="check-item" data-html5-preflight-check="{escape(status)}">
<strong>{escape(title)}</strong>
<span>{escape(status)}</span>
<small>{escape(message)}</small>
</article>
"""
@@ -0,0 +1,162 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Callable, Iterable
from typing import Any
from fastapi import HTTPException
from api_server.html5_forms import form_value
from api_server.html5_setup import (
render_html5_import_check,
render_html5_import_job,
render_html5_project_setup,
render_html5_settings_panel,
render_html5_setup_summary,
)
from api_server.html5_sse import html5_sse_comment, html5_sse_if_changed
def html5_setup_page(
*,
project_id: str,
projects: Iterable[object],
setup: object,
) -> str:
return render_html5_project_setup(project_id=project_id, projects=projects, setup=setup)
def html5_setup_summary(*, project_id: str, setup: object) -> str:
return render_html5_setup_summary(project_id, setup)
async def html5_setup_event_stream(
*,
project_id: str,
setup_response: Callable[[str], object],
latest_import_job: Callable[[str], object | None],
once: bool = False,
) -> AsyncIterator[str]:
last_fragments: dict[str, str] = {}
while True:
yield html5_sse_comment(f"setup {project_id} heartbeat")
try:
setup = setup_response(project_id)
except HTTPException as error:
setup_error = f'<div class="setup-summary" data-html5-setup-summary><p class="muted padded">{error.detail}</p></div>'
for event_text in html5_sse_if_changed(last_fragments, "setup-summary", setup_error):
yield event_text
if once:
break
await asyncio.sleep(2)
continue
for event_text in html5_sse_if_changed(last_fragments, "setup-summary", render_html5_setup_summary(project_id, setup)):
yield event_text
for event_text in html5_sse_if_changed(
last_fragments,
"setup-import-job",
render_html5_import_job(project_id, latest_import_job(project_id)),
):
yield event_text
if once:
break
await asyncio.sleep(2)
async def html5_setup_source(
*,
project_id: str,
form: dict[str, list[str]],
import_source_kind: Callable[[str], Any],
setup_response: Callable[[str], object],
save_settings: Callable[[str, object], Any],
) -> str:
source = import_source_kind(form_value(form, "source") or "XML_DUMP")
current = setup_response(project_id)
settings = current.settings.model_copy(update={"structure_source": source})
setup = await save_settings(project_id, settings)
return render_html5_setup_summary(project_id, setup)
async def html5_setup_settings(
*,
project_id: str,
form: dict[str, list[str]],
setup_response: Callable[[str], object],
save_settings: Callable[[str, object], Any],
) -> str:
current = setup_response(project_id)
settings = current.settings.model_copy(
update={
"name": form_value(form, "name") or current.settings.name,
"platform_version": form_value(form, "platform_version"),
"compatibility_mode": form_value(form, "compatibility_mode"),
}
)
setup = await save_settings(project_id, settings)
return render_html5_settings_panel(project_id, setup, saved=True)
def html5_setup_check(
*,
project_id: str,
form: dict[str, list[str]],
import_source_kind: Callable[[str], Any],
import_request: Callable[..., object],
current_import_source: Callable[[str], object],
import_check: Callable[[str, object, object], object],
) -> str:
source = import_source_kind(form_value(form, "source") or current_import_source(project_id).value)
check = import_check(project_id, source, import_request(source=source))
return render_html5_import_check(project_id, check)
def html5_setup_import(
*,
project_id: str,
form: dict[str, list[str]],
import_source_kind: Callable[[str], Any],
import_request: Callable[..., object],
current_import_source: Callable[[str], object],
execute_import: Callable[[str, object], object],
setup_response: Callable[[str], object],
) -> str:
source = import_source_kind(form_value(form, "source") or current_import_source(project_id).value)
structure_only = form_value(form, "structure_only") in {"1", "true", "on", "yes"}
execute_import(project_id, import_request(source=source, structure_only=structure_only))
return render_html5_setup_summary(project_id, setup_response(project_id))
async def html5_setup_import_job(
*,
project_id: str,
form: dict[str, list[str]],
import_source_kind: Callable[[str], Any],
import_request: Callable[..., object],
current_import_source: Callable[[str], object],
start_import_job: Callable[[str, object, object], Any],
) -> str:
source = import_source_kind(form_value(form, "source") or current_import_source(project_id).value)
job = await start_import_job(project_id, source, import_request(source=source))
return render_html5_import_job(project_id, job)
def html5_setup_job(
*,
project_id: str,
job_id: str,
jobs_by_id: dict[str, object],
) -> str:
job = jobs_by_id.get(job_id)
if job is None or (getattr(job, "payload", {}) or {}).get("project_id") != project_id:
raise HTTPException(status_code=404, detail=f"Unknown import job: {job_id}")
return render_html5_import_job(project_id, job)
async def html5_setup_reindex(
*,
project_id: str,
start_reindex_job: Callable[[str], Any],
) -> str:
job = await start_reindex_job(project_id)
return render_html5_import_job(project_id, job)
@@ -0,0 +1,19 @@
from __future__ import annotations
from collections.abc import Iterator
def html5_sse_event(event: str, fragment: str) -> str:
data = "\n".join(f"data: {line}" for line in fragment.splitlines())
return f"event: {event}\nretry: 5000\n{data}\n\n"
def html5_sse_if_changed(last_fragments: dict[str, str], event: str, fragment: str) -> Iterator[str]:
if last_fragments.get(event) == fragment:
return
last_fragments[event] = fragment
yield html5_sse_event(event, fragment)
def html5_sse_comment(message: str) -> str:
return f": {message}\n\n"
@@ -0,0 +1,55 @@
from __future__ import annotations
from pydantic import BaseModel, Field
from api_server.import_sync_models import ImportSyncPreview
from api_server.normalized_project_models import NormalizedProjectSummary
class SnapshotSummary(BaseModel):
snapshot_id: str
project_id: str
snapshot_hash: str | None
node_count: int
edge_count: int
diagnostics_count: int
unresolved_references_count: int
class ImportSummary(BaseModel):
source: str
mode: str = "FULL_REPLACE"
applied: bool = True
status: str
last_import: str
source_path: str | None = None
runtime_mode: str = "mock"
runtime_diagnostics: list[str] = Field(default_factory=list)
errors: list[str] = Field(default_factory=list)
diagnostics_count: int = 0
diagnostics: list[str] = Field(default_factory=list)
object_count: int = 0
module_count: int = 0
form_count: int = 0
role_count: int = 0
extensions: list[str] = Field(default_factory=list)
platform_version: str | None = None
compatibility_mode: str | None = None
snapshot: SnapshotSummary | None = None
normalized_summary: NormalizedProjectSummary | None = None
sync_preview: ImportSyncPreview | None = None
class IndexProjectResponse(BaseModel):
snapshot: SnapshotSummary
class IncrementalFileResponse(BaseModel):
snapshot: SnapshotSummary
added_nodes: int
updated_nodes: int
removed_nodes: int
added_edges: int
removed_edges: int
neo4j_projected: bool = False
neo4j_error: str | None = None
@@ -0,0 +1,23 @@
from __future__ import annotations
from pydantic import BaseModel, Field
from api_server.normalized_project_models import NormalizedProjectSummary
class ImportQualityCheck(BaseModel):
code: str
title: str
severity: str = "INFO"
passed: bool = True
message: str
value: int | str | None = None
class ImportQualityResponse(BaseModel):
project_id: str
status: str
score: int = 0
ready_for_ide: bool = False
summary: NormalizedProjectSummary | None = None
checks: list[ImportQualityCheck] = Field(default_factory=list)
@@ -0,0 +1,135 @@
from __future__ import annotations
from api_server.import_quality_models import ImportQualityCheck, ImportQualityResponse
from api_server.normalized_project_models import NormalizedProjectSummary
def import_quality_response(
*,
project_id: str,
status: str,
summary: NormalizedProjectSummary | None,
indexed_status: str,
) -> ImportQualityResponse:
checks: list[ImportQualityCheck] = []
checks.append(
_quality_check(
"normalized_project",
"NormalizedProject",
summary is not None,
"NormalizedProject сохранен" if summary is not None else "NormalizedProject не найден",
summary.project_id if summary is not None else None,
severity="ERROR",
)
)
if summary is not None:
checks.extend(
[
_quality_check(
"metadata_groups",
"Metadata groups",
summary.group_count >= 5,
f"Найдено групп metadata: {summary.group_count}",
summary.group_count,
),
_quality_check(
"metadata_objects",
"Metadata objects",
summary.object_count > 0,
f"Найдено объектов: {summary.object_count}",
summary.object_count,
severity="ERROR",
),
_quality_check(
"forms",
"Forms",
summary.form_count > 0,
f"Найдено форм: {summary.form_count}",
summary.form_count,
),
_quality_check(
"modules",
"Modules",
summary.module_count > 0,
f"Найдено модулей: {summary.module_count}",
summary.module_count,
),
_quality_check(
"roles",
"Roles",
summary.role_count > 0,
f"Найдено ролей: {summary.role_count}",
summary.role_count,
),
_quality_check(
"rights",
"Rights",
summary.rights_count > 0,
f"Найдено прав: {summary.rights_count}",
summary.rights_count,
),
_quality_check(
"access_profiles",
"Access profiles",
True,
f"Найдено профилей групп доступа: {summary.access_profile_count}",
summary.access_profile_count,
severity="INFO",
),
_quality_check(
"access_groups",
"Access groups",
True,
f"Найдено групп доступа: {summary.access_group_count}",
summary.access_group_count,
severity="INFO",
),
_quality_check(
"access_users",
"Access users",
True,
f"Найдено пользователей ИБ: {summary.access_user_count}",
summary.access_user_count,
severity="INFO",
),
_quality_check(
"extensions",
"Extensions",
True,
f"Найдено расширений: {summary.extension_count}",
summary.extension_count,
severity="INFO",
),
]
)
weighted_checks = [check for check in checks if check.code != "extensions"]
passed = sum(1 for check in weighted_checks if check.passed)
score = round((passed / len(weighted_checks)) * 100) if weighted_checks else 0
ready_for_ide = status == indexed_status and all(check.passed for check in checks if check.severity == "ERROR")
return ImportQualityResponse(
project_id=project_id,
status=status,
score=score,
ready_for_ide=ready_for_ide,
summary=summary,
checks=checks,
)
def _quality_check(
code: str,
title: str,
passed: bool,
message: str,
value: int | str | None = None,
severity: str = "WARNING",
) -> ImportQualityCheck:
return ImportQualityCheck(
code=code,
title=title,
severity="INFO" if passed else severity,
passed=passed,
message=message,
value=value,
)
@@ -0,0 +1,39 @@
from __future__ import annotations
from enum import Enum
class ProjectSetupStatus(str, Enum):
NOT_CONFIGURED = "NOT_CONFIGURED"
IMPORT_REQUIRED = "IMPORT_REQUIRED"
IMPORTED = "IMPORTED"
STRUCTURE_INDEXED = "STRUCTURE_INDEXED"
INDEXED = "INDEXED"
class ImportSourceStatus(str, Enum):
AVAILABLE = "доступен"
REQUIRES_1C_PLATFORM = "требует 1С платформу"
REQUIRES_AGENT = "требует агент"
REQUIRES_CREDENTIALS = "требует учетные данные"
METADATA_ONLY = "только metadata"
FULL_IMPORT = "полный import"
class ImportSourceKind(str, Enum):
CF_FILE = "CF_FILE"
CFE_FILE = "CFE_FILE"
XML_DUMP = "XML_DUMP"
LIVE_INFOBASE = "LIVE_INFOBASE"
EPF_AGENT = "EPF_AGENT"
CFE_AGENT = "CFE_AGENT"
EDT_PROJECT = "EDT_PROJECT"
ARCHIVE_DUMP = "ARCHIVE_DUMP"
FILE_TREE = "FILE_TREE"
CONTEXT_ONLY = "CONTEXT_ONLY"
REFERENCE_CONFIGURATION = "REFERENCE_CONFIGURATION"
class ImportMode(str, Enum):
FULL_REPLACE = "FULL_REPLACE"
SYNC_PREVIEW = "SYNC_PREVIEW"
@@ -0,0 +1,126 @@
from __future__ import annotations
from api_server.import_models import ImportSummary, SnapshotSummary
from api_server.import_sync_models import ImportSyncPreview
from api_server.normalized_project_models import NormalizedProjectSummary
from api_server.normalized_project_service import normalized_project_summary
from api_server.time_utils import current_timestamp
from one_c_normalizer import NormalizedProject
from sir import NodeKind, SirSnapshot
def snapshot_summary(snapshot: SirSnapshot) -> SnapshotSummary:
return SnapshotSummary(
snapshot_id=snapshot.snapshot_id,
project_id=snapshot.project_id,
snapshot_hash=snapshot.snapshot_hash,
node_count=len(snapshot.nodes),
edge_count=len(snapshot.edges),
diagnostics_count=len(snapshot.diagnostics),
unresolved_references_count=len(snapshot.unresolved_references),
)
def import_summary_from_snapshot(
*,
project_id: str,
source: str,
status: str,
snapshot: SirSnapshot | None,
errors: list[str],
metadata: dict,
runtime_mode: str,
runtime_diagnostics: list[str],
normalized: NormalizedProject | None,
mode: str = "FULL_REPLACE",
applied: bool = True,
sync_preview: ImportSyncPreview | None = None,
) -> ImportSummary:
normalized_summary = normalized_project_summary(normalized) if normalized is not None else None
empty_counts = snapshot is None and normalized_summary is None and not applied
if snapshot is None:
return ImportSummary(
source=source,
mode=mode,
applied=applied,
status=status,
last_import=current_timestamp(),
source_path=None,
runtime_mode=runtime_mode,
runtime_diagnostics=runtime_diagnostics,
errors=errors,
diagnostics_count=0,
diagnostics=[],
object_count=normalized_summary.object_count if normalized_summary is not None else 0 if empty_counts else 12,
module_count=normalized_summary.module_count if normalized_summary is not None else 0 if empty_counts else 4,
form_count=normalized_summary.form_count if normalized_summary is not None else 0 if empty_counts else 3,
role_count=normalized_summary.role_count if normalized_summary is not None else 0 if empty_counts else 2,
extensions=_summary_extensions(normalized_summary, metadata, empty_counts, default=["DemoExtension"]),
platform_version=metadata.get("platform_version", "mock-8.3"),
compatibility_mode=metadata.get("compatibility_mode", "mock"),
normalized_summary=normalized_summary,
sync_preview=sync_preview,
)
object_kinds = {
NodeKind.CATALOG,
NodeKind.DOCUMENT,
NodeKind.REGISTER,
NodeKind.REPORT,
NodeKind.DATA_PROCESSOR,
NodeKind.COMMON_MODULE,
NodeKind.EXCHANGE_PLAN,
NodeKind.BUSINESS_PROCESS,
NodeKind.TASK,
NodeKind.SUBSYSTEM,
NodeKind.HTTP_SERVICE,
NodeKind.XDTO_PACKAGE,
NodeKind.EXTENSION,
}
return ImportSummary(
source=source,
mode=mode,
applied=applied,
status=status,
last_import=current_timestamp(),
source_path=snapshot.metadata.source_root,
runtime_mode=runtime_mode,
runtime_diagnostics=runtime_diagnostics,
errors=errors,
diagnostics_count=len(snapshot.diagnostics),
diagnostics=[
f"{diagnostic.severity.value} {diagnostic.code}: {diagnostic.message}"
for diagnostic in snapshot.diagnostics[:20]
],
object_count=normalized_summary.object_count
if normalized_summary is not None
else sum(1 for node in snapshot.nodes if node.kind in object_kinds),
module_count=normalized_summary.module_count
if normalized_summary is not None
else sum(1 for node in snapshot.nodes if node.kind == NodeKind.MODULE),
form_count=normalized_summary.form_count
if normalized_summary is not None
else sum(1 for node in snapshot.nodes if node.kind == NodeKind.FORM),
role_count=normalized_summary.role_count
if normalized_summary is not None
else sum(1 for node in snapshot.nodes if node.kind == NodeKind.ROLE),
extensions=normalized_summary.extensions if normalized_summary is not None else list(metadata.get("extensions", [])),
platform_version=metadata.get("platform_version"),
compatibility_mode=metadata.get("compatibility_mode"),
snapshot=snapshot_summary(snapshot),
normalized_summary=normalized_summary,
sync_preview=sync_preview,
)
def _summary_extensions(
normalized_summary: NormalizedProjectSummary | None,
metadata: dict,
empty_counts: bool,
*,
default: list[str],
) -> list[str]:
if normalized_summary is not None:
return normalized_summary.extensions
if empty_counts:
return []
return list(metadata.get("extensions", default))
@@ -0,0 +1,25 @@
from __future__ import annotations
from pydantic import BaseModel, Field
class ImportSyncDiffItem(BaseModel):
qualified_name: str
name: str
object_kind: str
group_name: str | None = None
change_kind: str
before_hash: str | None = None
after_hash: str | None = None
class ImportSyncPreview(BaseModel):
mode: str = "SYNC_PREVIEW"
applied: bool = False
status: str = "preview_only"
message: str
added_count: int = 0
removed_count: int = 0
changed_count: int = 0
unchanged_count: int = 0
items: list[ImportSyncDiffItem] = Field(default_factory=list)
@@ -0,0 +1,73 @@
from __future__ import annotations
import json
from api_server.import_sync_models import ImportSyncDiffItem, ImportSyncPreview
from one_c_normalizer import NormalizedProject
from sir import stable_hash
def build_import_sync_preview(
current: NormalizedProject | None,
incoming: NormalizedProject | None,
) -> ImportSyncPreview:
current_index = normalized_object_hash_index(current)
incoming_index = normalized_object_hash_index(incoming)
items: list[ImportSyncDiffItem] = []
for qualified_name in sorted(set(current_index) | set(incoming_index)):
before = current_index.get(qualified_name)
after = incoming_index.get(qualified_name)
if before is None and after is not None:
change_kind = "ADD"
elif before is not None and after is None:
change_kind = "REMOVE"
elif before is not None and after is not None and before["hash"] != after["hash"]:
change_kind = "UPDATE"
else:
change_kind = "UNCHANGED"
source = after or before or {}
items.append(
ImportSyncDiffItem(
qualified_name=qualified_name,
name=str(source.get("name", qualified_name)),
object_kind=str(source.get("object_kind", "UNKNOWN")),
group_name=source.get("group_name"),
change_kind=change_kind,
before_hash=before["hash"] if before is not None else None,
after_hash=after["hash"] if after is not None else None,
)
)
added_count = sum(1 for item in items if item.change_kind == "ADD")
removed_count = sum(1 for item in items if item.change_kind == "REMOVE")
changed_count = sum(1 for item in items if item.change_kind == "UPDATE")
unchanged_count = sum(1 for item in items if item.change_kind == "UNCHANGED")
return ImportSyncPreview(
message=(
"Synchronization is not applied yet. This preview shows what would change; "
"use FULL_REPLACE to replace current project data."
),
added_count=added_count,
removed_count=removed_count,
changed_count=changed_count,
unchanged_count=unchanged_count,
items=[item for item in items if item.change_kind != "UNCHANGED"],
)
def normalized_object_hash_index(normalized: NormalizedProject | None) -> dict[str, dict]:
if normalized is None:
return {}
result: dict[str, dict] = {}
for group in normalized.configuration.groups:
for item in group.objects:
payload = item.model_dump(mode="json")
result[item.qualified_name] = {
"name": item.name,
"object_kind": item.object_kind,
"group_name": group.name,
"hash": stable_hash(json.dumps(payload, ensure_ascii=False, sort_keys=True)),
}
return result
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,130 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from fastapi import HTTPException
def metadata_tree(
*,
project_id: str,
object_limit_per_branch: int,
project_snapshot: Callable[[str], object],
normalized_project: Callable[[str], object | None],
normalized_tree: Callable[..., object],
snapshot_tree: Callable[..., object],
response_model: Callable[..., object],
) -> object:
snapshot = project_snapshot(project_id)
normalized = normalized_project(project_id)
root = (
normalized_tree(normalized, object_limit_per_branch=max(0, object_limit_per_branch))
if normalized is not None
else snapshot_tree(snapshot, object_limit_per_branch=max(0, object_limit_per_branch))
)
return response_model(project_id=project_id, root=root)
def metadata_tree_children(
*,
project_id: str,
node_id: str,
offset: int,
limit: int,
project_snapshot: Callable[[str], object],
normalized_project: Callable[[str], object | None],
normalized_children_for_node: Callable[..., tuple[list[object], int] | None],
snapshot_children_for_node: Callable[..., tuple[list[object], int]],
response_model: Callable[..., object],
) -> object:
snapshot = project_snapshot(project_id)
normalized = normalized_project(project_id)
normalized_offset = max(0, offset)
normalized_limit = min(max(1, limit), 250)
normalized_children = (
normalized_children_for_node(
normalized,
node_id=node_id,
offset=normalized_offset,
limit=normalized_limit,
)
if normalized is not None
else None
)
if normalized_children is None:
children, total = snapshot_children_for_node(
snapshot,
node_id=node_id,
offset=normalized_offset,
limit=normalized_limit,
)
else:
children, total = normalized_children
return response_model(
project_id=project_id,
parent_id=node_id,
offset=normalized_offset,
limit=normalized_limit,
total=total,
has_more=normalized_offset + len(children) < total,
children=children,
)
def metadata_tree_search(
*,
project_id: str,
q: str,
limit: int,
project_snapshot: Callable[[str], object],
is_search_node: Callable[[Any], bool],
search_rank: Callable[[Any, str], object],
child_count_index: Callable[[object, list[str]], object],
node_for_search_result: Callable[[object, Any, object], object],
response_model: Callable[..., object],
) -> object:
snapshot = project_snapshot(project_id)
normalized_query = q.strip().casefold()
normalized_limit = min(max(1, limit), 250)
if len(normalized_query) < 2:
return response_model(project_id=project_id, q=q, total=0, results=[])
matches = [
node
for node in snapshot.nodes
if is_search_node(node)
and (
normalized_query in node.name.casefold()
or normalized_query in node.qualified_name.casefold()
)
]
matches.sort(key=lambda item: search_rank(item, normalized_query))
page = matches[:normalized_limit]
counts = child_count_index(snapshot, [node.lineage_id for node in page])
return response_model(
project_id=project_id,
q=q,
total=len(matches),
results=[node_for_search_result(snapshot, node, counts) for node in page],
)
def metadata_tree_path(
*,
project_id: str,
node_id: str,
project_snapshot: Callable[[str], object],
tree_path_for_node: Callable[[object, str], list[str]],
tree_path_steps: Callable[[object, list[str]], list[object]],
response_model: Callable[..., object],
) -> object:
snapshot = project_snapshot(project_id)
path = tree_path_for_node(snapshot, node_id)
if not path:
raise HTTPException(status_code=404, detail=f"Metadata tree path not found: {node_id}")
return response_model(
project_id=project_id,
node_id=node_id,
path=path,
steps=tree_path_steps(snapshot, path),
)
@@ -0,0 +1,50 @@
from __future__ import annotations
from pydantic import BaseModel, Field
class MetadataTreeNodeResponse(BaseModel):
id: str
label: str
kind: str
icon: str
qualified_name: str | None = None
count: int = 0
loaded_count: int = 0
has_more: bool = False
children: list["MetadataTreeNodeResponse"] = Field(default_factory=list)
class ProjectMetadataTreeResponse(BaseModel):
project_id: str
root: MetadataTreeNodeResponse
class MetadataTreeChildrenResponse(BaseModel):
project_id: str
parent_id: str
offset: int = 0
limit: int = 50
total: int = 0
has_more: bool = False
children: list[MetadataTreeNodeResponse] = Field(default_factory=list)
class MetadataTreeSearchResponse(BaseModel):
project_id: str
q: str
total: int = 0
results: list[MetadataTreeNodeResponse] = Field(default_factory=list)
class MetadataTreePathStepResponse(BaseModel):
parent_id: str
child_id: str
offset: int = 0
class MetadataTreePathResponse(BaseModel):
project_id: str
node_id: str
path: list[str] = Field(default_factory=list)
steps: list[MetadataTreePathStepResponse] = Field(default_factory=list)
@@ -0,0 +1,49 @@
from __future__ import annotations
from pydantic import BaseModel, Field
from one_c_normalizer import MetadataObject
class NormalizedObjectDetail(BaseModel):
project_id: str | None = None
group_name: str
object: MetadataObject
class ModuleRoutineResponse(BaseModel):
name: str
kind: str
line_start: int | None = None
line_end: int | None = None
export: bool = False
calls_count: int = 0
queries_count: int = 0
writes_count: int = 0
calls: list[str] = Field(default_factory=list)
queries: list[str] = Field(default_factory=list)
writes: list[str] = Field(default_factory=list)
impact_level: str = "LOW"
impact_reasons: list[str] = Field(default_factory=list)
class ModuleSourceResponse(BaseModel):
name: str
qualified_name: str
module_role: str = "MODULE"
owner_qualified_name: str | None = None
owner_kind: str | None = None
object_part: str | None = None
form_name: str | None = None
form_qualified_name: str | None = None
source_path: str
source_text: str
routines_count: int = 0
routines: list[ModuleRoutineResponse] = Field(default_factory=list)
class BslCompletionItemResponse(BaseModel):
label: str
kind: str = "VALUE"
detail: str | None = None
insert_text: str | None = None
@@ -0,0 +1,190 @@
from __future__ import annotations
import re
from api_server.normalized_object_models import (
BslCompletionItemResponse,
ModuleRoutineResponse,
ModuleSourceResponse,
NormalizedObjectDetail,
)
from api_server.normalized_project_service import normalized_all_groups
from one_c_normalizer import NormalizedProject
def normalized_object_detail(normalized: NormalizedProject, qualified_name: str) -> NormalizedObjectDetail | None:
for group in normalized.configuration.groups:
for item in group.objects:
if item.qualified_name == qualified_name:
return NormalizedObjectDetail(
project_id=normalized.project_id,
group_name=group.name,
object=item,
)
return None
def normalized_module_sources_for_object(normalized: NormalizedProject, qualified_name: str) -> list[ModuleSourceResponse]:
normalized_query = qualified_name.strip().casefold()
if not normalized_query:
return []
selected_module = None
selected_owner = None
selected_object = None
for group in normalized_all_groups(normalized):
for item in group.objects:
if item.qualified_name.casefold() == normalized_query or item.name.casefold() == normalized_query:
selected_object = item
break
for module in item.modules:
module_keys = {
str(module.qualified_name or "").casefold(),
str(module.name or "").casefold(),
str(module.source_path or "").casefold(),
}
if normalized_query in module_keys:
selected_module = module
selected_owner = item
break
if selected_object is not None or selected_module is not None:
break
if selected_object is not None or selected_module is not None:
break
if selected_module is not None:
return [_normalized_module_source_response(selected_module, selected_owner)]
if selected_object is None:
return []
return sorted(
[_normalized_module_source_response(module, selected_object) for module in selected_object.modules],
key=lambda item: (item.module_role, item.name),
)
def normalized_bsl_completion_items(
normalized: NormalizedProject,
receiver: str | None,
qualified_name: str | None,
) -> list[BslCompletionItemResponse]:
receiver_key = (receiver or "").strip().casefold()
qualified_key = (qualified_name or "").strip().casefold()
items: list[BslCompletionItemResponse] = []
for group in normalized_all_groups(normalized):
for metadata_object in group.objects:
object_names = {
metadata_object.name.casefold(),
metadata_object.qualified_name.casefold(),
}
if receiver_key and receiver_key in object_names:
items.extend(
BslCompletionItemResponse(
label=part.name,
kind=_completion_kind_for_part(part.kind),
detail=f"{metadata_object.qualified_name}: {part.kind}",
insert_text=part.name,
)
for part in [
*metadata_object.attributes,
*metadata_object.resources,
*metadata_object.dimensions,
*metadata_object.tabular_sections,
*metadata_object.commands,
]
)
for module in metadata_object.modules:
module_names = {
str(module.name or "").casefold(),
str(module.qualified_name or "").casefold(),
str(module.source_path or "").casefold(),
f"{metadata_object.name}.{module.name}".casefold(),
f"{metadata_object.qualified_name}.{module.name}".casefold(),
}
if receiver_key and receiver_key not in module_names and receiver_key not in object_names:
continue
if not receiver_key and qualified_key and qualified_key not in module_names and qualified_key not in object_names:
continue
source_text = str((module.attributes or {}).get("source_text", ""))
for routine in normalized_module_routines(source_text):
if receiver_key and not routine.export:
continue
items.append(
BslCompletionItemResponse(
label=routine.name,
kind="FUNCTION" if routine.kind == "FUNCTION" else "PROCEDURE",
detail=f"{module.qualified_name or module.name}{' · Export' if routine.export else ''}",
insert_text=f"{routine.name}()",
)
)
return items
def _completion_kind_for_part(kind: str) -> str:
normalized = kind.upper()
if normalized in {"ATTRIBUTE", "RESOURCE", "DIMENSION", "FIELD"}:
return "PROPERTY"
if normalized in {"COMMAND", "METHOD", "OPERATION"}:
return "METHOD"
if normalized in {"TABULAR_SECTION", "TABLE"}:
return "COLLECTION"
return "VALUE"
def _normalized_module_source_response(module, owner) -> ModuleSourceResponse:
attributes = module.attributes or {}
source_text = str(attributes.get("source_text", ""))
routines = normalized_module_routines(source_text)
module_role = str(module.module_kind or attributes.get("module_role") or "MODULE")
return ModuleSourceResponse(
name=module.name,
qualified_name=module.qualified_name or module.name,
module_role=module_role,
owner_qualified_name=str(attributes.get("owner_qualified_name") or getattr(owner, "qualified_name", "") or "") or None,
owner_kind=str(attributes.get("owner_kind") or getattr(owner, "object_kind", "") or "") or None,
object_part=str(
attributes.get("object_part")
or module_object_part_for_response(module_role, str(attributes.get("form_name") or ""))
),
form_name=str(attributes.get("form_name") or "") or None,
form_qualified_name=str(attributes.get("form_qualified_name") or "") or None,
source_path=module.source_path or "",
source_text=source_text,
routines_count=len(routines),
routines=routines,
)
def module_object_part_for_response(module_role: str, form_name: str = "") -> str:
return {
"OBJECT_MODULE": "object.module",
"MANAGER_MODULE": "object.manager",
"RECORD_SET_MODULE": "object.record_set",
"FORM_MODULE": f"form.{form_name}.module" if form_name else "form.module",
"MODULE": "module",
}.get(module_role, "module")
def normalized_module_routines(source_text: str) -> list[ModuleRoutineResponse]:
if not source_text:
return []
declarations: list[tuple[int, re.Match[str]]] = []
pattern = re.compile(
r"^\s*(Процедура|Функция|Procedure|Function)\s+([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\s*\(([^)]*)\)\s*(.*)$",
re.IGNORECASE | re.MULTILINE,
)
for match in pattern.finditer(source_text):
line_start = source_text.count("\n", 0, match.start()) + 1
declarations.append((line_start, match))
routines: list[ModuleRoutineResponse] = []
for index, (line_start, match) in enumerate(declarations):
line_end = declarations[index + 1][0] - 1 if index + 1 < len(declarations) else len(source_text.splitlines())
kind_label = match.group(1).casefold()
tail = match.group(4) or ""
routines.append(
ModuleRoutineResponse(
name=match.group(2),
kind="FUNCTION" if kind_label in {"функция", "function"} else "PROCEDURE",
line_start=line_start,
line_end=line_end,
export=bool(re.search(r"\b(Экспорт|Export)\b", tail, re.IGNORECASE)),
)
)
return routines
@@ -0,0 +1,32 @@
from __future__ import annotations
from pydantic import BaseModel, Field
class NormalizedGroupSummary(BaseModel):
name: str
object_kind: str
object_count: int
class NormalizedProjectSummary(BaseModel):
project_id: str | None = None
source_path: str | None = None
group_count: int = 0
object_count: int = 0
attribute_count: int = 0
tabular_section_count: int = 0
form_count: int = 0
command_count: int = 0
role_count: int = 0
rights_count: int = 0
access_profile_count: int = 0
access_group_count: int = 0
access_user_count: int = 0
access_assignment_count: int = 0
module_count: int = 0
layout_count: int = 0
movement_count: int = 0
extension_count: int = 0
extensions: list[str] = Field(default_factory=list)
groups: list[NormalizedGroupSummary] = Field(default_factory=list)
@@ -0,0 +1,61 @@
from __future__ import annotations
from api_server.normalized_project_models import NormalizedGroupSummary, NormalizedProjectSummary
from one_c_normalizer import NormalizedProject
def normalized_all_groups(normalized: NormalizedProject):
groups = list(normalized.configuration.groups)
for extension in normalized.configuration.extensions:
groups.extend(extension.groups)
return groups
def normalized_project_summary(normalized: NormalizedProject) -> NormalizedProjectSummary:
all_groups = normalized_all_groups(normalized)
objects = [item for group in all_groups for item in group.objects]
return NormalizedProjectSummary(
project_id=normalized.project_id,
source_path=normalized.source_path,
group_count=len(all_groups),
object_count=len(objects),
attribute_count=sum(
len(item.attributes)
+ sum(normalized_part_descendant_count(section, "ATTRIBUTE") for section in item.tabular_sections)
+ sum(normalized_part_descendant_count(form, "ATTRIBUTE") for form in item.forms)
for item in objects
),
tabular_section_count=sum(len(item.tabular_sections) for item in objects),
form_count=sum(len(item.forms) for item in objects),
command_count=sum(len(item.commands) for item in objects),
role_count=sum(1 for item in objects if item.object_kind == "ROLE"),
rights_count=sum(len(item.rights) for item in objects),
access_profile_count=len(normalized.access.profiles),
access_group_count=len(normalized.access.groups),
access_user_count=len(normalized.access.users),
access_assignment_count=sum(len(item.roles) for item in normalized.access.profiles)
+ sum(len(item.roles) for item in normalized.access.groups)
+ sum(len(item.roles) for item in normalized.access.users)
+ sum(len(item.users) for item in normalized.access.groups),
module_count=sum(len(item.modules) for item in objects),
layout_count=sum(len(item.layouts) for item in objects),
movement_count=sum(len(item.movements) for item in objects),
extension_count=len(normalized.configuration.extensions),
extensions=[item.name for item in normalized.configuration.extensions],
groups=[
NormalizedGroupSummary(
name=group.name,
object_kind=", ".join(group.object_kinds),
object_count=len(group.objects),
)
for group in all_groups
],
)
def normalized_part_descendant_count(part, kind: str | None = None) -> int:
children = getattr(part, "children", [])
return sum(
(1 if kind is None or child.kind == kind else 0) + normalized_part_descendant_count(child, kind)
for child in children
)
@@ -0,0 +1,170 @@
from __future__ import annotations
import shutil
from pathlib import Path
from typing import Any
def is_unc_path(path: str) -> bool:
return path.startswith("\\\\")
def copy_smb_tree_to_local(
*,
source: str,
target: Path,
username: str,
password: str,
domain: str | None = None,
) -> None:
smbclient = _smbclient()
server, _share, _relative = parse_unc_path(source)
_register_session(smbclient, server=server, username=username, password=password, domain=domain)
_copy_smb_directory(smbclient, source.rstrip("\\"), target)
def copy_local_tree_to_smb(
*,
source: Path,
target: str,
username: str,
password: str,
domain: str | None = None,
) -> None:
smbclient = _smbclient()
server, _share, _relative = parse_unc_path(target)
_register_session(smbclient, server=server, username=username, password=password, domain=domain)
remote_root = target.rstrip("\\")
_ensure_smb_directory(smbclient, remote_root)
if source.is_file():
_copy_local_file_to_smb(smbclient, source, f"{remote_root}\\{source.name}")
return
for path in sorted(source.rglob("*")):
relative = path.relative_to(source)
remote_relative = str(relative).replace("/", "\\")
remote = f"{remote_root}\\{remote_relative}"
if path.is_dir():
_ensure_smb_directory(smbclient, remote)
elif path.is_file():
_ensure_smb_directory(smbclient, remote.rsplit("\\", 1)[0])
_copy_local_file_to_smb(smbclient, path, remote)
def parse_unc_path(path: str) -> tuple[str, str, str]:
parts = [part for part in path.strip("\\").split("\\") if part]
if len(parts) < 2:
raise ValueError("UNC путь должен содержать сервер и share: \\\\server\\share.")
server, share = parts[0], parts[1]
relative = "\\".join(parts[2:])
return server, share, relative
def remove_tree(path: Path, *, expected_parent: Path) -> None:
resolved = path.resolve()
parent = expected_parent.resolve()
if resolved == parent or parent not in resolved.parents:
raise ValueError(f"Refusing to remove path outside work root: {resolved}")
if resolved.exists():
shutil.rmtree(resolved)
def _smbclient() -> Any:
try:
import smbclient
except ImportError as error:
raise RuntimeError("SMB client dependency is not installed on the API server.") from error
return smbclient
def _register_session(smbclient: Any, *, server: str, username: str, password: str, domain: str | None) -> None:
effective_domain, effective_username = _normalize_credentials(username, domain)
qualified_user = f"{effective_domain}\\{effective_username}" if effective_domain else effective_username
try:
smbclient.register_session(server, username=qualified_user, password=password)
except Exception as error: # pragma: no cover - depends on smb backend details
raise RuntimeError(_translate_smb_error(error, server=server, username=qualified_user)) from error
def _copy_smb_directory(smbclient: Any, source: str, target: Path) -> None:
target.mkdir(parents=True, exist_ok=True)
try:
items = list(smbclient.scandir(source))
except Exception as error: # pragma: no cover - depends on smb backend details
raise RuntimeError(_translate_smb_error(error, path=source)) from error
for item in items:
destination = target / item.name
child_source = f"{source}\\{item.name}"
try:
if item.is_dir():
_copy_smb_directory(smbclient, child_source, destination)
continue
except OSError:
continue
with smbclient.open_file(child_source, mode="rb") as remote_file:
with destination.open("wb") as local_file:
shutil.copyfileobj(remote_file, local_file, length=1024 * 1024)
def _copy_local_file_to_smb(smbclient: Any, source: Path, target: str) -> None:
try:
with source.open("rb") as local_file:
with smbclient.open_file(target, mode="wb") as remote_file:
shutil.copyfileobj(local_file, remote_file, length=1024 * 1024)
except Exception as error: # pragma: no cover - depends on smb backend details
raise RuntimeError(_translate_smb_error(error, path=target)) from error
def _ensure_smb_directory(smbclient: Any, path: str) -> None:
normalized = path.rstrip("\\")
try:
if smbclient.path.isdir(normalized):
return
except OSError:
pass
parent = _unc_parent_path(normalized)
if parent and parent != normalized:
_ensure_smb_directory(smbclient, parent)
try:
smbclient.mkdir(normalized)
except OSError as error:
if not smbclient.path.isdir(normalized):
raise RuntimeError(_translate_smb_error(error, path=normalized)) from error
def _unc_parent_path(path: str) -> str | None:
server, share, relative = parse_unc_path(path)
if not relative:
return None
parts = [part for part in relative.split("\\") if part]
if len(parts) <= 1:
return f"\\\\{server}\\{share}"
parent_relative = "\\".join(parts[:-1])
return f"\\\\{server}\\{share}\\{parent_relative}"
def _normalize_credentials(username: str, domain: str | None) -> tuple[str | None, str]:
raw_username = username.strip()
raw_domain = (domain or "").strip() or None
if "\\" in raw_username:
embedded_domain, embedded_username = raw_username.split("\\", 1)
return embedded_domain.strip() or raw_domain, embedded_username.strip()
if "@" in raw_username and not raw_domain:
embedded_username, embedded_domain = raw_username.split("@", 1)
return embedded_domain.strip() or None, embedded_username.strip()
return raw_domain, raw_username
def _translate_smb_error(error: Exception, *, server: str | None = None, path: str | None = None, username: str | None = None) -> str:
message = str(error).strip()
lowered = message.casefold()
target = path or server or "сетевой ресурс"
if any(token in lowered for token in ["logon failure", "access denied", "authentication", "STATUS_LOGON_FAILURE".casefold(), "STATUS_ACCESS_DENIED".casefold()]):
user_part = f" для пользователя {username}" if username else ""
return f"Ошибка авторизации SMB{user_part}. Проверьте логин, пароль, домен и права доступа к {target}."
if any(token in lowered for token in ["bad network name", "object name not found", "path not found", "no such file", "STATUS_OBJECT_PATH_NOT_FOUND".casefold(), "STATUS_BAD_NETWORK_NAME".casefold()]):
return f"Сетевой путь не найден или недоступен: {target}."
if any(token in lowered for token in ["connection reset", "connection refused", "timed out", "host is down", "network name deleted", "network path was not found"]):
return f"Не удалось подключиться к сетевому ресурсу {target}. Проверьте доступность сервера и сети."
if message:
return f"Ошибка SMB при обращении к {target}: {message}"
return f"Неизвестная ошибка SMB при обращении к {target}."
@@ -0,0 +1,309 @@
from __future__ import annotations
from api_server.normalized_object_models import BslCompletionItemResponse, ModuleRoutineResponse, ModuleSourceResponse
from api_server.normalized_object_service import module_object_part_for_response
from sir import EdgeKind, NodeKind, SirSnapshot
def snapshot_bsl_completion_items(snapshot: SirSnapshot, receiver: str | None) -> list[BslCompletionItemResponse]:
receiver_key = (receiver or "").strip().casefold()
nodes_by_lineage = {node.lineage_id: node for node in snapshot.nodes}
module_lineages: set[str] = set()
if receiver_key:
for node in snapshot.nodes:
if node.kind == NodeKind.MODULE and (
node.name.casefold() == receiver_key
or node.qualified_name.casefold() == receiver_key
or str(node.attributes.get("source_path", "")).casefold() == receiver_key
):
module_lineages.add(node.lineage_id)
else:
module_lineages = {node.lineage_id for node in snapshot.nodes if node.kind == NodeKind.MODULE}
items: list[BslCompletionItemResponse] = []
for module_lineage in module_lineages:
module = nodes_by_lineage.get(module_lineage)
if module is None:
continue
for routine in module_routines(snapshot, module_lineage, nodes_by_lineage):
if receiver_key and not routine.export:
continue
items.append(
BslCompletionItemResponse(
label=routine.name,
kind="FUNCTION" if routine.kind == "FUNCTION" else "PROCEDURE",
detail=f"{module.qualified_name}{' · Export' if routine.export else ''}",
insert_text=f"{routine.name}()",
)
)
return items
def module_sources_for_object(
snapshot: SirSnapshot,
qualified_name: str,
owner_node_kinds: set[NodeKind],
) -> list[ModuleSourceResponse]:
nodes_by_lineage = {node.lineage_id: node for node in snapshot.nodes}
selected_routine = next(
(
node
for node in snapshot.nodes
if node.kind in {NodeKind.PROCEDURE, NodeKind.FUNCTION}
and (node.qualified_name == qualified_name or node.name == qualified_name)
),
None,
)
if selected_routine is not None:
module_edge = next(
(
edge
for edge in snapshot.edges
if edge.kind == EdgeKind.DECLARES and edge.target_lineage == selected_routine.lineage_id
),
None,
)
if module_edge is not None:
module = nodes_by_lineage.get(module_edge.source_lineage)
if module is not None:
return module_source_response(snapshot, module, nodes_by_lineage)
selected_command = next(
(
node
for node in snapshot.nodes
if node.kind in {NodeKind.COMMAND, NodeKind.FORM, NodeKind.FORM_ELEMENT}
and (node.qualified_name == qualified_name or node.name == qualified_name)
),
None,
)
if selected_command is not None:
handler_edge = next(
(
edge
for edge in snapshot.edges
if edge.kind == EdgeKind.HANDLES and edge.source_lineage == selected_command.lineage_id
),
None,
)
if handler_edge is not None:
routine = nodes_by_lineage.get(handler_edge.target_lineage)
module_edge = next(
(
edge
for edge in snapshot.edges
if edge.kind == EdgeKind.DECLARES and routine is not None and edge.target_lineage == routine.lineage_id
),
None,
)
if module_edge is not None:
module = nodes_by_lineage.get(module_edge.source_lineage)
if module is not None:
return module_source_response(snapshot, module, nodes_by_lineage)
selected_module = next(
(
node
for node in snapshot.nodes
if node.kind == NodeKind.MODULE
and (
node.qualified_name == qualified_name
or node.name == qualified_name
or node.source_ref.source_path == qualified_name
or module_metadata_qualified_name(snapshot, node, nodes_by_lineage) == qualified_name
)
),
None,
)
if selected_module is not None:
return module_source_response(snapshot, selected_module, nodes_by_lineage)
owner = next(
(
node
for node in snapshot.nodes
if node.qualified_name == qualified_name and node.kind in owner_node_kinds
),
None,
)
if owner is None:
return []
module_edges = [
edge
for edge in snapshot.edges
if edge.kind == EdgeKind.CONTAINS
and edge.source_lineage == owner.lineage_id
and edge.attributes.get("link_type") == "METADATA_MODULE"
]
modules: list[ModuleSourceResponse] = []
for edge in module_edges:
module = nodes_by_lineage.get(edge.target_lineage)
if module is None or module.kind != NodeKind.MODULE:
continue
routines = module_routines(snapshot, module.lineage_id, nodes_by_lineage)
module_role = str(edge.attributes.get("module_role") or module.attributes.get("module_role") or "MODULE")
modules.append(
ModuleSourceResponse(
name=module.name,
qualified_name=module.qualified_name,
module_role=module_role,
owner_qualified_name=str(module.attributes.get("owner_qualified_name") or owner.qualified_name or "") or None,
owner_kind=str(module.attributes.get("owner_kind") or owner.kind.value or "") or None,
object_part=str(
edge.attributes.get("object_part")
or module.attributes.get("object_part")
or module_object_part_for_response(module_role, str(module.attributes.get("form_name") or ""))
),
form_name=str(edge.attributes.get("form_name") or module.attributes.get("form_name") or "") or None,
form_qualified_name=str(module.attributes.get("form_qualified_name") or "") or None,
source_path=module.source_ref.source_path,
source_text=str(module.attributes.get("source_text", "")),
routines_count=len(routines),
routines=routines,
)
)
return sorted(modules, key=lambda item: (item.module_role, item.name))
def module_source_response(
snapshot: SirSnapshot,
module,
nodes_by_lineage: dict[str, object],
) -> list[ModuleSourceResponse]:
owner_edge = next(
(
edge
for edge in snapshot.edges
if edge.kind == EdgeKind.CONTAINS
and edge.target_lineage == module.lineage_id
and edge.attributes.get("link_type") == "METADATA_MODULE"
),
None,
)
routines = module_routines(snapshot, module.lineage_id, nodes_by_lineage)
owner = nodes_by_lineage.get(owner_edge.source_lineage) if owner_edge else None
module_role = str((owner_edge.attributes.get("module_role") if owner_edge else None) or module.attributes.get("module_role") or "MODULE")
return [
ModuleSourceResponse(
name=module.name,
qualified_name=module.qualified_name,
module_role=module_role,
owner_qualified_name=str(module.attributes.get("owner_qualified_name") or getattr(owner, "qualified_name", "") or "") or None,
owner_kind=str(module.attributes.get("owner_kind") or getattr(getattr(owner, "kind", None), "value", "") or "") or None,
object_part=str(
(owner_edge.attributes.get("object_part") if owner_edge else None)
or module.attributes.get("object_part")
or module_object_part_for_response(module_role, str(module.attributes.get("form_name") or ""))
),
form_name=str((owner_edge.attributes.get("form_name") if owner_edge else None) or module.attributes.get("form_name") or "") or None,
form_qualified_name=str(module.attributes.get("form_qualified_name") or "") or None,
source_path=module.source_ref.source_path,
source_text=str(module.attributes.get("source_text", "")),
routines_count=len(routines),
routines=routines,
)
]
def module_metadata_qualified_name(
snapshot: SirSnapshot,
module,
nodes_by_lineage: dict[str, object],
) -> str | None:
owner_edge = next(
(
edge
for edge in snapshot.edges
if edge.kind == EdgeKind.CONTAINS
and edge.target_lineage == module.lineage_id
and edge.attributes.get("link_type") == "METADATA_MODULE"
),
None,
)
if owner_edge is None:
return None
owner = nodes_by_lineage.get(owner_edge.source_lineage)
if owner is None:
return None
role = str(owner_edge.attributes.get("module_role", "MODULE"))
form_name = str(owner_edge.attributes.get("form_name", ""))
suffix = {
"OBJECT_MODULE": "МодульОбъекта",
"MANAGER_MODULE": "МодульМенеджера",
"RECORD_SET_MODULE": "МодульНабораЗаписей",
"FORM_MODULE": f"Форма.{form_name}.Модуль" if form_name else "МодульФормы",
"MODULE": "Модуль",
}.get(role, module.name)
return f"{owner.qualified_name}.{suffix}"
def module_routines(
snapshot: SirSnapshot,
module_lineage: str,
nodes_by_lineage: dict[str, object],
) -> list[ModuleRoutineResponse]:
routines: list[ModuleRoutineResponse] = []
for edge in snapshot.edges:
if edge.kind != EdgeKind.DECLARES or edge.source_lineage != module_lineage:
continue
routine = nodes_by_lineage.get(edge.target_lineage)
if routine is None or getattr(routine, "kind", None) not in {NodeKind.PROCEDURE, NodeKind.FUNCTION}:
continue
calls = _routine_relation_values(snapshot, nodes_by_lineage, routine.lineage_id, EdgeKind.CALLS)
queries = _routine_relation_values(snapshot, nodes_by_lineage, routine.lineage_id, EdgeKind.OWNS_QUERY)
writes = _routine_relation_values(snapshot, nodes_by_lineage, routine.lineage_id, EdgeKind.WRITES)
impact_level, impact_reasons = _routine_impact_markers(calls, queries, writes)
routines.append(
ModuleRoutineResponse(
name=routine.name,
kind=routine.kind.value,
line_start=routine.source_ref.line_start,
line_end=routine.source_ref.line_end,
export=bool(routine.attributes.get("export", False)),
calls_count=len(calls),
queries_count=len(queries),
writes_count=len(writes),
calls=calls,
queries=queries,
writes=writes,
impact_level=impact_level,
impact_reasons=impact_reasons,
)
)
return sorted(routines, key=lambda item: item.line_start or 0)
def _routine_impact_markers(calls: list[str], queries: list[str], writes: list[str]) -> tuple[str, list[str]]:
reasons: list[str] = []
if writes:
reasons.append("writes data")
if queries:
reasons.append("reads query tables")
if len(calls) >= 3:
reasons.append("fan-out calls")
if writes and (queries or len(calls) >= 2):
level = "HIGH"
elif writes or queries or len(calls) >= 3:
level = "MEDIUM"
else:
level = "LOW"
return level, reasons
def _routine_relation_values(
snapshot: SirSnapshot,
nodes_by_lineage: dict[str, object],
routine_lineage: str,
relation: EdgeKind,
) -> list[str]:
values: list[str] = []
for edge in snapshot.edges:
if edge.kind != relation or edge.source_lineage != routine_lineage:
continue
target = nodes_by_lineage.get(edge.target_lineage)
if target is None:
continue
if relation == EdgeKind.OWNS_QUERY:
query_text = str(target.attributes.get("query_text", "")).strip()
values.append(query_text or target.name)
else:
values.append(target.qualified_name or target.name)
return values
@@ -0,0 +1,125 @@
(function () {
const jobs = new WeakMap();
function formatClock(seconds) {
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
return String(minutes).padStart(2, "0") + ":" + String(rest).padStart(2, "0");
}
function formatHuman(seconds) {
if (seconds < 60) {
return seconds + " сек";
}
return Math.floor(seconds / 60) + " мин " + String(seconds % 60).padStart(2, "0");
}
function stageFor(seconds) {
if (seconds < 10) {
return "Проверка путей и учетных данных";
}
if (seconds < 45) {
return "Копирование входных файлов";
}
if (seconds < 120) {
return "Разбор структуры 1С";
}
if (seconds < 240) {
return "Построение индексов для ИИ";
}
return "Запись Codex-пакета";
}
function etaFor(seconds) {
if (seconds < 10) {
return "1-5 мин";
}
if (seconds < 45) {
return "до 5 мин";
}
if (seconds < 120) {
return "2-6 мин";
}
if (seconds < 300) {
return "еще несколько минут";
}
return "зависит от размера SMB-папки";
}
function update(progress, startedAt) {
const seconds = Math.max(0, Math.floor((Date.now() - startedAt) / 1000));
const elapsed = progress.querySelector("[data-ai-structure-elapsed]");
const elapsedLabel = progress.querySelector("[data-ai-structure-elapsed-label]");
const eta = progress.querySelector("[data-ai-structure-eta]");
const stage = progress.querySelector("[data-ai-structure-stage]");
const bar = progress.querySelector("[data-ai-structure-bar]");
if (elapsed) {
elapsed.textContent = formatClock(seconds);
}
if (elapsedLabel) {
elapsedLabel.textContent = formatHuman(seconds);
}
if (eta) {
eta.textContent = etaFor(seconds);
}
if (stage) {
stage.textContent = stageFor(seconds);
}
if (bar) {
bar.style.width = Math.min(92, 8 + seconds * 0.35) + "%";
}
}
function start(form) {
const progress = document.querySelector("[data-ai-structure-progress]");
if (!progress) {
return;
}
const previous = jobs.get(form);
if (previous) {
window.clearInterval(previous.timer);
}
const startedAt = Date.now();
progress.hidden = false;
progress.setAttribute("data-ai-structure-progress-state", "running");
update(progress, startedAt);
const timer = window.setInterval(function () {
update(progress, startedAt);
}, 1000);
jobs.set(form, { progress: progress, timer: timer });
}
function stop(form) {
const job = jobs.get(form);
if (!job) {
return;
}
window.clearInterval(job.timer);
job.progress.setAttribute("data-ai-structure-progress-state", "done");
window.setTimeout(function () {
job.progress.hidden = true;
}, 500);
jobs.delete(form);
}
document.addEventListener("htmx:beforeRequest", function (event) {
const form = event.target && event.target.closest ? event.target.closest(".ai-structure-form") : null;
if (form) {
start(form);
}
});
document.addEventListener("htmx:afterRequest", function (event) {
const form = event.target && event.target.closest ? event.target.closest(".ai-structure-form") : null;
if (form) {
stop(form);
}
});
document.addEventListener("htmx:sendError", function (event) {
const form = event.target && event.target.closest ? event.target.closest(".ai-structure-form") : null;
if (form) {
stop(form);
}
});
})();
@@ -0,0 +1,31 @@
:root{color-scheme:light;--bg:#f7f8fb;--card:#fff;--text:#17202a;--muted:#687385;--line:#dce2ea;--brand:#2457d6;--ok:#168457;--warn:#a16207}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.45 system-ui,-apple-system,Segoe UI,sans-serif}a{color:inherit}
.shell{min-height:100vh;padding:32px}.hero{display:flex;justify-content:space-between;gap:24px;align-items:end;margin:0 auto 24px;max-width:1180px}.hero h1{margin:0;font-size:36px;letter-spacing:0}.lead{max-width:640px;color:var(--muted);font-size:16px}.eyebrow{margin:0 0 8px;color:var(--brand);font-size:12px;font-weight:800;text-transform:uppercase}.hero-metrics{min-width:160px;border:1px solid var(--line);background:var(--card);padding:18px}.hero-metrics strong{display:block;font-size:34px}.hero-metrics span,.muted{color:var(--muted)}.toolbar-links{display:flex;gap:8px;flex-wrap:wrap}
.band,.panel,.editor{border:1px solid var(--line);background:var(--card)}.band{max-width:1180px;margin:auto;padding:18px}.section-title,.topbar,.editor-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.button,button{display:inline-flex;align-items:center;justify-content:center;height:32px;border:1px solid var(--line);background:#fff;padding:0 12px;text-decoration:none;font-weight:700;cursor:pointer}table{width:100%;border-collapse:collapse}th,td{border-top:1px solid var(--line);padding:12px;text-align:left}td small{display:block;color:var(--muted)}td .button,td form{margin-right:6px}.delete-project{display:inline-flex;gap:4px;vertical-align:middle}.delete-project input{height:32px;width:120px;border:1px solid var(--line);padding:0 6px}
.workspace{min-height:100vh;padding-bottom:34px}.topbar{position:sticky;top:0;z-index:2;height:54px;border-bottom:1px solid var(--line);background:rgba(255,255,255,.94);padding:0 14px}.brand{font-weight:900;text-decoration:none;color:var(--brand)}.project-nav{display:flex;gap:6px;overflow:auto;flex:1}.project-link{white-space:nowrap;text-decoration:none;border:1px solid var(--line);padding:6px 10px;background:#fff}.project-link.active{background:var(--brand);border-color:var(--brand);color:#fff}
.layout{display:grid;grid-template-columns:280px minmax(0,1fr)320px;height:calc(100vh - 88px)}.panel{overflow:auto}.panel-title{padding:12px;border-bottom:1px solid var(--line);font-size:12px;font-weight:900;text-transform:uppercase;color:var(--muted)}.tree-item{display:block;padding:10px 12px;border-bottom:1px solid var(--line);text-decoration:none}.tree-item span{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tree-item small{color:var(--muted)}
.editor{min-width:0;overflow:hidden;border-top:0;border-bottom:0}.editor-head{height:72px;border-bottom:1px solid var(--line);padding:0 14px}.editor-head h1{margin:0;font-size:18px}.search{display:flex;gap:6px}.search input{height:32px;width:260px;border:1px solid var(--line);padding:0 10px}.source-panel{height:calc(100% - 72px);display:grid;grid-template-rows:auto auto auto minmax(0,1fr);overflow:hidden}.source-head{display:flex;justify-content:space-between;gap:12px;align-items:center;min-height:54px;padding:10px 14px;border-bottom:1px solid var(--line);background:#fff}.source-head strong,.source-head small{display:block}.source-head small{color:var(--muted)}.source-head dl{display:flex;gap:12px;margin:0}.source-head div div{padding:0}.source-head dt{font-size:11px;color:var(--muted)}.source-head dd{margin:0;font-weight:800}.source-summary,.object-cache{margin:0;padding:8px 14px;border-bottom:1px solid var(--line);background:#fffdf8;color:var(--muted);font-size:12px;font-weight:800;line-height:1.45}.object-cache{background:#f8fbff}.code{height:100%;margin:0;overflow:auto;padding:16px;background:#fbfaf7;font:13px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap}
.metrics{display:grid;grid-template-columns:1fr 1fr;margin:0}.metrics div{padding:12px;border-bottom:1px solid var(--line)}.metrics dt{color:var(--muted);font-size:12px}.metrics dd{margin:2px 0 0;font-size:22px;font-weight:800}.compact{list-style:none;margin:0;padding:0}.compact li{display:flex;justify-content:space-between;padding:8px 12px;border-bottom:1px solid var(--line)}.object-actions{display:flex;gap:6px;flex-wrap:wrap;padding:10px 12px;border-bottom:1px solid var(--line);background:#fbfcfe}.object-actions .button{height:28px;padding:0 9px;font-size:12px}.object-actions .button[data-html5-object-action-active="true"],.object-actions .button[aria-current="page"]{background:var(--brand);border-color:var(--brand);color:#fff}.object-breadcrumb{display:flex;gap:6px;flex-wrap:wrap;padding:9px 12px;border-bottom:1px solid var(--line);background:#fff;font-size:12px;font-weight:800;color:var(--muted)}.object-breadcrumb span:not(:last-child)::after{content:"/";margin-left:6px;color:#98a2b3}.object-breadcrumb span:last-child{color:var(--ink)}.object-summary,.symbol-summary,.review-summary,.project-summary,.object-report-summary,.authoring-summary{margin:0;padding:10px 12px;border-bottom:1px solid var(--line);background:#f8fbff;color:var(--muted);font-size:12px;font-weight:800;line-height:1.45}.symbol{display:grid;gap:3px;padding:10px 12px;border-bottom:1px solid var(--line);cursor:pointer}.symbol:hover{background:#f8fbff}.symbol span,.symbol small{color:var(--muted)}.symbol-focus,.symbol-reference,.object-focus,.object-context-item{display:grid;gap:3px;padding:10px 12px;border-bottom:1px solid var(--line)}.symbol-focus small,.symbol-reference span,.symbol-reference small,.object-focus span,.object-context-item small{color:var(--muted)}.inline-actions{display:flex;gap:8px;flex-wrap:wrap;font-size:12px;font-weight:800}.inline-actions a{color:var(--brand);text-decoration:none}.report-grid{display:grid;grid-template-columns:1fr 1fr;margin:0}.report-grid div{padding:10px 12px;border-bottom:1px solid var(--line)}.report-grid dt{color:var(--muted);font-size:12px}.report-grid dd{margin:2px 0 0;font-size:20px;font-weight:900}.review-item,.authoring-change{display:grid;gap:3px;padding:10px 12px;border-bottom:1px solid var(--line)}.authoring-change[hx-get]{cursor:pointer}.authoring-change[hx-get]:hover{background:#f8fbff}.review-item span,.review-item small,.authoring-change span,.authoring-change small{color:var(--muted)}.diff-item{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px;padding:8px 12px;border-bottom:1px solid var(--line)}.diff-item span{color:var(--muted);font-weight:800}.diff-item code{white-space:pre-wrap;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}.status{position:fixed;bottom:0;left:0;right:0;display:flex;gap:18px;overflow:auto;height:34px;align-items:center;border-top:1px solid var(--line);background:#fff;padding:0 12px;color:var(--muted);font-size:12px}.empty-state{max-width:720px;margin:80px auto;background:#fff;border:1px solid var(--line);padding:28px}
.setup-layout{display:grid;grid-template-columns:340px minmax(0,1fr);gap:16px;max-width:1180px;margin:18px auto;padding:0 16px}.setup-workspace{background:#f3f6fb}.setup-card{padding:16px;border-bottom:1px solid var(--line)}.setup-card h1{margin:0;font-size:24px}.setup-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}.setup-main{min-height:620px}.settings-form{display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:8px;align-items:end;padding:12px 16px;border-bottom:1px solid var(--line);background:#fff}.settings-form label{display:grid;gap:4px;font-size:12px;font-weight:800;color:var(--muted);text-transform:uppercase}.settings-form input{height:32px;border:1px solid var(--line);padding:0 8px}.saved{float:right;color:var(--ok);font-weight:900}.setup-actions-panel,.ops-filter{display:flex;gap:8px;flex-wrap:wrap;align-items:end;padding:12px 16px;border-bottom:1px solid var(--line);background:#fbfcfe}.ops-filter input{height:32px;min-width:180px;border:1px solid var(--line);padding:0 8px}.inline-form{display:flex;gap:8px;align-items:end}.inline-form label{display:grid;gap:4px;font-size:12px;font-weight:800;color:var(--muted);text-transform:uppercase}.inline-form select{height:32px;min-width:240px;border:1px solid var(--line);background:#fff;padding:0 8px}.rollback-form,.authoring-preview-form{display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:12px;border-bottom:1px solid var(--line);background:#fbfcfe}.rollback-form input,.authoring-preview-form input,.authoring-preview-form textarea{min-width:0;border:1px solid var(--line);padding:0 8px}.rollback-form input,.authoring-preview-form input{height:32px}.authoring-preview-form textarea{grid-column:1/-1;min-height:88px;padding:8px;resize:vertical;font:12px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace}.rollback-form button,.authoring-preview-form button{grid-column:1/-1}.setup-summary{display:grid;gap:0}.compact-lead{margin:0;padding:0 16px 16px}.setup-metrics,.ops-summary{display:grid;grid-template-columns:repeat(4,1fr);margin:0;border-top:1px solid var(--line)}.ops-summary{grid-template-columns:repeat(5,1fr);margin:12px 0}.setup-metrics div,.ops-summary div{padding:16px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);background:#fff}.setup-metrics div:last-child,.ops-summary div:last-child{border-right:0}.setup-metrics dt,.ops-summary dt{font-size:12px;color:var(--muted)}.setup-metrics dd,.ops-summary dd{margin:4px 0 0;font-size:28px;font-weight:900}.status-pill{border:1px solid var(--line);padding:6px 10px;background:#eef6f1;color:var(--ok);font-weight:800}.flush{border-top:1px solid var(--line)}.padded{padding:12px 16px;margin:0}.setup-detail,.history-item,.source-card,.check-item{display:grid;gap:3px;padding:12px 16px;border-bottom:1px solid var(--line)}.setup-detail span,.setup-detail small,.history-item span,.history-item small,.source-card span,.source-card small,.check-item span,.check-item small,.check-head span,.check-head small{color:var(--muted)}.source-list,.history-list,.check-list{display:grid}.check-head{display:flex;gap:10px;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);background:#fff}.job-log{margin:0;padding:0;list-style:none}.job-log li{padding:8px 16px;border-bottom:1px solid var(--line);color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}
.form-editor-layout{grid-template-columns:280px minmax(0,1.2fr) minmax(320px,.8fr)}.form-editor-actions{display:flex;gap:8px;flex-wrap:wrap}.tree-item[data-html5-form-selected="true"]{background:#f8fbff;border-left-color:var(--brand)}.form-designer{height:calc(100% - 72px);display:grid;grid-template-rows:auto minmax(0,1fr) auto;overflow:hidden;background:#f7f9fc}.form-designer-head,.form-designer-foot{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px;border-bottom:1px solid var(--line);background:#fff}.form-designer-head strong,.form-designer-head small{display:block}.form-designer-head small{color:var(--muted)}.form-designer-head span{border:1px solid var(--line);background:#eef6f1;color:var(--ok);padding:5px 8px;font-size:12px;font-weight:900}.form-designer-foot{border-top:1px solid var(--line);border-bottom:0}.form-canvas{min-height:0;overflow:auto;padding:18px}.form-window{max-width:720px;min-height:420px;margin:0 auto;border:1px solid var(--line);background:#fff;box-shadow:0 8px 24px rgba(15,23,42,.08)}.form-window-title{height:38px;display:flex;align-items:center;border-bottom:1px solid var(--line);padding:0 12px;font-weight:900;background:#fbfcfe}.form-command-bar{display:flex;gap:6px;flex-wrap:wrap;padding:10px 12px;border-bottom:1px solid var(--line)}.form-command-bar button{height:28px;font-size:12px}.form-fields{display:grid;gap:10px;padding:14px}.form-field{display:grid;grid-template-columns:160px minmax(0,1fr);gap:8px;align-items:center}.form-field span{font-size:12px;font-weight:800;color:var(--muted)}.form-field input{height:30px;border:1px solid var(--line);padding:0 8px;background:#fbfaf7}
.form-designer-body{min-height:0;display:grid;grid-template-columns:minmax(0,1fr) 340px;overflow:hidden}.form-property-panel{min-height:0;overflow:auto;border-left:1px solid var(--line);background:#fff}.property-row{display:grid;grid-template-columns:1fr 132px;gap:8px;padding:12px;border-bottom:1px solid var(--line)}.property-row label,.form-editor-row label{display:grid;gap:4px;font-size:11px;font-weight:900;color:var(--muted);text-transform:uppercase}.property-row input,.property-row select,.form-editor-row input,.form-editor-row select,.form-add-row input,.form-add-row select{height:30px;min-width:0;border:1px solid var(--line);background:#fff;padding:0 8px;color:var(--text);font:13px/1.2 system-ui,-apple-system,Segoe UI,sans-serif;text-transform:none}.form-window{border-color:#b9c1cd;background:#fdfdfd;box-shadow:0 12px 28px rgba(33,43,54,.12)}.form-window-title{justify-content:space-between;height:34px;background:linear-gradient(#f8f9fb,#e9edf3);border-bottom-color:#cbd3df;color:#1f2937}.form-window-title small{font-size:11px;color:#687385;font-weight:800}.form-command-bar{min-height:42px;background:#f4f6f9;border-bottom-color:#ccd4df}.form-command-bar button{height:27px;border-color:#aeb8c6;background:linear-gradient(#fff,#edf1f6);box-shadow:inset 0 1px 0 #fff;color:#1f2937}.form-command-bar button:hover{background:#fff}.form-fields{grid-template-columns:repeat(12,minmax(0,1fr));align-content:start;gap:8px 12px;padding:14px 16px 22px;background:#fbfbfc}.form-field{grid-column:1/-1;grid-template-columns:150px minmax(0,1fr);min-height:32px;padding:4px 6px;border:1px solid transparent}.form-field:hover{border-color:#b9c1cd;background:#fff}.form-field[data-html5-form-width="half"]{grid-column:span 6}.form-field[data-html5-form-width="third"]{grid-column:span 4}.form-field>label{font-size:12px;font-weight:800;color:#4b5563;align-self:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.form-input-control,.form-text-control{width:100%;border:1px solid #aeb8c6;background:#fff;padding:0 7px;color:#17202a;box-shadow:inset 0 1px 2px rgba(15,23,42,.06)}.form-input-control{height:28px}.form-text-control{min-height:64px;resize:none}.form-check-control{display:flex;align-items:center;gap:8px;color:#374151}.form-check-control input{width:16px;height:16px}.form-table-control{border:1px solid #aeb8c6;background:#fff;min-height:108px;overflow:hidden}.form-table-control div{display:grid;grid-template-columns:2fr 1fr 1fr}.form-table-control span{min-height:27px;border-right:1px solid #d7dde6;border-bottom:1px solid #d7dde6;padding:5px 7px;color:#374151;font-size:12px}.form-table-control div:first-child span{background:#eef2f7;font-weight:900}.form-group-control{min-height:44px;border:1px dashed #aeb8c6;background:#f6f8fb;padding:10px;color:#687385;font-weight:800}.form-editor-row{display:grid;gap:7px;padding:10px 12px;border-bottom:1px solid var(--line)}label.form-editor-row{grid-template-columns:88px minmax(0,1fr);align-items:center;text-transform:none}.form-editor-row small{color:var(--muted);font-size:11px}.form-editor-row[data-html5-form-element-editor]{grid-template-columns:1fr 118px 1fr 104px}.form-add-row{display:grid;grid-template-columns:minmax(0,1fr) 118px;gap:8px;padding:12px;border-bottom:1px solid var(--line);background:#fbfcfe}.button.primary{background:var(--brand);border-color:var(--brand);color:#fff;margin:12px}.form-designer-foot small{color:var(--muted);font-weight:800}.form-window[data-html5-form-layout="compact"] .form-fields{gap:4px 8px}.form-window[data-html5-form-layout="compact"] .form-field{min-height:28px}.form-window[data-html5-form-layout="columns"] .form-field{grid-column:span 6}.form-window[data-html5-form-layout="columns"] .form-field[data-html5-form-control="table"],.form-window[data-html5-form-layout="columns"] .form-field[data-html5-form-control="group"]{grid-column:1/-1}
.form-empty-structure{grid-column:1/-1;border:1px dashed #aeb8c6;background:#fff;padding:18px;color:#687385}.form-empty-structure strong,.form-empty-structure span{display:block}.form-empty-structure strong{color:#1f2937}
.access-workspace{background:#f4f7fb}.access-layout{display:grid;grid-template-columns:300px minmax(0,1fr)340px;height:calc(100vh - 88px)}.access-main{border-top:0;border-bottom:0;overflow:auto}.access-nav,.access-side{overflow:auto}.tree-item[data-html5-access-profile-selected="true"]{background:#f8fbff;border-left:3px solid var(--brand);padding-left:9px}.access-empty{margin:16px;border:1px dashed #aeb8c6;background:#fff;padding:18px;color:#687385}.access-empty strong,.access-empty span{display:block}.access-empty strong{color:#1f2937}.access-profile,.access-plan,.access-result{border-bottom:1px solid var(--line);background:#fff}.access-summary{display:flex;gap:8px;flex-wrap:wrap;padding:10px 14px;border-bottom:1px solid var(--line);background:#f8fbff;color:#687385;font-size:12px;font-weight:800}.access-role-grid,.access-operations,.access-list{display:grid}.access-role-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.access-card{display:grid;gap:3px;min-width:0;padding:10px 12px;border-bottom:1px solid var(--line)}.access-role-grid .access-card:nth-child(odd){border-right:1px solid var(--line)}.access-card strong,.access-card small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.access-card small{color:var(--muted)}.access-plan-head{display:flex;gap:10px;align-items:center;justify-content:space-between;padding:10px 12px;border-bottom:1px solid var(--line);background:#fbfcfe}.access-plan-head form{margin:0}.access-actions{padding:12px}.access-warnings{margin:0;padding:0;list-style:none}.access-warnings li{padding:8px 12px;border-bottom:1px solid var(--line);color:var(--warn);font-weight:800}.access-json{margin:0;max-height:220px;overflow:auto;padding:12px;background:#fbfaf7;border-top:1px solid var(--line);font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap}.access-main .primary{background:var(--brand);border-color:var(--brand);color:#fff}
.access-builder{border-bottom:1px solid var(--line);background:#fff}.access-builder-form{display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:12px;border-bottom:1px solid var(--line);background:#fbfcfe}.access-builder-form label{display:grid;gap:5px;font-size:11px;font-weight:900;color:var(--muted);text-transform:uppercase}.access-builder-form input,.access-builder-form textarea{min-width:0;border:1px solid var(--line);background:#fff;padding:0 8px;color:var(--text);font:13px/1.3 system-ui,-apple-system,Segoe UI,sans-serif;text-transform:none}.access-builder-form input{height:32px}.access-builder-form textarea{min-height:68px;padding:8px;resize:vertical}.access-builder-actions{grid-column:1/-1;display:flex;gap:8px;justify-content:flex-end}.access-builder-result{border-top:1px solid var(--line);background:#fff}
.access-card[hx-get]{cursor:pointer}.access-card[hx-get]:hover{background:#f8fbff}.access-user-detail{border-bottom:1px solid var(--line);background:#fff}
.ai-structure-form{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;align-items:end;padding:12px 16px;border-bottom:1px solid var(--line);background:#fbfcfe;overflow-x:hidden}.ai-structure-form label{display:grid;gap:5px;font-size:11px;font-weight:900;color:var(--muted);text-transform:uppercase;min-width:0}.ai-structure-form input{height:32px;min-width:0;width:100%;border:1px solid var(--line);background:#fff;padding:0 8px;color:var(--text);font:13px/1.3 system-ui,-apple-system,Segoe UI,sans-serif;text-transform:none}.ai-structure-field{min-width:0}.ai-structure-field-wide{grid-column:span 2}.ai-structure-field-compact{grid-column:span 1}.ai-structure-form .checkbox-row{display:flex;align-items:center;gap:7px;height:32px}.ai-structure-form .checkbox-row input{width:16px;height:16px;flex:0 0 auto}.ai-structure-submit{width:100%;margin:0;min-width:0}.ai-structure-result{background:#fff}
.ai-structure-hint{border-bottom:1px solid var(--line);background:#fff}.ai-structure-hint-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));background:#fff}.ai-structure-hint-grid .access-card:nth-child(3n+1),.ai-structure-hint-grid .access-card:nth-child(3n+2){border-right:1px solid var(--line)}.ai-structure-hint-grid code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}
.ai-structure-tree-grid{grid-template-columns:repeat(2,minmax(0,1fr));border-top:1px solid var(--line)}.ai-structure-tree-grid .access-card:nth-child(3n+1),.ai-structure-tree-grid .access-card:nth-child(3n+2){border-right:0}.ai-structure-tree-grid .access-card:nth-child(odd){border-right:1px solid var(--line)}.ai-structure-tree-card strong{margin-bottom:6px}.ai-structure-tree-list{margin:0;padding:0;list-style:none}.ai-structure-tree-list li{position:relative;padding:4px 0 4px 16px;color:var(--muted);font-size:12px;line-height:1.4}.ai-structure-tree-list li::before{content:"";position:absolute;left:4px;top:11px;width:6px;height:1px;background:#9aa4b2}
.ai-structure-progress{border-bottom:1px solid var(--line);background:#fff}.ai-progress-head{display:flex;align-items:center;gap:10px;padding:10px 12px;border-bottom:1px solid var(--line);background:#fbfcfe}.ai-progress-head strong{flex:1}.ai-progress-head small{font-weight:900;color:var(--muted);font-variant-numeric:tabular-nums}.ai-progress-spinner{width:18px;height:18px;border:3px solid #c9d6ec;border-top-color:var(--brand);border-radius:50%;animation:aiProgressSpin 900ms linear infinite}.ai-progress-bar{height:6px;background:#eef2f7;overflow:hidden}.ai-progress-bar span{display:block;width:8%;height:100%;background:var(--brand);transition:width 700ms ease}.ai-progress-metrics{display:grid;grid-template-columns:repeat(3,1fr);margin:0}.ai-progress-metrics div{padding:10px 12px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.ai-progress-metrics div:last-child{border-right:0}.ai-progress-metrics dt{font-size:11px;font-weight:900;text-transform:uppercase;color:var(--muted)}.ai-progress-metrics dd{margin:3px 0 0;font-weight:900}.ai-structure-progress[hidden]{display:none}.ai-structure-progress.htmx-request,.ai-structure-progress[data-ai-structure-progress-state="running"]{display:block}@keyframes aiProgressSpin{to{transform:rotate(360deg)}}
@media(max-width:980px){.layout{grid-template-columns:1fr;height:auto}.tree,.inspector{max-height:320px}.editor{min-height:520px}.hero{display:block}.shell{padding:16px}}
@media(max-width:980px){.access-layout{grid-template-columns:1fr;height:auto}.access-nav,.access-side{max-height:360px}.access-role-grid{grid-template-columns:1fr}.access-role-grid .access-card:nth-child(odd){border-right:0}}
@media(max-width:980px){.access-builder-form{grid-template-columns:1fr}.access-builder-actions{justify-content:stretch}.access-builder-actions button{flex:1}}
@media(max-width:1180px){.ai-structure-form{grid-template-columns:repeat(2,minmax(0,1fr))}.ai-structure-field-wide{grid-column:1/-1}}
@media(max-width:1180px){.ai-structure-hint-grid{grid-template-columns:1fr 1fr}.ai-structure-hint-grid .access-card:nth-child(3n+1),.ai-structure-hint-grid .access-card:nth-child(3n+2){border-right:0}.ai-structure-hint-grid .access-card:nth-child(odd){border-right:1px solid var(--line)}}
@media(max-width:1180px){.ai-structure-tree-grid{grid-template-columns:1fr 1fr}.ai-structure-tree-grid .access-card:nth-child(odd){border-right:1px solid var(--line)}}
@media(max-width:700px){.ai-structure-form{grid-template-columns:1fr}.ai-structure-field-wide,.ai-structure-field-compact,.ai-structure-submit{grid-column:1/-1}}
@media(max-width:700px){.ai-structure-hint-grid{grid-template-columns:1fr}.ai-structure-hint-grid .access-card:nth-child(odd){border-right:0}}
@media(max-width:700px){.ai-structure-tree-grid{grid-template-columns:1fr}.ai-structure-tree-grid .access-card:nth-child(odd){border-right:0}}
@media(max-width:980px){.ai-progress-metrics{grid-template-columns:1fr}}
@media(max-width:980px){.form-designer-body{grid-template-columns:1fr}.form-property-panel{border-left:0;border-top:1px solid var(--line)}.form-field[data-html5-form-width="half"],.form-field[data-html5-form-width="third"]{grid-column:1/-1}.form-editor-row[data-html5-form-element-editor]{grid-template-columns:1fr}.property-row{grid-template-columns:1fr}}
@media(max-width:980px){.setup-layout{grid-template-columns:1fr}.setup-metrics{grid-template-columns:1fr 1fr}.settings-form{grid-template-columns:1fr}}
@@ -0,0 +1,290 @@
/*
Server Sent Events Extension
============================
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
*/
(function() {
/** @type {import("../htmx").HtmxInternalApi} */
var api
htmx.defineExtension('sse', {
/**
* Init saves the provided reference to the internal HTMX API.
*
* @param {import("../htmx").HtmxInternalApi} api
* @returns void
*/
init: function(apiRef) {
// store a reference to the internal API.
api = apiRef
// set a function in the public API for creating new EventSource objects
if (htmx.createEventSource == undefined) {
htmx.createEventSource = createEventSource
}
},
getSelectors: function() {
return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
},
/**
* onEvent handles all events passed to this extension.
*
* @param {string} name
* @param {Event} evt
* @returns void
*/
onEvent: function(name, evt) {
var parent = evt.target || evt.detail.elt
switch (name) {
case 'htmx:beforeCleanupElement':
var internalData = api.getInternalData(parent)
// Try to remove remove an EventSource when elements are removed
var source = internalData.sseEventSource
if (source) {
api.triggerEvent(parent, 'htmx:sseClose', {
source,
type: 'nodeReplaced',
})
internalData.sseEventSource.close()
}
return
// Try to create EventSources when elements are processed
case 'htmx:afterProcessNode':
ensureEventSourceOnElement(parent)
}
}
})
/// ////////////////////////////////////////////
// HELPER FUNCTIONS
/// ////////////////////////////////////////////
/**
* createEventSource is the default method for creating new EventSource objects.
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
*
* @param {string} url
* @returns EventSource
*/
function createEventSource(url) {
return new EventSource(url, { withCredentials: true })
}
/**
* registerSSE looks for attributes that can contain sse events, right
* now hx-trigger and sse-swap and adds listeners based on these attributes too
* the closest event source
*
* @param {HTMLElement} elt
*/
function registerSSE(elt) {
// Add message handlers for every `sse-swap` attribute
if (api.getAttributeValue(elt, 'sse-swap')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
var sseEventNames = sseSwapAttr.split(',')
for (var i = 0; i < sseEventNames.length; i++) {
const sseEventName = sseEventNames[i].trim()
const listener = function(event) {
// If the source is missing then close SSE
if (maybeCloseSSESource(sourceElement)) {
return
}
// If the body no longer contains the element, remove the listener
if (!api.bodyContains(elt)) {
source.removeEventListener(sseEventName, listener)
return
}
// swap the response into the DOM and trigger a notification
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
return
}
swap(elt, event.data)
api.triggerEvent(elt, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(sseEventName, listener)
}
}
// Add message handlers for every `hx-trigger="sse:*"` attribute
if (api.getAttributeValue(elt, 'hx-trigger')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var triggerSpecs = api.getTriggerSpecs(elt)
triggerSpecs.forEach(function(ts) {
if (ts.trigger.slice(0, 4) !== 'sse:') {
return
}
var listener = function (event) {
if (maybeCloseSSESource(sourceElement)) {
return
}
if (!api.bodyContains(elt)) {
source.removeEventListener(ts.trigger.slice(4), listener)
}
// Trigger events to be handled by the rest of htmx
htmx.trigger(elt, ts.trigger, event)
htmx.trigger(elt, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(ts.trigger.slice(4), listener)
})
}
}
/**
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
* is created and stored in the element's internalData.
* @param {HTMLElement} elt
* @param {number} retryCount
* @returns {EventSource | null}
*/
function ensureEventSourceOnElement(elt, retryCount) {
if (elt == null) {
return null
}
// handle extension source creation attribute
if (api.getAttributeValue(elt, 'sse-connect')) {
var sseURL = api.getAttributeValue(elt, 'sse-connect')
if (sseURL == null) {
return
}
ensureEventSource(elt, sseURL, retryCount)
}
registerSSE(elt)
}
function ensureEventSource(elt, url, retryCount) {
var source = htmx.createEventSource(url)
source.onerror = function(err) {
// Log an error event
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
// If parent no longer exists in the document, then clean up this EventSource
if (maybeCloseSSESource(elt)) {
return
}
// Otherwise, try to reconnect the EventSource
if (source.readyState === EventSource.CLOSED) {
retryCount = retryCount || 0
retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
var timeout = retryCount * 500
window.setTimeout(function() {
ensureEventSourceOnElement(elt, retryCount)
}, timeout)
}
}
source.onopen = function(evt) {
api.triggerEvent(elt, 'htmx:sseOpen', { source })
if (retryCount && retryCount > 0) {
const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
for (let i = 0; i < childrenToFix.length; i++) {
registerSSE(childrenToFix[i])
}
// We want to increase the reconnection delay for consecutive failed attempts only
retryCount = 0
}
}
api.getInternalData(elt).sseEventSource = source
var closeAttribute = api.getAttributeValue(elt, "sse-close");
if (closeAttribute) {
// close eventsource when this message is received
source.addEventListener(closeAttribute, function() {
api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'message',
})
source.close()
});
}
}
/**
* maybeCloseSSESource confirms that the parent element still exists.
* If not, then any associated SSE source is closed and the function returns true.
*
* @param {HTMLElement} elt
* @returns boolean
*/
function maybeCloseSSESource(elt) {
if (!api.bodyContains(elt)) {
var source = api.getInternalData(elt).sseEventSource
if (source != undefined) {
api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'nodeMissing',
})
source.close()
// source = null
return true
}
}
return false
}
/**
* @param {HTMLElement} elt
* @param {string} content
*/
function swap(elt, content) {
api.withExtensions(elt, function(extension) {
content = extension.transformResponse(content, null, elt)
})
var swapSpec = api.getSwapSpecification(elt)
var target = api.getTarget(elt)
api.swap(target, content, swapSpec)
}
function hasEventSource(node) {
return api.getInternalData(node).sseEventSource != null
}
})()
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
from __future__ import annotations
from datetime import datetime, timezone
def current_timestamp() -> str:
return datetime.now(timezone.utc).isoformat()
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,10 @@
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import tempfile
from enum import Enum
from pathlib import Path
@@ -134,7 +138,19 @@ async def runtime_import(request: RuntimeImportRequest) -> RuntimeImportResponse
],
dump_plan=dump_plan,
)
raise HTTPException(status_code=501, detail="Designer execution runner is not implemented yet")
if source_kind in {"CF_FILE", "CFE_FILE"}:
if not request.path:
raise HTTPException(status_code=400, detail="path is required for CF/CFE import")
dump_root, execution_logs = _convert_local_cf_or_cfe_to_metadata_dump(request, platform_status)
return RuntimeImportResponse(
status="normalized",
mode=mode.value,
platform_found=True,
normalized_project=normalize_one_c_project(dump_root, project_id=request.project_id),
diagnostics=[*platform_status.diagnostics, *execution_logs],
dump_plan=dump_plan,
)
raise HTTPException(status_code=501, detail=f"Designer execution runner is not implemented yet for {source_kind}")
def _mode() -> RuntimeMode:
@@ -217,6 +233,105 @@ def _designer_dump_plan(request: RuntimeImportRequest) -> list[str]:
return plan
def _convert_local_cf_or_cfe_to_metadata_dump(
request: RuntimeImportRequest,
platform_status: RuntimePlatformResponse,
) -> tuple[Path, list[str]]:
source_kind = request.source_kind.upper()
payload_path = Path(request.path or "")
if not payload_path.exists():
raise HTTPException(status_code=404, detail=f"Path not found: {request.path}")
if not platform_status.designer_path:
raise HTTPException(status_code=503, detail="1C Designer CLI path is not configured")
export_root = Path(tempfile.mkdtemp(prefix=f"sfera-runtime-{request.project_id or 'project'}-"))
builder_infobase = export_root / "builder-infobase"
logs: list[str] = []
_run_designer_command(
platform_status.designer_path,
["CREATEINFOBASE", f"File={builder_infobase};"],
export_root / "create-builder-infobase.log",
"1C CREATEINFOBASE for local CF/CFE conversion",
)
builder_args = ["/F", str(builder_infobase)]
artifacts_root = export_root / "artifacts"
artifacts_root.mkdir(parents=True, exist_ok=True)
shutil.copyfile(payload_path, artifacts_root / payload_path.name)
if source_kind == "CF_FILE":
_run_designer_command(
platform_status.designer_path,
[*builder_args, "/LoadCfg", str(payload_path)],
export_root / "designer-loadcfg-local-cf.log",
"1C LoadCfg local CF",
)
metadata_root = export_root / "configuration"
metadata_root.mkdir(parents=True, exist_ok=True)
_run_designer_command(
platform_status.designer_path,
[*builder_args, "/DumpConfigToFiles", str(metadata_root), "-Format", "Hierarchical"],
export_root / "designer-dumpconfigtofiles-local-cf.log",
"1C DumpConfigToFiles from local CF",
)
shutil.copyfile(payload_path, metadata_root / payload_path.name)
logs.append("Local .cf converted to metadata export for server-side parsing.")
return export_root, logs
if source_kind == "CFE_FILE":
extension_name = str(request.metadata.get("one_c_extension") or payload_path.stem).strip()
if not extension_name:
raise HTTPException(status_code=400, detail="Extension name is required for local CFE conversion.")
_run_designer_command(
platform_status.designer_path,
[*builder_args, "/LoadCfg", str(payload_path), "-Extension", extension_name, "/UpdateDBCfg"],
export_root / "designer-loadcfg-local-cfe.log",
"1C LoadCfg local CFE",
)
metadata_root = export_root / "extension"
metadata_root.mkdir(parents=True, exist_ok=True)
_run_designer_command(
platform_status.designer_path,
[*builder_args, "/DumpConfigToFiles", str(metadata_root), "-Format", "Hierarchical", "-Extension", extension_name],
export_root / "designer-dumpconfigtofiles-local-cfe.log",
"1C DumpConfigToFiles from local CFE",
)
shutil.copyfile(payload_path, metadata_root / payload_path.name)
logs.append("Local .cfe converted to metadata export for server-side parsing.")
return export_root, logs
raise HTTPException(status_code=400, detail=f"Unsupported local 1C source: {source_kind}")
def _designer_process_command(designer_path: str, arguments: list[str]) -> list[str]:
path = Path(designer_path)
if path.suffix.casefold() == ".py":
return [sys.executable, designer_path, *arguments]
return [designer_path, *arguments]
def _run_designer_command(designer_path: str, arguments: list[str], log_path: Path, action_title: str, timeout_seconds: int = 180) -> None:
command = _designer_process_command(designer_path, arguments)
log_path.parent.mkdir(parents=True, exist_ok=True)
completed = subprocess.run(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout_seconds,
check=False,
)
output = completed.stdout or ""
log_path.write_text(output, encoding="utf-8")
if completed.returncode != 0:
tail = output[-4000:] if output else ""
raise HTTPException(
status_code=500,
detail=f"{action_title} failed with code {completed.returncode}. {tail}".strip(),
)
def _redact_connection_string(value: str) -> str:
sensitive_keys = {"pwd", "password", "пароль"}
chunks: list[str] = []
@@ -245,6 +360,11 @@ def _request_fingerprint(request: RuntimeImportRequest) -> str:
def _mock_project(project_id: str | None) -> NormalizedProject:
from one_c_normalizer import (
AccessGroup,
AccessModel,
AccessProfile,
AccessRoleAssignment,
AccessUser,
Command,
ConfigurationRoot,
Extension,
@@ -258,6 +378,31 @@ def _mock_project(project_id: str | None) -> NormalizedProject:
return NormalizedProject(
project_id=project_id,
source_path="mock://runtime-adapter",
access=AccessModel(
profiles=[
AccessProfile(
name="МенеджерПродаж",
qualified_name="ПрофильГруппыДоступа.МенеджерПродаж",
roles=[AccessRoleAssignment(role="Менеджер", role_qualified_name="Роль.Менеджер")],
)
],
groups=[
AccessGroup(
name="ОтделПродаж",
qualified_name="ГруппаДоступа.ОтделПродаж",
profile="МенеджерПродаж",
users=["demo.user"],
)
],
users=[
AccessUser(
name="demo.user",
qualified_name="Пользователь.demo.user",
full_name="Demo User",
groups=["ОтделПродаж"],
)
],
),
configuration=ConfigurationRoot(
groups=[
MetadataGroup(
@@ -111,3 +111,47 @@ def test_runtime_platform_reports_capabilities(monkeypatch, tmp_path):
payload = response.json()
assert payload["platform_found"] is True
assert "cf_dump_plan" in payload["capabilities"]
def test_runtime_adapter_local_1c_executes_cf_dump(monkeypatch, tmp_path):
designer = tmp_path / "fake_designer.py"
designer.write_text(
"""
import sys
from pathlib import Path
args = sys.argv[1:]
if args and args[0] == "CREATEINFOBASE":
target = next(item for item in args[1:] if item.startswith("File="))[5:].rstrip(";")
Path(target).mkdir(parents=True, exist_ok=True)
raise SystemExit(0)
if "/DumpConfigToFiles" in args:
target = Path(args[args.index("/DumpConfigToFiles") + 1])
target.mkdir(parents=True, exist_ok=True)
(target / "metadata.xml").write_text(
"<Configuration><Catalog name='Контрагенты' qualifiedName='Справочник.Контрагенты' /></Configuration>",
encoding="utf-8",
)
raise SystemExit(0)
raise SystemExit(0)
""",
encoding="utf-8",
)
cf_file = tmp_path / "demo.cf"
cf_file.write_text("binary-placeholder", encoding="utf-8")
monkeypatch.setenv("RUNTIME_ADAPTER_MODE", "local_1c")
monkeypatch.setenv("ONEC_DESIGNER_PATH", str(designer))
monkeypatch.setenv("ONEC_ENABLE_DESIGNER_EXECUTION", "true")
client = TestClient(app)
response = client.post(
"/runtime/import",
json={"source_kind": "CF_FILE", "project_id": "cf-exec", "path": str(cf_file)},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "normalized"
assert payload["platform_found"] is True
assert payload["normalized_project"]["project_id"] == "cf-exec"
assert "Local .cf converted to metadata export" in "\n".join(payload["diagnostics"])
Generated
+11
View File
@@ -505,6 +505,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.28"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/54/a85eb421fbdd5007bc5af39d0f4ed9fa609e0fedbfdc2adcf0b34526870e/python_multipart-0.0.28.tar.gz", hash = "sha256:8550da197eac0f7ab748961fc9509b999fa2662ea25cef857f05249f6893c0f8", size = 45314, upload-time = "2026-05-10T11:05:16.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/a2/43bbc5860b5034e2af4ef99a0e04d726ff329c43e192ef3abaa8d7ecfce5/python_multipart-0.0.28-py3-none-any.whl", hash = "sha256:10faac07eb966c3f48dc415f9dee46c04cb10d58d30a35677db8027c825ed9b6", size = 29438, upload-time = "2026-05-10T11:05:15.052Z" },
]
[[package]]
name = "pytz"
version = "2026.2"
@@ -521,6 +530,7 @@ source = { editable = "services/api-server" }
dependencies = [
{ name = "fastapi" },
{ name = "neo4j" },
{ name = "python-multipart" },
{ name = "sfera-collaboration" },
{ name = "sfera-impact-engine" },
{ name = "sfera-incremental-indexer" },
@@ -550,6 +560,7 @@ dependencies = [
requires-dist = [
{ name = "fastapi", specifier = ">=0.115" },
{ name = "neo4j", specifier = ">=5.0" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "sfera-collaboration", editable = "packages/collaboration" },
{ name = "sfera-impact-engine", editable = "packages/impact-engine" },
{ name = "sfera-incremental-indexer", editable = "packages/incremental-indexer" },