Extract managed form elements from XML
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled

This commit is contained in:
2026-05-21 06:10:05 +03:00
parent 5bd188fe6f
commit af900e4e34
9 changed files with 186 additions and 46 deletions
@@ -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<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("таб") || normalized.includes("table")) return "table";
@@ -772,7 +772,10 @@ export function ProjectSetupClient({ initialSetup }: Readonly<{ initialSetup: Pr
return;
} else if (mode === "reindex") {
setLastSyncPreview(null);
await postJson(`/api/sfera/projects/${setup.project_id}/reindex`, undefined);
const job = await postJson<OperationJob>(`/api/sfera/projects/${setup.project_id}/reindex/jobs`, undefined);
setServerImportJob(job);
keepImportRun = true;
return;
} else {
setLastImportResult(null);
setServerImportJob(null);
+1
View File
@@ -139,6 +139,7 @@ export type NamedNode = {
kind: string;
name: string;
qualified_name: string;
attributes: Record<string, unknown>;
};
export type SourceLocation = {
@@ -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)
@@ -983,7 +983,9 @@ def _xml_edge_kind(kind: NodeKind) -> EdgeKind:
return EdgeKind.HAS_TABULAR_SECTION
if kind == NodeKind.ROLE:
return EdgeKind.HAS_ROLE
if kind == NodeKind.FORM_ELEMENT:
return EdgeKind.HAS_ELEMENT
return EdgeKind.CONTAINS
def _find_xml_parent(parents: dict[str, SemanticNode], qualified_name: str) -> SemanticNode | None:
@@ -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(
"""
<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_links_form_events_to_handlers(tmp_path: Path):
xml = tmp_path / "form.xml"
xml.write_text(
@@ -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"]
@@ -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)
+69 -30
View File
@@ -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
]