Extract managed form elements from XML
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user