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 t = messages[language];
const objectForms = data.selectedObjectUi?.forms.length ? data.selectedObjectUi.forms : data.forms; const objectForms = data.selectedObjectUi?.forms.length ? data.selectedObjectUi.forms : data.forms;
const [selectedFormId, setSelectedFormId] = useState<string | null>(objectForms[0]?.form.lineage_id ?? null); 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(() => { 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)) { if (objectForms.length > 0 && !objectForms.some((item) => item.form.lineage_id === selectedFormId)) {
setSelectedFormId(objectForms[0].form.lineage_id); setSelectedFormId(objectForms[0].form.lineage_id);
} }
}, [objectForms, selectedFormId]); }, [objectForms, selectedFormId, selectedFormQualifiedName]);
const form = objectForms.find((item) => item.form.lineage_id === selectedFormId) ?? objectForms[0]; const form =
const elements = form?.elements.slice(0, 8) ?? []; 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 commands = form?.commands.slice(0, 6) ?? [];
const formTitle = form?.form.name ?? "ФормаДокумента"; const formKey = form?.form.lineage_id ?? "draft";
const formObjectCaption = language === "ru" ? `${formTitle} (форма 1С)` : `${formTitle} (1C form)`; const baseElements = useMemo(() => buildIdeFormElements(form), [form]);
const commandLabels = [t.postAndClose, ...commands.slice(0, 2).map((command) => command.name)]; 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 ( return (
<Card className="min-h-0 rounded-none border-0 p-0 shadow-none" data-active-mode={modeId}> <Card className="min-h-0 rounded-none border-0 p-0 shadow-none" data-active-mode={modeId}>
<PanelTitle icon={Layers3} title={t.formDesigner} /> <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="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="flex items-center justify-between border-b border-border pb-2">
<div className="text-xs font-semibold uppercase text-muted-foreground">{t.forms}</div> <div className="text-xs font-semibold uppercase text-muted-foreground">{t.forms}</div>
@@ -3082,19 +3134,24 @@ function FormDesignerPanel({
</div> </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-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"> <div className="mt-3 space-y-1">
{elements.length === 0 ? ( {flatElements.length === 0 ? (
<div className="text-sm text-muted-foreground">{t.none}</div> <div className="text-sm text-muted-foreground">{t.none}</div>
) : ( ) : (
elements.map((element) => ( sidebarElements.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}> <div className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-background" key={element.id}>
<OneCTreeIcon kind={kindForTreeLabel(element.kind)} /> <OneCTreeIcon kind={element.controlKind === "table" ? "tabular" : "attribute"} />
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate font-medium text-foreground">{element.name}</div> <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>
</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>
<div className="mt-5 border-t border-border pt-3 text-xs font-semibold uppercase text-muted-foreground">{t.commands}</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"> <div className="mt-3 space-y-1">
@@ -3107,146 +3164,335 @@ function FormDesignerPanel({
</div> </div>
</div> </div>
<div className="min-h-0 overflow-auto bg-[#ececec] p-5"> <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="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 items-start justify-between"> <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>
<div className="text-lg font-medium text-slate-950">{formObjectCaption}</div> <div className="font-semibold">{formObjectCaption}</div>
<button className="mt-8 inline-flex items-center gap-1 text-sm text-slate-900" type="button"> <div className="text-[11px] text-slate-500">{data.selectedObjectSchema?.object.qualified_name ?? form?.form.qualified_name ?? data.projectId}</div>
{t.mainSection}
<ChevronDown className="h-3.5 w-3.5" />
</button>
</div> </div>
<div className="flex items-center gap-2 text-slate-500"> <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" /> <MoreVertical className="h-4 w-4" />
</button> </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" /> <Maximize2 className="h-4 w-4" />
</button> </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" /> <X className="h-4 w-4" />
</button> </button>
</div> </div>
</div> </div>
<div className="mt-7 grid max-w-3xl grid-cols-[minmax(0,360px)_minmax(0,360px)] gap-x-3 gap-y-3"> <div className="flex min-h-11 flex-wrap items-center gap-1 border-b border-[#ccd4df] bg-[#f4f6f9] px-3 py-2">
<label className="grid gap-1 text-xs text-slate-700"> {commands.length ? commands.map((command) => (
<span><span className="text-red-500">*</span>{t.nameField}</span> <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">
<span className="flex h-8 items-center rounded-md border border-slate-300 bg-white px-2 text-sm text-slate-900">{formTitle}</span> {command.name}
</label> </button>
<label className="grid gap-1 text-xs text-slate-700"> )) : <span className="text-xs text-muted-foreground">{language === "ru" ? "Команды формы не описаны" : "No form commands"}</span>}
{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> </div>
<div className="mt-4 border-b border-slate-200"> <div className={[
<div className="flex gap-5 text-sm"> "grid grid-cols-12 gap-x-3 gap-y-2 p-5",
{[t.goods, t.sites, t.compensationTerms, t.agencyAgreements, t.telegram, t.mail].map((tab, index) => ( layout === "compact" ? "gap-y-1" : ""
<button ].join(" ")}>
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"} {elements.length ? (
key={tab} elements.map((element) => (
type="button" <IdeFormControl element={element} forceHalf={layout === "columns"} key={element.id} />
> ))
{tab} ) : (
</button> <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>
<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>
<div className="mt-3 overflow-hidden rounded-md border border-slate-100"> <div className="mt-auto border-t border-[#ccd4df] bg-white px-4 py-3">
<div className="flex h-10 items-center gap-3 bg-[#f0f0f0] px-3 text-sm text-slate-800"> <div className="flex flex-wrap justify-end gap-2">
<button className="inline-flex h-8 items-center gap-2 rounded px-2 hover:bg-white" type="button"> {commands.slice(0, 3).map((command) => (
<Plus className="h-4 w-4" /> <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">
{t.create} {command.name}
</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}
</button> </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} {t.saveAndClose}
</button> </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} {t.save}
</button> </button>
</div> </div>
</div> </div>
</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> </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({ function MetadataDesignerPanel({
activeMode, activeMode,
data, data,
@@ -772,7 +772,10 @@ export function ProjectSetupClient({ initialSetup }: Readonly<{ initialSetup: Pr
return; return;
} else if (mode === "reindex") { } else if (mode === "reindex") {
setLastSyncPreview(null); 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 { } else {
setLastImportResult(null); setLastImportResult(null);
setServerImportJob(null); setServerImportJob(null);
@@ -5500,7 +5503,7 @@ function ObjectWorkbench({
<ObjectPartList title="Модули" rows={object.modules} /> <ObjectPartList title="Модули" rows={object.modules} />
<ObjectRightsList rows={object.rights} /> <ObjectRightsList rows={object.rights} />
</div> </div>
<FormDesignerPanel object={object} /> <FormDesignerPanel projectId={projectId} object={object} />
<ObjectImpactPanel impact={objectImpact} /> <ObjectImpactPanel impact={objectImpact} />
<ModuleSourcePanel <ModuleSourcePanel
projectId={projectId} 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 [activeFormName, setActiveFormName] = useState<string | null>(null);
const activeForm = object.forms.find((form) => form.name === activeFormName) ?? object.forms[0] ?? 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 ( return (
<div className="mt-5 rounded-md border border-border"> <div className="mt-5 overflow-hidden rounded-md border border-border">
<div className="flex h-10 items-center justify-between border-b border-border px-3"> <div className="flex min-h-10 items-center justify-between gap-3 border-b border-border px-3 py-2">
<div className="text-sm font-medium">Form designer</div> <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> <Badge>{object.forms.length}</Badge>
</div> </div>
</div>
{activeForm ? ( {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"> <div className="border-r border-border p-2">
{object.forms.map((form) => ( {object.forms.map((form) => (
<button <button
@@ -5535,40 +5595,144 @@ function FormDesignerPanel({ object }: Readonly<{ object: NormalizedMetadataObje
}`} }`}
> >
<div className="truncate font-medium">{form.name}</div> <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> </button>
))} ))}
</div> </div>
<div className="min-w-0 p-3"> <div className="min-w-0 bg-[#f3f5f8] p-5">
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mx-auto min-h-[430px] max-w-[760px] border border-[#b8c0ca] bg-[#fbfbfc] shadow-lg" data-legacy-form-window>
<div className="min-w-0"> <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">
<div className="truncate text-sm font-semibold">{activeForm.name}</div> <span className="truncate">{formTitle}</span>
<div className="truncate text-xs text-muted-foreground">{object.qualified_name}</div> <span className="text-[11px] text-muted-foreground">1C:Enterprise 8.5-style managed form</span>
</div> </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>
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3"> <div
{formFields.slice(0, 18).map((field) => ( className={`grid grid-cols-12 gap-x-3 gap-y-2 p-4 ${layout === "compact" ? "gap-y-1" : ""}`}
<div key={`field-${field.kind}-${field.name}`} className="rounded-md border border-border p-2"> data-legacy-form-layout={layout}
<div className="truncate text-sm font-medium">{field.name}</div> >
<div className="truncate text-xs text-muted-foreground">{field.kind}</div> {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> </div>
))} )}
{formFields.length === 0 ? (
<div className="text-sm text-muted-foreground">Поля формы не найдены в structure-only metadata.</div>
) : null}
</div> </div>
</div> </div>
<div className="border-l border-border p-3"> </div>
<div className="text-sm font-medium">Form context</div> <div className="overflow-auto border-l border-border bg-background">
<div className="mt-3 grid grid-cols-2 gap-2"> <div className="border-b border-border p-3">
<SmallMetric label="fields" value={formFields.length} /> <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="commands" value={object.commands.length} />
<SmallMetric label="modules" value={object.modules.length} /> <SmallMetric label="modules" value={object.modules.length} />
<SmallMetric label="events" value={object.events.length} /> <SmallMetric label="events" value={object.events.length} />
</div> </div>
<ObjectMetaList title="Commands" rows={object.commands} /> <div className="border-b border-border p-3">
<ObjectMetaList title="Form metadata" rows={Object.entries(activeForm.attributes ?? {}).map(([key, value]) => `${key}: ${String(value)}`)} /> <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>
</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[] }>) { function ObjectMetaList({ title, rows }: Readonly<{ title: string; rows: ObjectPart[] | string[] }>) {
return ( return (
<div className="mt-3"> <div className="mt-3">
+31 -2
View File
@@ -139,6 +139,7 @@ export type NamedNode = {
kind: string; kind: string;
name: string; name: string;
qualified_name: string; qualified_name: string;
attributes: Record<string, unknown>;
}; };
export type SourceLocation = { export type SourceLocation = {
@@ -816,6 +817,22 @@ export async function getBslCompletions(
return getJson<BslCompletionItem[]>(apiUrl, `/projects/${projectId}/bsl/completions?${params.toString()}`); 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) { export async function getProjectWorkspaceData(projectId: string, apiUrl = resolveApiUrl(), selectedRoutine?: string | null, activeMode?: string | null) {
const selectedRoutineName = selectedRoutine?.trim() ?? null; const selectedRoutineName = selectedRoutine?.trim() ?? null;
const workspaceMode = activeMode?.trim() || "overview"; const workspaceMode = activeMode?.trim() || "overview";
@@ -850,7 +867,18 @@ export async function getProjectWorkspaceData(projectId: string, apiUrl = resolv
); );
selectedTreeNode = firstCommonModulePage?.children[0] ?? null; 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 const selectedObjectModules = selectedObjectName
? getOptionalJson<WorkspaceModuleSource[]>( ? getOptionalJson<WorkspaceModuleSource[]>(
apiUrl, apiUrl,
@@ -956,7 +984,7 @@ export async function getProjectWorkspaceData(projectId: string, apiUrl = resolv
apiUrl, apiUrl,
metadataCatalog, metadataCatalog,
metadataTree, metadataTree,
selectedMetadataNode: selectedMetadataSearch?.results[0] ?? null, selectedMetadataNode: selectedTreeNode,
selectedObjectSchema, selectedObjectSchema,
selectedObjectUi, selectedObjectUi,
selectedObjectImpact, selectedObjectImpact,
@@ -977,6 +1005,7 @@ export async function getProjectWorkspaceData(projectId: string, apiUrl = resolv
editorProposedText: authoringProposedText, editorProposedText: authoringProposedText,
editorSelectedObject, editorSelectedObject,
editorSelectedRoutine, editorSelectedRoutine,
editorSelectedForm: selectedFormQualifiedName,
editorModules: objectModules, editorModules: objectModules,
editorModuleName: selectedObjectModule?.name ?? snapshotModule?.name ?? null, editorModuleName: selectedObjectModule?.name ?? snapshotModule?.name ?? null,
editorSourcePath: selectedObjectModule?.source_path ?? snapshotModule?.source_path ?? 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.read` - чтение данных через ограниченный запрос или менеджер объекта.
- `data.write` - изменение данных только при явном `allow_mutation`. - `data.write` - изменение данных только при явном `allow_mutation`.
- `metadata.apply` - изменение структуры не выполняется из HTTP runtime. Возвращает план установки `.cfe`; применение делает Windows Agent через Designer. - `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`. - `dry_run=false`.
Без этого операции изменения возвращают блокировку. Без этого операции изменения возвращают блокировку.
@@ -49,7 +49,8 @@
КонецЕсли; КонецЕсли;
Возврат ОтветJSON(BridgeMetadataApply( Возврат ОтветJSON(BridgeMetadataApply(
ПолучитьПоле(Контекст, "payload", Новый Структура), ПолучитьПоле(Контекст, "payload", Новый Структура),
ПолучитьПоле(Контекст, "dry_run", Истина))); ПолучитьПоле(Контекст, "dry_run", Истина),
ПолучитьПоле(Контекст, "allow_mutation", Ложь)));
КонецФункции КонецФункции
#КонецОбласти #КонецОбласти
@@ -64,6 +65,7 @@
Результат.Вставить("timestamp", ТекущаяДата()); Результат.Вставить("timestamp", ТекущаяДата());
Результат.Вставить("mutation_supported", Истина); Результат.Вставить("mutation_supported", Истина);
Результат.Вставить("metadata_apply_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."); Возврат Ошибка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"); Результат.Вставить("status", "planned");
Результат.Вставить("message", "Changing configuration structure is performed by SFERA Windows Agent through Designer and .cfe update, not by runtime HTTP."); Результат.Вставить("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 from __future__ import annotations
import hashlib import hashlib
from pathlib import Path from pathlib import Path, PurePosixPath
import re import re
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
@@ -71,6 +71,49 @@ class Rights(ObjectPart):
permissions: dict = {} 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): class Extension(ObjectPart):
kind: str = "EXTENSION" kind: str = "EXTENSION"
version: str | None = None version: str | None = None
@@ -137,6 +180,7 @@ class NormalizedProject(BaseModel):
project_id: str | None = None project_id: str | None = None
configuration: ConfigurationRoot configuration: ConfigurationRoot
source_path: str | None = None source_path: str | None = None
access: AccessModel = Field(default_factory=AccessModel)
def normalize_bsl_source(text: str) -> str: 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) source_path = normalize_source_path(path)
root = ET.fromstring(_read_text_file(Path(path))) root = ET.fromstring(_read_text_file(Path(path)))
result: list[OneCXmlObject] = [] 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 return result
@@ -207,6 +251,9 @@ def build_normalized_project(
extension_metadata_objects: dict[str, dict[str, MetadataObject]] = {} extension_metadata_objects: dict[str, dict[str, MetadataObject]] = {}
part_owners: dict[str, tuple[MetadataObject, ObjectPart]] = {} part_owners: dict[str, tuple[MetadataObject, ObjectPart]] = {}
pending_roles: dict[str, Role] = {} pending_roles: dict[str, Role] = {}
access_profiles: dict[str, AccessProfile] = {}
access_groups: dict[str, AccessGroup] = {}
access_users: dict[str, AccessUser] = {}
extensions: list[Extension] = [] extensions: list[Extension] = []
extension_by_qualified_name: dict[str, Extension] = {} extension_by_qualified_name: dict[str, Extension] = {}
saw_configuration = False saw_configuration = False
@@ -248,6 +295,19 @@ def build_normalized_project(
metadata=dict(item.attributes), metadata=dict(item.attributes),
rights=role.rights, 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: elif item.object_kind in _ROOT_METADATA_OBJECT_KINDS:
target_objects = _metadata_target_for_item( target_objects = _metadata_target_for_item(
item, item,
@@ -292,6 +352,11 @@ def build_normalized_project(
return NormalizedProject( return NormalizedProject(
project_id=project_id, project_id=project_id,
source_path=source_path, 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( configuration=ConfigurationRoot(
name=configuration_name, name=configuration_name,
metadata=configuration_metadata, metadata=configuration_metadata,
@@ -344,6 +409,143 @@ def _all_metadata_objects(
return result 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( def _walk_xml_objects(
source_path: str, source_path: str,
element: ET.Element, element: ET.Element,
@@ -351,26 +553,37 @@ def _walk_xml_objects(
*, *,
current_role: OneCXmlObject | None, current_role: OneCXmlObject | None,
parent_qualified_name: str | None, parent_qualified_name: str | None,
parent_object_kind: str | None,
) -> None: ) -> None:
role_context = current_role role_context = current_role
child_parent_qualified_name = parent_qualified_name 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": if object_kind == "RIGHT":
right = _xml_right_object(source_path, element, role_context) right = _xml_right_object(source_path, element, role_context)
if right is not None: if right is not None:
result.append(right) 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: elif object_kind is not None:
name = _xml_name(element) name = _xml_name(element, source_path=source_path)
if name: if name:
xml_object = OneCXmlObject( xml_object = OneCXmlObject(
source_path=source_path, source_path=source_path,
object_kind=object_kind, object_kind=object_kind,
name=name, 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), attributes=_xml_attributes(element),
) )
result.append(xml_object) result.append(xml_object)
child_parent_qualified_name = xml_object.qualified_name child_parent_qualified_name = xml_object.qualified_name
child_parent_object_kind = object_kind
if object_kind == "ROLE": if object_kind == "ROLE":
role_context = xml_object role_context = xml_object
@@ -381,6 +594,7 @@ def _walk_xml_objects(
result, result,
current_role=role_context, current_role=role_context,
parent_qualified_name=child_parent_qualified_name, 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 "" 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 = { _OBJECT_KIND_BY_TAG = {
"configuration": "PROJECT", "configuration": "PROJECT",
"конфигурация": "PROJECT", "конфигурация": "PROJECT",
@@ -502,6 +766,13 @@ _OBJECT_KIND_BY_TAG = {
"subsystem": "SUBSYSTEM", "subsystem": "SUBSYSTEM",
"subsystems": "SUBSYSTEM", "subsystems": "SUBSYSTEM",
"подсистема": "SUBSYSTEM", "подсистема": "SUBSYSTEM",
"sequence": "SEQUENCE",
"sequences": "SEQUENCE",
"последовательность": "SEQUENCE",
"documentnumerator": "DOCUMENT_NUMERATOR",
"documentnumerators": "DOCUMENT_NUMERATOR",
"нумератордокументов": "DOCUMENT_NUMERATOR",
"нумератор": "DOCUMENT_NUMERATOR",
"httpservice": "HTTP_SERVICE", "httpservice": "HTTP_SERVICE",
"httpservices": "HTTP_SERVICE", "httpservices": "HTTP_SERVICE",
"httpсервис": "HTTP_SERVICE", "httpсервис": "HTTP_SERVICE",
@@ -529,6 +800,41 @@ _OBJECT_KIND_BY_TAG = {
"пакетxdto": "XDTO_PACKAGE", "пакетxdto": "XDTO_PACKAGE",
"role": "ROLE", "role": "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", "sessionparameter": "SESSION_PARAMETER",
"sessionparameters": "SESSION_PARAMETER", "sessionparameters": "SESSION_PARAMETER",
"параметрсеанса": "SESSION_PARAMETER", "параметрсеанса": "SESSION_PARAMETER",
@@ -695,10 +1001,38 @@ _OBJECT_KIND_BY_TAG = {
"предопределенный": "PREDEFINED", "предопределенный": "PREDEFINED",
} }
_FORM_ELEMENT_TAGS = {
"item",
"items",
"element",
"elements",
"formitem",
"formitems",
"field",
"fields",
"group",
"groups",
"table",
"tables",
"button",
"buttons",
"page",
"pages",
}
_QUALIFIED_PREFIX_BY_KIND = { _QUALIFIED_PREFIX_BY_KIND = {
"CATALOG": "Справочник", "CATALOG": "Справочник",
"DOCUMENT": "Документ", "DOCUMENT": "Документ",
"CONSTANT": "Константа",
"DOCUMENT_JOURNAL": "ЖурналДокументов",
"ENUM": "Перечисление",
"REPORT": "Отчет",
"DATA_PROCESSOR": "Обработка",
"CHART_OF_CHARACTERISTIC_TYPES": "ПланВидовХарактеристик",
"CHART_OF_ACCOUNTS": "ПланСчетов",
"CHART_OF_CALCULATION_TYPES": "ПланВидовРасчета",
"EXTERNAL_DATA_SOURCE": "ВнешнийИсточникДанных",
"REGISTER": "Регистр", "REGISTER": "Регистр",
"INFORMATION_REGISTER": "РегистрСведений", "INFORMATION_REGISTER": "РегистрСведений",
"ACCUMULATION_REGISTER": "РегистрНакопления", "ACCUMULATION_REGISTER": "РегистрНакопления",
@@ -711,6 +1045,8 @@ _QUALIFIED_PREFIX_BY_KIND = {
"BUSINESS_PROCESS": "БизнесПроцесс", "BUSINESS_PROCESS": "БизнесПроцесс",
"TASK": "Задача", "TASK": "Задача",
"SUBSYSTEM": "Подсистема", "SUBSYSTEM": "Подсистема",
"SEQUENCE": "Последовательность",
"DOCUMENT_NUMERATOR": "НумераторДокументов",
"HTTP_SERVICE": "HTTPСервис", "HTTP_SERVICE": "HTTPСервис",
"WEB_SERVICE": "WebСервис", "WEB_SERVICE": "WebСервис",
"WS_REFERENCE": "WSСсылка", "WS_REFERENCE": "WSСсылка",
@@ -738,6 +1074,9 @@ _QUALIFIED_PREFIX_BY_KIND = {
"STYLE_ITEM": "ЭлементСтиля", "STYLE_ITEM": "ЭлементСтиля",
"STYLE": "Стиль", "STYLE": "Стиль",
"LANGUAGE": "Язык", "LANGUAGE": "Язык",
"ACCESS_PROFILE": "ПрофильГруппыДоступа",
"ACCESS_GROUP": "ГруппаДоступа",
"ACCESS_USER": "Пользователь",
"FORM": "Форма", "FORM": "Форма",
"COMMAND": "Команда", "COMMAND": "Команда",
"URL_TEMPLATE": "ШаблонURL", "URL_TEMPLATE": "ШаблонURL",
@@ -780,6 +1119,8 @@ _QUALIFIED_PREFIX_BY_TAG = {
"bot": "Бот", "bot": "Бот",
"interface": "Интерфейс", "interface": "Интерфейс",
"fulltextsearchdictionary": "СловарьПолнотекстовогоПоиска", "fulltextsearchdictionary": "СловарьПолнотекстовогоПоиска",
"sequence": "Последовательность",
"documentnumerator": "НумераторДокументов",
} }
_ROOT_METADATA_OBJECT_KINDS = { _ROOT_METADATA_OBJECT_KINDS = {
@@ -807,6 +1148,8 @@ _ROOT_METADATA_OBJECT_KINDS = {
"BUSINESS_PROCESS", "BUSINESS_PROCESS",
"TASK", "TASK",
"SUBSYSTEM", "SUBSYSTEM",
"SEQUENCE",
"DOCUMENT_NUMERATOR",
"HTTP_SERVICE", "HTTP_SERVICE",
"WEB_SERVICE", "WEB_SERVICE",
"WS_REFERENCE", "WS_REFERENCE",
@@ -828,6 +1171,7 @@ _ROOT_METADATA_OBJECT_KINDS = {
"COMMON_LAYOUT", "COMMON_LAYOUT",
"COMMON_PICTURE", "COMMON_PICTURE",
"INTEGRATION_SERVICE", "INTEGRATION_SERVICE",
"EXTENSION",
"PALETTE_COLOR", "PALETTE_COLOR",
"STYLE_ITEM", "STYLE_ITEM",
"STYLE", "STYLE",
@@ -838,8 +1182,11 @@ _ROOT_METADATA_OBJECT_KINDS = {
_GROUP_BY_OBJECT_KIND = { _GROUP_BY_OBJECT_KIND = {
"PROJECT": "Конфигурация", "PROJECT": "Конфигурация",
"COMMON_MODULE": "Общие модули", "COMMON_MODULE": "Общие модули",
"CONSTANT": "Константы",
"CATALOG": "Справочники", "CATALOG": "Справочники",
"DOCUMENT": "Документы", "DOCUMENT": "Документы",
"DOCUMENT_JOURNAL": "Журналы документов",
"ENUM": "Перечисления",
"REGISTER": "Регистры", "REGISTER": "Регистры",
"INFORMATION_REGISTER": "Регистры сведений", "INFORMATION_REGISTER": "Регистры сведений",
"ACCUMULATION_REGISTER": "Регистры накопления", "ACCUMULATION_REGISTER": "Регистры накопления",
@@ -847,10 +1194,18 @@ _GROUP_BY_OBJECT_KIND = {
"CALCULATION_REGISTER": "Регистры расчета", "CALCULATION_REGISTER": "Регистры расчета",
"REPORT": "Отчеты", "REPORT": "Отчеты",
"DATA_PROCESSOR": "Обработки", "DATA_PROCESSOR": "Обработки",
"CHART_OF_CHARACTERISTIC_TYPES": "Планы видов характеристик",
"CHART_OF_ACCOUNTS": "Планы счетов",
"CHART_OF_CALCULATION_TYPES": "Планы видов расчета",
"BUSINESS_PROCESS": "Бизнес-процессы",
"TASK": "Задачи",
"EXTENSION": "Расширения конфигурации",
"FORM": "Формы", "FORM": "Формы",
"COMMAND": "Команды", "COMMAND": "Команды",
"ROLE": "Роли", "ROLE": "Роли",
"SUBSYSTEM": "Подсистемы", "SUBSYSTEM": "Подсистемы",
"SEQUENCE": "Последовательности",
"DOCUMENT_NUMERATOR": "Нумераторы документов",
"HTTP_SERVICE": "HTTP-сервисы", "HTTP_SERVICE": "HTTP-сервисы",
"WEB_SERVICE": "Web-сервисы", "WEB_SERVICE": "Web-сервисы",
"WS_REFERENCE": "WS-ссылки", "WS_REFERENCE": "WS-ссылки",
@@ -1095,9 +1450,15 @@ def _attach_bsl_modules(root: Path, normalized: NormalizedProject) -> None:
"original_hash": source.original_hash, "original_hash": source.original_hash,
"source_text": source.text, "source_text": source.text,
"module_role": role, "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: if form_name:
attributes["form_name"] = 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( owner.modules.append(
Module( Module(
name=source_file.stem, 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}" 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: def _form_name_for_module(root: Path, source_file: Path) -> str:
parts = list(_relative_path(source_file, root).parts) parts = list(_relative_path(source_file, root).parts)
normalized_parts = [_normalize_path_part(part) for part in 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() 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"}: if tag in {"metadataobject", "object"}:
type_name = _xml_type_name(element) type_name = _xml_type_name(element)
if type_name: if type_name:
@@ -1278,7 +1677,7 @@ def _xml_type_name(element: ET.Element) -> str:
return "" return ""
def _xml_name(element: ET.Element) -> str: def _xml_name(element: ET.Element, *, source_path: str = "") -> str:
for key in ("name", "Name", "Имя"): for key in ("name", "Name", "Имя"):
if key in element.attrib: if key in element.attrib:
return element.attrib[key] 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: if _local_name(child.tag).lower() in {"name", "имя"} and child.text:
return child.text.strip() return child.text.strip()
tag = _local_name(element.tag).lower() 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 = { fallback_keys = {
"urltemplate": ("template", "url", "path", "Шаблон", "URL"), "urltemplate": ("template", "url", "path", "Шаблон", "URL"),
"urltemplates": ("template", "url", "path", "Шаблон", "URL"), "urltemplates": ("template", "url", "path", "Шаблон", "URL"),
@@ -1334,6 +1736,7 @@ def _xml_qualified_name(
name: str, name: str,
object_kind: str, object_kind: str,
parent_qualified_name: str | None, parent_qualified_name: str | None,
source_path: str = "",
) -> str: ) -> str:
for key in ("qualifiedName", "QualifiedName", "ПолноеИмя"): for key in ("qualifiedName", "QualifiedName", "ПолноеИмя"):
if key in element.attrib: if key in element.attrib:
@@ -1341,6 +1744,10 @@ def _xml_qualified_name(
for child in _xml_property_children(element): for child in _xml_property_children(element):
if _local_name(child.tag).lower() in {"qualifiedname", "полноеимя"} and child.text: if _local_name(child.tag).lower() in {"qualifiedname", "полноеимя"} and child.text:
return child.text.strip() 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 parent_qualified_name:
if object_kind in _ROOT_METADATA_OBJECT_KINDS and object_kind not in {"PROJECT", "ROLE"}: 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) prefix = _QUALIFIED_PREFIX_BY_KIND.get(object_kind, object_kind)
@@ -1353,8 +1760,55 @@ def _xml_qualified_name(
return 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: def _xml_attributes(element: ET.Element) -> dict:
attributes = dict(element.attrib) 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) attribute_role = _xml_attribute_role(element)
if attribute_role: if attribute_role:
attributes.setdefault("attribute_role", 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())) return localized.get("ru") or localized.get("ru_RU") or next(iter(localized.values()))
if _local_name(element.tag).lower() == "value": if _local_name(element.tag).lower() == "value":
return _element_text_content(element) 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 = [ values = [
_element_text_content(child) _element_text_content(child)
for child in element 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] values = [value for value in values if value]
if values: if values:
@@ -1463,6 +1925,11 @@ def _read_text_file(path: Path) -> str:
__all__ = [ __all__ = [
"COMMON_BRANCH_CHILDREN", "COMMON_BRANCH_CHILDREN",
"AccessGroup",
"AccessModel",
"AccessProfile",
"AccessRoleAssignment",
"AccessUser",
"Command", "Command",
"ConfigurationRoot", "ConfigurationRoot",
"Extension", "Extension",
@@ -34,6 +34,8 @@ COMMON_BRANCH_CHILDREN = (
"Подписки на события", "Подписки на события",
"Критерии отбора", "Критерии отбора",
"Регламентные задания", "Регламентные задания",
"Последовательности",
"Нумераторы документов",
"Функциональные опции", "Функциональные опции",
"Параметры функциональных опций", "Параметры функциональных опций",
"Определяемые типы", "Определяемые типы",
@@ -145,6 +147,7 @@ REPORT_CHILDREN = (
METADATA_TYPE_SPECS: tuple[MetadataTypeSpec, ...] = ( METADATA_TYPE_SPECS: tuple[MetadataTypeSpec, ...] = (
MetadataTypeSpec("COMMON", "Общие", "Общие", "common", COMMON_BRANCH_CHILDREN), MetadataTypeSpec("COMMON", "Общие", "Общие", "common", COMMON_BRANCH_CHILDREN),
MetadataTypeSpec("SUBSYSTEM", "Подсистема", "Подсистемы", "subsystem", ("Состав", "Командный интерфейс", "Права")),
MetadataTypeSpec( MetadataTypeSpec(
"COMMON_MODULE", "COMMON_MODULE",
"Общий модуль", "Общий модуль",
@@ -186,8 +189,11 @@ METADATA_TYPE_SPECS: tuple[MetadataTypeSpec, ...] = (
MetadataTypeSpec("EXTERNAL_DATA_SOURCE", "Внешний источник данных", "Внешние источники данных", "external-source", ("Таблицы", "Кубы", "Функции", "Формы", "Команды", "Макеты")), MetadataTypeSpec("EXTERNAL_DATA_SOURCE", "Внешний источник данных", "Внешние источники данных", "external-source", ("Таблицы", "Кубы", "Функции", "Формы", "Команды", "Макеты")),
MetadataTypeSpec("EXCHANGE_PLAN", "План обмена", "Планы обмена", "exchange-plan", STRUCTURED_OBJECT_CHILDREN + ("Состав",), OBJECT_MODULES), MetadataTypeSpec("EXCHANGE_PLAN", "План обмена", "Планы обмена", "exchange-plan", STRUCTURED_OBJECT_CHILDREN + ("Состав",), OBJECT_MODULES),
MetadataTypeSpec("EVENT_SUBSCRIPTION", "Подписка на событие", "Подписки на события", "event", ("События",), HANDLER_METHOD), MetadataTypeSpec("EVENT_SUBSCRIPTION", "Подписка на событие", "Подписки на события", "event", ("События",), HANDLER_METHOD),
MetadataTypeSpec("ROLE", "Роль", "Роли", "role", ("Права", "Ограничения доступа", "Объекты доступа")),
MetadataTypeSpec("EXTENSION", "Расширение конфигурации", "Расширения конфигурации", "extension", ("Объекты расширения", "Заимствованные объекты", "Добавленные реквизиты", "Формы", "Команды", "Проверки совместимости")), MetadataTypeSpec("EXTENSION", "Расширение конфигурации", "Расширения конфигурации", "extension", ("Объекты расширения", "Заимствованные объекты", "Добавленные реквизиты", "Формы", "Команды", "Проверки совместимости")),
MetadataTypeSpec("SCHEDULED_JOB", "Регламентное задание", "Регламентные задания", "scheduled-job", ("Расписание", "Параметры"), ("Метод",)), MetadataTypeSpec("SCHEDULED_JOB", "Регламентное задание", "Регламентные задания", "scheduled-job", ("Расписание", "Параметры"), ("Метод",)),
MetadataTypeSpec("SEQUENCE", "Последовательность", "Последовательности", "sequence", ("Измерения", "Документы", "Границы")),
MetadataTypeSpec("DOCUMENT_NUMERATOR", "Нумератор документов", "Нумераторы документов", "numbering", ("Документы", "Периодичность", "Длина номера")),
MetadataTypeSpec("SESSION_PARAMETER", "Параметр сеанса", "Параметры сеанса", "parameter"), MetadataTypeSpec("SESSION_PARAMETER", "Параметр сеанса", "Параметры сеанса", "parameter"),
MetadataTypeSpec("COMMON_ATTRIBUTE", "Общий реквизит", "Общие реквизиты", "attribute"), MetadataTypeSpec("COMMON_ATTRIBUTE", "Общий реквизит", "Общие реквизиты", "attribute"),
MetadataTypeSpec("FILTER_CRITERION", "Критерий отбора", "Критерии отбора", "filter"), MetadataTypeSpec("FILTER_CRITERION", "Критерий отбора", "Критерии отбора", "filter"),
@@ -217,6 +223,7 @@ METADATA_TYPE_SPECS: tuple[MetadataTypeSpec, ...] = (
METADATA_TYPE_DESCRIPTIONS = { METADATA_TYPE_DESCRIPTIONS = {
"COMMON": "Служебная ветка дерева конфигурации, объединяющая общие объекты метаданных.", "COMMON": "Служебная ветка дерева конфигурации, объединяющая общие объекты метаданных.",
"SUBSYSTEM": "Подсистема группирует прикладные объекты и участвует в построении командного интерфейса.",
"COMMON_MODULE": "Общий модуль содержит процедуры и функции, доступные из разных областей выполнения конфигурации.", "COMMON_MODULE": "Общий модуль содержит процедуры и функции, доступные из разных областей выполнения конфигурации.",
"CONSTANT": "Константа хранит единичное значение конфигурации и может иметь формы, команды, права и модуль менеджера.", "CONSTANT": "Константа хранит единичное значение конфигурации и может иметь формы, команды, права и модуль менеджера.",
"CATALOG": "Справочник описывает прикладной список объектов с реквизитами, табличными частями, формами, командами, макетами, правами и предопределенными данными.", "CATALOG": "Справочник описывает прикладной список объектов с реквизитами, табличными частями, формами, командами, макетами, правами и предопределенными данными.",
@@ -237,8 +244,11 @@ METADATA_TYPE_DESCRIPTIONS = {
"EXTERNAL_DATA_SOURCE": "Внешний источник данных описывает подключение к внешним таблицам, кубам и функциям.", "EXTERNAL_DATA_SOURCE": "Внешний источник данных описывает подключение к внешним таблицам, кубам и функциям.",
"EXCHANGE_PLAN": "План обмена описывает узлы и состав данных для распределенного обмена.", "EXCHANGE_PLAN": "План обмена описывает узлы и состав данных для распределенного обмена.",
"EVENT_SUBSCRIPTION": "Подписка на событие связывает событие платформы или объекта с обработчиком.", "EVENT_SUBSCRIPTION": "Подписка на событие связывает событие платформы или объекта с обработчиком.",
"ROLE": "Роль описывает набор прав доступа к объектам конфигурации и их данным.",
"EXTENSION": "Расширение конфигурации содержит добавленные и заимствованные объекты, а также проверки совместимости.", "EXTENSION": "Расширение конфигурации содержит добавленные и заимствованные объекты, а также проверки совместимости.",
"SCHEDULED_JOB": "Регламентное задание описывает метод, параметры и расписание фонового выполнения.", "SCHEDULED_JOB": "Регламентное задание описывает метод, параметры и расписание фонового выполнения.",
"SEQUENCE": "Последовательность управляет сквозной последовательностью проведения документов и границами восстановления.",
"DOCUMENT_NUMERATOR": "Нумератор документов задает общие правила нумерации для одного или нескольких видов документов.",
"SESSION_PARAMETER": "Параметр сеанса задает значение, доступное в течение пользовательского сеанса.", "SESSION_PARAMETER": "Параметр сеанса задает значение, доступное в течение пользовательского сеанса.",
"COMMON_ATTRIBUTE": "Общий реквизит добавляет реквизит сразу к выбранному набору объектов конфигурации.", "COMMON_ATTRIBUTE": "Общий реквизит добавляет реквизит сразу к выбранному набору объектов конфигурации.",
"FILTER_CRITERION": "Критерий отбора задает состав реквизитов для универсального отбора ссылочных данных.", "FILTER_CRITERION": "Критерий отбора задает состав реквизитов для универсального отбора ссылочных данных.",
@@ -282,6 +292,7 @@ METADATA_TYPE_DOCUMENTATION_URLS.update(
METADATA_TYPE_PROPERTIES: dict[str, tuple[str, ...]] = { METADATA_TYPE_PROPERTIES: dict[str, tuple[str, ...]] = {
"COMMON": ("Состав общих объектов",), "COMMON": ("Состав общих объектов",),
"SUBSYSTEM": STANDARD_PROPERTIES + ("Состав", "Включать в командный интерфейс", "Картинка", "Родитель"),
"COMMON_MODULE": STANDARD_PROPERTIES + ("Клиент", "Сервер", "Внешнее соединение", "Глобальный", "Вызов сервера", "Повторное использование возвращаемых значений"), "COMMON_MODULE": STANDARD_PROPERTIES + ("Клиент", "Сервер", "Внешнее соединение", "Глобальный", "Вызов сервера", "Повторное использование возвращаемых значений"),
"CONSTANT": STANDARD_PROPERTIES + ("Тип значения", "Основная форма", "Форма выбора"), "CONSTANT": STANDARD_PROPERTIES + ("Тип значения", "Основная форма", "Форма выбора"),
"CATALOG": REFERENCE_OBJECT_PROPERTIES, "CATALOG": REFERENCE_OBJECT_PROPERTIES,
@@ -302,8 +313,11 @@ METADATA_TYPE_PROPERTIES: dict[str, tuple[str, ...]] = {
"EXTERNAL_DATA_SOURCE": STANDARD_PROPERTIES + ("Соединение", "Таблицы", "Кубы", "Функции"), "EXTERNAL_DATA_SOURCE": STANDARD_PROPERTIES + ("Соединение", "Таблицы", "Кубы", "Функции"),
"EXCHANGE_PLAN": DATA_OBJECT_PROPERTIES + ("Состав обмена", "Распределенная ИБ", "Авторегистрация изменений"), "EXCHANGE_PLAN": DATA_OBJECT_PROPERTIES + ("Состав обмена", "Распределенная ИБ", "Авторегистрация изменений"),
"EVENT_SUBSCRIPTION": STANDARD_PROPERTIES + ("Источник", "Событие", "Обработчик", "Перед/после события"), "EVENT_SUBSCRIPTION": STANDARD_PROPERTIES + ("Источник", "Событие", "Обработчик", "Перед/после события"),
"ROLE": STANDARD_PROPERTIES + ("Права", "RLS", "Ограничения доступа"),
"EXTENSION": ("Имя", "Назначение", "Версия", "Режим совместимости", "Заимствованные объекты", "Проверки совместимости"), "EXTENSION": ("Имя", "Назначение", "Версия", "Режим совместимости", "Заимствованные объекты", "Проверки совместимости"),
"SCHEDULED_JOB": STANDARD_PROPERTIES + ("Метод", "Расписание", "Использование", "Параметры", "Предопределенное"), "SCHEDULED_JOB": STANDARD_PROPERTIES + ("Метод", "Расписание", "Использование", "Параметры", "Предопределенное"),
"SEQUENCE": STANDARD_PROPERTIES + ("Документы", "Измерения", "Периодичность", "Заполнение", "Граница"),
"DOCUMENT_NUMERATOR": STANDARD_PROPERTIES + ("Длина номера", "Тип номера", "Периодичность", "Документы"),
"SESSION_PARAMETER": STANDARD_PROPERTIES + ("Тип значения",), "SESSION_PARAMETER": STANDARD_PROPERTIES + ("Тип значения",),
"COMMON_ATTRIBUTE": STANDARD_PROPERTIES + ("Тип значения", "Состав", "Разделение данных", "Автоиспользование"), "COMMON_ATTRIBUTE": STANDARD_PROPERTIES + ("Тип значения", "Состав", "Разделение данных", "Автоиспользование"),
"FILTER_CRITERION": 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"] == "Используется в подборе товаров" 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): def test_normalize_edt_project_preserves_localized_descriptions(tmp_path: Path):
catalog = tmp_path / "Контрагенты.mdo" catalog = tmp_path / "Контрагенты.mdo"
catalog.write_text( catalog.write_text(
@@ -78,6 +78,40 @@ _METADATA_OWNER_KINDS = {
NodeKind.SCHEDULED_JOB, NodeKind.SCHEDULED_JOB,
NodeKind.BUSINESS_PROCESS, NodeKind.BUSINESS_PROCESS,
NodeKind.TASK, 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 = { _PATH_METADATA_ALIASES = {
"catalogs": ("Справочник", NodeKind.CATALOG), "catalogs": ("Справочник", NodeKind.CATALOG),
@@ -120,6 +154,69 @@ _PATH_METADATA_ALIASES = {
"бизнеспроцессы": ("БизнесПроцесс", NodeKind.BUSINESS_PROCESS), "бизнеспроцессы": ("БизнесПроцесс", NodeKind.BUSINESS_PROCESS),
"tasks": ("Задача", NodeKind.TASK), "tasks": ("Задача", NodeKind.TASK),
"задачи": ("Задача", 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] = [] command_nodes: list[SemanticNode] = []
form_nodes: list[SemanticNode] = [] form_nodes: list[SemanticNode] = []
role_rights: list[dict] = [] role_rights: list[dict] = []
access_role_assignments: list[dict] = []
access_group_memberships: list[dict] = []
for source_file in source_files: for source_file in source_files:
text = _read_text_file(source_file) 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": if xml_object.object_kind == "RIGHT":
role_rights.append(xml_object.attributes) role_rights.append(xml_object.attributes)
continue 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) kind = _xml_node_kind(xml_object.object_kind)
if kind is None: if kind is None:
continue continue
@@ -348,14 +453,49 @@ def index_project(path: str | Path, *, project_id: str | None = None, structure_
NodeKind.SCHEDULED_JOB, NodeKind.SCHEDULED_JOB,
NodeKind.BUSINESS_PROCESS, NodeKind.BUSINESS_PROCESS,
NodeKind.TASK, 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.ROLE,
NodeKind.FORM, NodeKind.FORM,
NodeKind.TABULAR_SECTION, NodeKind.TABULAR_SECTION,
}: }:
parent_by_prefix[node.qualified_name] = node 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_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_scheduled_jobs_to_routines(scheduled_job_nodes, routine_by_name))
edges.extend(_link_commands_to_handlers(command_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)) 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, "BUSINESS_PROCESS": NodeKind.BUSINESS_PROCESS,
"TASK": NodeKind.TASK, "TASK": NodeKind.TASK,
"SUBSYSTEM": NodeKind.SUBSYSTEM, "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, "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, "XDTO_PACKAGE": NodeKind.XDTO_PACKAGE,
"EXTENSION": NodeKind.EXTENSION, "EXTENSION": NodeKind.EXTENSION,
"LAYOUT": NodeKind.LAYOUT, "LAYOUT": NodeKind.LAYOUT,
@@ -983,7 +1152,9 @@ def _xml_edge_kind(kind: NodeKind) -> EdgeKind:
return EdgeKind.HAS_TABULAR_SECTION return EdgeKind.HAS_TABULAR_SECTION
if kind == NodeKind.ROLE: if kind == NodeKind.ROLE:
return EdgeKind.HAS_ROLE return EdgeKind.HAS_ROLE
if kind == NodeKind.FORM_ELEMENT:
return EdgeKind.HAS_ELEMENT return EdgeKind.HAS_ELEMENT
return EdgeKind.CONTAINS
def _find_xml_parent(parents: dict[str, SemanticNode], qualified_name: str) -> SemanticNode | None: def _find_xml_parent(parents: dict[str, SemanticNode], qualified_name: str) -> SemanticNode | None:
@@ -1025,6 +1196,7 @@ def _link_metadata_to_modules(
root: Path, root: Path,
module_nodes: dict[str, SemanticNode], module_nodes: dict[str, SemanticNode],
metadata_nodes: list[SemanticNode], metadata_nodes: list[SemanticNode],
form_nodes: list[SemanticNode],
) -> list[SemanticEdge]: ) -> list[SemanticEdge]:
if not metadata_nodes: if not metadata_nodes:
return [] return []
@@ -1034,6 +1206,7 @@ def _link_metadata_to_modules(
(node.kind, _normalize_lookup_key(node.name)): node (node.kind, _normalize_lookup_key(node.name)): node
for node in metadata_nodes for node in metadata_nodes
} }
forms_by_qualified = {_normalize_lookup_key(node.qualified_name): node for node in form_nodes}
edges: list[SemanticEdge] = [] edges: list[SemanticEdge] = []
for source_path, module in module_nodes.items(): for source_path, module in module_nodes.items():
@@ -1042,6 +1215,26 @@ def _link_metadata_to_modules(
if owner is None: if owner is None:
continue continue
line = module.source_ref.line_start or 1 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( edges.append(
_edge( _edge(
EdgeKind.CONTAINS, EdgeKind.CONTAINS,
@@ -1049,16 +1242,61 @@ def _link_metadata_to_modules(
module, module,
source_path, source_path,
line, line,
{ edge_attributes,
"link_type": "METADATA_MODULE", )
"module_role": _module_role(source_file), )
"form_name": _form_name_for_module(root, source_file), 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 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]: def _link_role_rights(nodes: list[SemanticNode], role_rights: list[dict]) -> list[SemanticEdge]:
if not role_rights: if not role_rights:
return [] return []
@@ -1094,6 +1332,57 @@ def _link_role_rights(nodes: list[SemanticNode], role_rights: list[dict]) -> lis
return edges 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( def _link_scheduled_jobs_to_routines(
scheduled_jobs: list[SemanticNode], scheduled_jobs: list[SemanticNode],
routine_by_name: dict[str, 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) 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): def test_index_project_remaps_edges_from_duplicate_metadata_nodes(tmp_path: Path):
first = tmp_path / "first.xml" first = tmp_path / "first.xml"
first.write_text( 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) 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): def test_index_project_links_form_events_to_handlers(tmp_path: Path):
xml = tmp_path / "form.xml" xml = tmp_path / "form.xml"
xml.write_text( xml.write_text(
+31
View File
@@ -26,6 +26,35 @@ class NodeKind(str, Enum):
BUSINESS_PROCESS = "BUSINESS_PROCESS" BUSINESS_PROCESS = "BUSINESS_PROCESS"
TASK = "TASK" TASK = "TASK"
SUBSYSTEM = "SUBSYSTEM" 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" HTTP_SERVICE = "HTTP_SERVICE"
XDTO_PACKAGE = "XDTO_PACKAGE" XDTO_PACKAGE = "XDTO_PACKAGE"
EXTENSION = "EXTENSION" EXTENSION = "EXTENSION"
@@ -57,3 +86,5 @@ class EdgeKind(str, Enum):
RUNS = "RUNS" RUNS = "RUNS"
USES_INTEGRATION = "USES_INTEGRATION" USES_INTEGRATION = "USES_INTEGRATION"
HANDLES = "HANDLES" 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 for node in snapshot.nodes
if node.kind == NodeKind.FORM if node.kind == NodeKind.FORM
} }
element_children: dict[str, list[SemanticNode]] = {}
for edge in snapshot.edges: for edge in snapshot.edges:
form = forms.get(edge.source_lineage) form = forms.get(edge.source_lineage)
target = nodes.get(edge.target_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 continue
if edge.kind == EdgeKind.HAS_COMMAND: if edge.kind == EdgeKind.HAS_COMMAND:
form.commands.append(target) form.commands.append(target)
elif edge.kind == EdgeKind.HAS_ELEMENT: for form in forms.values():
form.elements.append(target) form.elements.extend(_flatten_form_elements(form.form.lineage_id, element_children))
command_to_form = { command_to_form = {
command.lineage_id: form command.lineage_id: form
for form in forms.values() 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) 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"] __all__ = ["FormSemantics", "form_semantics"]
+99 -1
View File
@@ -694,6 +694,100 @@ function Export-CfOrCfeFromInfobase {
return $exportRoot 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 { function Install-SferaExtensionJob {
param([object]$Job, [string[]]$PlatformBins) param([object]$Job, [string[]]$PlatformBins)
$workRoot = Join-Path $env:TEMP "sfera-agent" $workRoot = Join-Path $env:TEMP "sfera-agent"
@@ -1138,9 +1232,13 @@ while ($true) {
continue continue
} }
$payloadPath = $job.local_path $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 $payloadPath = Export-CfOrCfeFromInfobase -Job $job -PlatformBins $platformBins
} }
}
if ([string]::IsNullOrWhiteSpace($payloadPath)) { if ([string]::IsNullOrWhiteSpace($payloadPath)) {
throw "Job does not contain local_path or enough 1C infobase settings for agent export." 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", "sfera-ui-semantics",
"smbprotocol>=1.15", "smbprotocol>=1.15",
"uvicorn>=0.30", "uvicorn>=0.30",
"python-multipart>=0.0.20",
] ]
[tool.uv] [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 from __future__ import annotations
import os import os
import shutil
import subprocess
import sys
import tempfile
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
@@ -134,7 +138,19 @@ async def runtime_import(request: RuntimeImportRequest) -> RuntimeImportResponse
], ],
dump_plan=dump_plan, 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: def _mode() -> RuntimeMode:
@@ -217,6 +233,105 @@ def _designer_dump_plan(request: RuntimeImportRequest) -> list[str]:
return plan 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: def _redact_connection_string(value: str) -> str:
sensitive_keys = {"pwd", "password", "пароль"} sensitive_keys = {"pwd", "password", "пароль"}
chunks: list[str] = [] chunks: list[str] = []
@@ -245,6 +360,11 @@ def _request_fingerprint(request: RuntimeImportRequest) -> str:
def _mock_project(project_id: str | None) -> NormalizedProject: def _mock_project(project_id: str | None) -> NormalizedProject:
from one_c_normalizer import ( from one_c_normalizer import (
AccessGroup,
AccessModel,
AccessProfile,
AccessRoleAssignment,
AccessUser,
Command, Command,
ConfigurationRoot, ConfigurationRoot,
Extension, Extension,
@@ -258,6 +378,31 @@ def _mock_project(project_id: str | None) -> NormalizedProject:
return NormalizedProject( return NormalizedProject(
project_id=project_id, project_id=project_id,
source_path="mock://runtime-adapter", 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( configuration=ConfigurationRoot(
groups=[ groups=[
MetadataGroup( MetadataGroup(
@@ -111,3 +111,47 @@ def test_runtime_platform_reports_capabilities(monkeypatch, tmp_path):
payload = response.json() payload = response.json()
assert payload["platform_found"] is True assert payload["platform_found"] is True
assert "cf_dump_plan" in payload["capabilities"] 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" }, { 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]] [[package]]
name = "pytz" name = "pytz"
version = "2026.2" version = "2026.2"
@@ -521,6 +530,7 @@ source = { editable = "services/api-server" }
dependencies = [ dependencies = [
{ name = "fastapi" }, { name = "fastapi" },
{ name = "neo4j" }, { name = "neo4j" },
{ name = "python-multipart" },
{ name = "sfera-collaboration" }, { name = "sfera-collaboration" },
{ name = "sfera-impact-engine" }, { name = "sfera-impact-engine" },
{ name = "sfera-incremental-indexer" }, { name = "sfera-incremental-indexer" },
@@ -550,6 +560,7 @@ dependencies = [
requires-dist = [ requires-dist = [
{ name = "fastapi", specifier = ">=0.115" }, { name = "fastapi", specifier = ">=0.115" },
{ name = "neo4j", specifier = ">=5.0" }, { name = "neo4j", specifier = ">=5.0" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "sfera-collaboration", editable = "packages/collaboration" }, { name = "sfera-collaboration", editable = "packages/collaboration" },
{ name = "sfera-impact-engine", editable = "packages/impact-engine" }, { name = "sfera-impact-engine", editable = "packages/impact-engine" },
{ name = "sfera-incremental-indexer", editable = "packages/incremental-indexer" }, { name = "sfera-incremental-indexer", editable = "packages/incremental-indexer" },