From af900e4e3486af353575fcf7e6a93af2c4f5140c Mon Sep 17 00:00:00 2001 From: Mikhail Date: Thu, 21 May 2026 06:10:05 +0300 Subject: [PATCH] Extract managed form elements from XML --- .../src/components/editor/ide-workspace.tsx | 26 ++++- .../project-setup/project-setup-client.tsx | 5 +- frontend/sfera-web/src/lib/api.ts | 1 + .../src/one_c_normalizer/__init__.py | 36 ++++++- .../src/semantic_kernel/__init__.py | 4 +- .../tests/test_xml_indexing.py | 27 +++++ .../ui-semantics/src/ui_semantics/__init__.py | 27 ++++- .../src/api_server/html5_setup_controller.py | 7 +- services/api-server/src/api_server/main.py | 99 +++++++++++++------ 9 files changed, 186 insertions(+), 46 deletions(-) diff --git a/frontend/sfera-web/src/components/editor/ide-workspace.tsx b/frontend/sfera-web/src/components/editor/ide-workspace.tsx index 83571ab..8581af7 100644 --- a/frontend/sfera-web/src/components/editor/ide-workspace.tsx +++ b/frontend/sfera-web/src/components/editor/ide-workspace.tsx @@ -3338,15 +3338,33 @@ function buildIdeFormElements(form: ProjectWorkspaceData["forms"][number] | unde return explicitElements.map((element, index) => ({ id: element.lineage_id || `element.${index}`, name: element.name, - caption: element.name, - controlKind: controlKindForFormNode(element.name, element.kind), - binding: element.qualified_name || element.name, - width: "stretch" + caption: formElementString(element.attributes, ["caption", "title", "synonym"]) ?? element.name, + controlKind: controlKindForFormNode(element.name, formElementString(element.attributes, ["control_kind", "control", "type", "kind"]) ?? element.kind), + binding: formElementString(element.attributes, ["binding", "dataPath", "data_path", "path"]) ?? element.qualified_name ?? element.name, + width: formElementWidth(element.attributes, index) })); } return []; } +function formElementString(attributes: Record, 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, 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("таб") || normalized.includes("table")) return "table"; diff --git a/frontend/sfera-web/src/components/project-setup/project-setup-client.tsx b/frontend/sfera-web/src/components/project-setup/project-setup-client.tsx index 46478d5..0022461 100644 --- a/frontend/sfera-web/src/components/project-setup/project-setup-client.tsx +++ b/frontend/sfera-web/src/components/project-setup/project-setup-client.tsx @@ -772,7 +772,10 @@ export function ProjectSetupClient({ initialSetup }: Readonly<{ initialSetup: Pr return; } else if (mode === "reindex") { setLastSyncPreview(null); - await postJson(`/api/sfera/projects/${setup.project_id}/reindex`, undefined); + const job = await postJson(`/api/sfera/projects/${setup.project_id}/reindex/jobs`, undefined); + setServerImportJob(job); + keepImportRun = true; + return; } else { setLastImportResult(null); setServerImportJob(null); diff --git a/frontend/sfera-web/src/lib/api.ts b/frontend/sfera-web/src/lib/api.ts index 7a493e7..d8b952c 100644 --- a/frontend/sfera-web/src/lib/api.ts +++ b/frontend/sfera-web/src/lib/api.ts @@ -139,6 +139,7 @@ export type NamedNode = { kind: string; name: string; qualified_name: string; + attributes: Record; }; export type SourceLocation = { diff --git a/packages/one-c-normalizer/src/one_c_normalizer/__init__.py b/packages/one-c-normalizer/src/one_c_normalizer/__init__.py index b7ca693..725d18b 100644 --- a/packages/one-c-normalizer/src/one_c_normalizer/__init__.py +++ b/packages/one-c-normalizer/src/one_c_normalizer/__init__.py @@ -171,7 +171,7 @@ def parse_one_c_xml_file(path: str | Path) -> list[OneCXmlObject]: source_path = normalize_source_path(path) root = ET.fromstring(_read_text_file(Path(path))) result: list[OneCXmlObject] = [] - _walk_xml_objects(source_path, root, result, current_role=None, parent_qualified_name=None) + _walk_xml_objects(source_path, root, result, current_role=None, parent_qualified_name=None, parent_object_kind=None) return result @@ -351,10 +351,12 @@ def _walk_xml_objects( *, current_role: OneCXmlObject | None, parent_qualified_name: str | None, + parent_object_kind: str | None, ) -> None: role_context = current_role child_parent_qualified_name = parent_qualified_name - object_kind = _xml_object_kind(element) + child_parent_object_kind = parent_object_kind + object_kind = _xml_object_kind(element, parent_object_kind=parent_object_kind) if object_kind == "RIGHT": right = _xml_right_object(source_path, element, role_context) if right is not None: @@ -371,6 +373,7 @@ def _walk_xml_objects( ) result.append(xml_object) child_parent_qualified_name = xml_object.qualified_name + child_parent_object_kind = object_kind if object_kind == "ROLE": role_context = xml_object @@ -381,6 +384,7 @@ def _walk_xml_objects( result, current_role=role_context, parent_qualified_name=child_parent_qualified_name, + parent_object_kind=child_parent_object_kind, ) @@ -695,6 +699,25 @@ _OBJECT_KIND_BY_TAG = { "предопределенный": "PREDEFINED", } +_FORM_ELEMENT_TAGS = { + "item", + "items", + "element", + "elements", + "formitem", + "formitems", + "field", + "fields", + "group", + "groups", + "table", + "tables", + "button", + "buttons", + "page", + "pages", +} + _QUALIFIED_PREFIX_BY_KIND = { "CATALOG": "Справочник", @@ -1288,8 +1311,10 @@ _PATH_METADATA_ALIASES = { } -def _xml_object_kind(element: ET.Element) -> str | None: +def _xml_object_kind(element: ET.Element, *, parent_object_kind: str | None = None) -> str | None: tag = _local_name(element.tag).lower() + if parent_object_kind in {"FORM", "ELEMENT"} and tag in _FORM_ELEMENT_TAGS and _xml_name(element): + return "ELEMENT" if tag in {"metadataobject", "object"}: type_name = _xml_type_name(element) if type_name: @@ -1384,6 +1409,11 @@ def _xml_qualified_name( def _xml_attributes(element: ET.Element) -> dict: attributes = dict(element.attrib) + for key, value in element.attrib.items(): + local_key = _local_name(key) + attributes.setdefault(local_key, value) + if local_key.lower() == "type": + attributes.setdefault("control_kind", value.split(":")[-1].split(".")[-1]) attribute_role = _xml_attribute_role(element) if attribute_role: attributes.setdefault("attribute_role", attribute_role) diff --git a/packages/semantic-kernel/src/semantic_kernel/__init__.py b/packages/semantic-kernel/src/semantic_kernel/__init__.py index 63b6273..798f27b 100644 --- a/packages/semantic-kernel/src/semantic_kernel/__init__.py +++ b/packages/semantic-kernel/src/semantic_kernel/__init__.py @@ -983,7 +983,9 @@ def _xml_edge_kind(kind: NodeKind) -> EdgeKind: return EdgeKind.HAS_TABULAR_SECTION if kind == NodeKind.ROLE: return EdgeKind.HAS_ROLE - return EdgeKind.HAS_ELEMENT + if kind == NodeKind.FORM_ELEMENT: + return EdgeKind.HAS_ELEMENT + return EdgeKind.CONTAINS def _find_xml_parent(parents: dict[str, SemanticNode], qualified_name: str) -> SemanticNode | None: diff --git a/packages/semantic-kernel/tests/test_xml_indexing.py b/packages/semantic-kernel/tests/test_xml_indexing.py index 37c5d68..17b08d1 100644 --- a/packages/semantic-kernel/tests/test_xml_indexing.py +++ b/packages/semantic-kernel/tests/test_xml_indexing.py @@ -346,6 +346,33 @@ def test_index_project_links_form_command_to_handler(tmp_path: Path): assert any(edge.kind == EdgeKind.HANDLES for edge in snapshot.edges) +def test_index_project_extracts_managed_form_items_without_layouts(tmp_path: Path): + xml = tmp_path / "form.xml" + xml.write_text( + """ +
+ + Основное + + Номер + Объект.Номер + + + + +""", + 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_links_form_events_to_handlers(tmp_path: Path): xml = tmp_path / "form.xml" xml.write_text( diff --git a/packages/ui-semantics/src/ui_semantics/__init__.py b/packages/ui-semantics/src/ui_semantics/__init__.py index 312e6a7..5f67b3a 100644 --- a/packages/ui-semantics/src/ui_semantics/__init__.py +++ b/packages/ui-semantics/src/ui_semantics/__init__.py @@ -19,15 +19,20 @@ def form_semantics(snapshot: SirSnapshot) -> list[FormSemantics]: for node in snapshot.nodes if node.kind == NodeKind.FORM } + element_children: dict[str, list[SemanticNode]] = {} for edge in snapshot.edges: form = forms.get(edge.source_lineage) target = nodes.get(edge.target_lineage) - if form is None or target is None: + if target is None: + continue + if edge.kind == EdgeKind.HAS_ELEMENT and target.kind == NodeKind.FORM_ELEMENT: + element_children.setdefault(edge.source_lineage, []).append(target) + if form is None: continue if edge.kind == EdgeKind.HAS_COMMAND: form.commands.append(target) - elif edge.kind == EdgeKind.HAS_ELEMENT: - form.elements.append(target) + for form in forms.values(): + form.elements.extend(_flatten_form_elements(form.form.lineage_id, element_children)) command_to_form = { command.lineage_id: form for form in forms.values() @@ -43,4 +48,20 @@ def form_semantics(snapshot: SirSnapshot) -> list[FormSemantics]: return sorted(forms.values(), key=lambda item: item.form.qualified_name) +def _flatten_form_elements(root_lineage: str, element_children: dict[str, list[SemanticNode]]) -> list[SemanticNode]: + result: list[SemanticNode] = [] + seen: set[str] = set() + + def visit(parent_lineage: str) -> None: + for element in element_children.get(parent_lineage, []): + if element.lineage_id in seen: + continue + seen.add(element.lineage_id) + result.append(element) + visit(element.lineage_id) + + visit(root_lineage) + return result + + __all__ = ["FormSemantics", "form_semantics"] diff --git a/services/api-server/src/api_server/html5_setup_controller.py b/services/api-server/src/api_server/html5_setup_controller.py index 656db7c..c2fca0b 100644 --- a/services/api-server/src/api_server/html5_setup_controller.py +++ b/services/api-server/src/api_server/html5_setup_controller.py @@ -156,8 +156,7 @@ def html5_setup_job( async def html5_setup_reindex( *, project_id: str, - reindex: Callable[[str], Any], - setup_response: Callable[[str], object], + start_reindex_job: Callable[[str], Any], ) -> str: - await reindex(project_id) - return render_html5_setup_summary(project_id, setup_response(project_id)) + job = await start_reindex_job(project_id) + return render_html5_import_job(project_id, job) diff --git a/services/api-server/src/api_server/main.py b/services/api-server/src/api_server/main.py index 21e5727..237bd05 100644 --- a/services/api-server/src/api_server/main.py +++ b/services/api-server/src/api_server/main.py @@ -488,7 +488,7 @@ def _load_persisted_state() -> None: _collaboration.ownership[_collaboration._ownership_key(ownership)] = ownership for payload in _storage.list_documents("operations_jobs"): job = OperationJob.model_validate(payload) - if job.kind == "SERVER_IMPORT" and job.status in {OperationJobStatus.QUEUED, OperationJobStatus.RUNNING}: + if job.kind in {"SERVER_IMPORT", "REINDEX"} and job.status in {OperationJobStatus.QUEUED, OperationJobStatus.RUNNING}: job.status = OperationJobStatus.FAILED job.error = "Операция была прервана перезапуском сервера." from datetime import datetime, timezone @@ -1738,8 +1738,7 @@ async def html5_project_setup_reindex(project_id: str) -> Response: return _html5_response( await _html5_setup_reindex( project_id=project_id, - reindex=reindex_project, - setup_response=_project_setup_response, + start_reindex_job=start_project_reindex_job, ) ) @@ -2100,6 +2099,33 @@ async def check_project_import( @app.post("/projects/{project_id}/reindex", response_model=ImportSummary) async def reindex_project(project_id: str) -> ImportSummary: + return _execute_reindex_project(project_id) + + +@app.post("/projects/{project_id}/reindex/jobs", response_model=OperationJob) +async def start_project_reindex_job(project_id: str) -> OperationJob: + active_job = _active_project_operation_job(project_id, {"REINDEX"}) + if active_job is not None: + return active_job + job = OperationJob( + job_id=f"reindex-{uuid4()}", + kind="REINDEX", + status=OperationJobStatus.QUEUED, + payload={ + "project_id": project_id, + "stage": "queued", + "message": "Переиндексация поставлена в очередь.", + "logs": ["Переиндексация поставлена в очередь."], + "started_at": None, + "finished_at": None, + }, + ) + _persist_job(_operations.enqueue(job)) + threading.Thread(target=_run_reindex_job, args=(job.job_id, project_id), daemon=True).start() + return _operations.jobs[job.job_id] + + +def _execute_reindex_project(project_id: str) -> ImportSummary: state = _project_setup.get(project_id, {}) current_source = ImportSourceKind(state.get("current_source") or ImportSourceKind.XML_DUMP.value) last_import = state.get("last_import") or {} @@ -2128,6 +2154,7 @@ async def reindex_project(project_id: str) -> ImportSummary: state["last_import"] = summary.model_dump(mode="json") _append_import_history(project_id, summary) state["status"] = ProjectSetupStatus.INDEXED.value + _storage.write_document("project_settings", project_id, state) return summary @@ -7850,32 +7877,11 @@ def _form_semantics_response(item) -> FormSemanticsResponse: def _form_semantics_for_lineages(snapshot: SirSnapshot, form_lineages: set[str]) -> list[FormSemanticsResponse]: if not form_lineages: return [] - nodes = {node.lineage_id: node for node in snapshot.nodes} - forms = { - lineage_id: FormSemanticsResponse(form=_named_node(node), commands=[], elements=[], command_handlers={}) - for lineage_id, node in nodes.items() - if lineage_id in form_lineages and node.kind == NodeKind.FORM - } - command_to_form: dict[str, FormSemanticsResponse] = {} - for edge in snapshot.edges: - form = forms.get(edge.source_lineage) - target = nodes.get(edge.target_lineage) - if form is None or target is None: - continue - if edge.kind == EdgeKind.HAS_COMMAND: - named_target = _named_node(target) - form.commands.append(named_target) - command_to_form[target.lineage_id] = form - elif edge.kind == EdgeKind.HAS_ELEMENT: - form.elements.append(_named_node(target)) - for edge in snapshot.edges: - if edge.kind != EdgeKind.HANDLES: - continue - form = command_to_form.get(edge.source_lineage) - handler = nodes.get(edge.target_lineage) - if form is not None and handler is not None: - form.command_handlers[edge.source_lineage] = _named_node(handler) - return sorted(forms.values(), key=lambda item: item.form.qualified_name) + return [ + _form_semantics_response(item) + for item in form_semantics(snapshot) + if item.form.lineage_id in form_lineages + ] def _persist_job(job: OperationJob) -> OperationJob: @@ -7916,6 +7922,35 @@ def _run_server_import_job(job_id: str, project_id: str, request: ImportRequest) ) +def _run_reindex_job(job_id: str, project_id: str) -> None: + if job_id not in _operations.jobs: + return + _update_import_job_progress(job_id, "Переиндексация запущена.", stage="running", status=OperationJobStatus.RUNNING) + try: + _update_import_job_progress(job_id, "Пересборка snapshot и UI-семантики.", stage="indexing") + summary = _execute_reindex_project(project_id) + except Exception as error: + _update_import_job_progress( + job_id, + f"Переиндексация завершилась ошибкой: {error}", + stage="failed", + status=OperationJobStatus.FAILED, + error=str(error), + finished_at=_current_timestamp(), + ) + return + status = OperationJobStatus.SUCCEEDED if summary.applied is not False and not summary.errors else OperationJobStatus.FAILED + _update_import_job_progress( + job_id, + "Переиндексация завершена." if status == OperationJobStatus.SUCCEEDED else "Переиндексация остановлена. Проверьте ошибки.", + stage="done" if status == OperationJobStatus.SUCCEEDED else "failed", + status=status, + result={"import_summary": summary.model_dump(mode="json")}, + error="; ".join(summary.errors) if summary.errors else None, + finished_at=_current_timestamp(), + ) + + def _create_agent_server_import_job(agent_job: AgentImportJob, request: ImportRequest) -> OperationJob: operation_job = OperationJob( job_id=f"server-import-{uuid4()}", @@ -8091,11 +8126,15 @@ def _run_agent_uploaded_zip_job(job_id: str, upload_path_raw: str, import_root_r def _active_server_import_job(project_id: str) -> OperationJob | None: + return _active_project_operation_job(project_id, {"SERVER_IMPORT"}) + + +def _active_project_operation_job(project_id: str, kinds: set[str]) -> OperationJob | None: active_statuses = {OperationJobStatus.QUEUED, OperationJobStatus.RUNNING} candidates = [ job for job in _operations.jobs.values() - if job.kind == "SERVER_IMPORT" + if job.kind in kinds and job.payload.get("project_id") == project_id and job.status in active_statuses ]