Compare commits
134 Commits
7304acdba7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a9ab94679 | |||
| 7501111d29 | |||
| c9f3c12c3f | |||
| 58afd3932e | |||
| d8394e4e89 | |||
| 4c02e2f73a | |||
| 5a4e3c6d9d | |||
| aa36d58a73 | |||
| 519f10dd6b | |||
| 2e86d25205 | |||
| b3689b1d9e | |||
| e12332d3c6 | |||
| d93b7cb07e | |||
| 51d52ccf04 | |||
| de8b0eb795 | |||
| b85bff6e06 | |||
| 23800dea71 | |||
| afb455a2c6 | |||
| 4afbb493b4 | |||
| 65a1437c7c | |||
| 9ea2ff5518 | |||
| dafb552ad3 | |||
| fea29e665c | |||
| cbcfcc1741 | |||
| e86f6be385 | |||
| 5f066d2f6b | |||
| 87236606d1 | |||
| 2f7db03001 | |||
| 29bbe1dca6 | |||
| 6051f59e08 | |||
| db5fdf0aa4 | |||
| feaf40c205 | |||
| d0b74c05be | |||
| 3c7b1825c4 | |||
| 9dc35bae20 | |||
| 9f1f1a8ee1 | |||
| a5e0c8bf0f | |||
| 8b9a076d86 | |||
| 7d4d9917dd | |||
| af900e4e34 | |||
| 5bd188fe6f | |||
| d26aaef44a | |||
| ebded03ecf | |||
| e546985843 | |||
| 1b2721e2b7 | |||
| 09300f013f | |||
| 8db3225359 | |||
| c979428d90 | |||
| 588edfcf24 | |||
| 6dd4d69163 | |||
| 8c19410da1 | |||
| d610a9ad6c | |||
| b67d734aa4 | |||
| ff8f9a6dd4 | |||
| b97c449211 | |||
| bb3e70f1e5 | |||
| 8d206a3bf2 | |||
| 68b20a27fa | |||
| 0750ebe836 | |||
| f6679d1694 | |||
| 07d23d2ba9 | |||
| 02aa084634 | |||
| 35dd134ebc | |||
| 1ad103b6dc | |||
| faf1bbd10a | |||
| 2e6fee5fc7 | |||
| 940d7e884b | |||
| 8683b136b3 | |||
| 990eeedaba | |||
| 7b5893e5a8 | |||
| dd80ea2f9d | |||
| c90d708f21 | |||
| b0409044eb | |||
| b2ef1e811d | |||
| d3ae2459ce | |||
| 0a16058ebd | |||
| d68c656ce6 | |||
| dfc400c4e9 | |||
| 6d92c82c2b | |||
| b8256927bf | |||
| 53e983af4e | |||
| 624dc5d7f0 | |||
| 0f8141d5f9 | |||
| 22f59b7580 | |||
| 65c82c4fed | |||
| 8a1c0da0ea | |||
| fb66cfba6f | |||
| de7248db9e | |||
| 816b009f47 | |||
| 5c506e4b23 | |||
| 8f2a17b24a | |||
| dde672202c | |||
| 04cba89580 | |||
| 74369397c2 | |||
| 3f6b14e57a | |||
| e5332e9a7c | |||
| 15d659e661 | |||
| de119c2106 | |||
| 2aaf5d0082 | |||
| f1584023ff | |||
| 571d85e53c | |||
| 82690f0007 | |||
| 3e40ba64a8 | |||
| a735048270 | |||
| 6cc669f694 | |||
| 20c1b1809b | |||
| 9ff2cf3676 | |||
| 2a2786bc60 | |||
| 477a94d302 | |||
| 48070f0f70 | |||
| 61cfc9d1cd | |||
| f830c4290f | |||
| 6e89cdcd84 | |||
| 9c4d02616f | |||
| 6f594395f8 | |||
| 70c6424c8c | |||
| 7583851dc4 | |||
| 7ee6deb088 | |||
| c871a8bbd2 | |||
| 79ae2b3023 | |||
| f695846b7b | |||
| b93fd88e81 | |||
| 1da745c52e | |||
| a8baa2aa49 | |||
| f20c045de9 | |||
| 41dc88c33b | |||
| 460881428b | |||
| c14db34f14 | |||
| b7876db8ef | |||
| a62e213b2f | |||
| d2c42cabda | |||
| 165f218e1c | |||
| 9f11c07781 | |||
| 0c3953e6bd |
@@ -3036,22 +3036,74 @@ function FormDesignerPanel({
|
||||
const t = messages[language];
|
||||
const objectForms = data.selectedObjectUi?.forms.length ? data.selectedObjectUi.forms : data.forms;
|
||||
const [selectedFormId, setSelectedFormId] = useState<string | null>(objectForms[0]?.form.lineage_id ?? null);
|
||||
const [titleByForm, setTitleByForm] = useState<Record<string, string>>({});
|
||||
const [layoutByForm, setLayoutByForm] = useState<Record<string, "auto" | "columns" | "compact">>({});
|
||||
const [elementDrafts, setElementDrafts] = useState<Record<string, IdeFormElementDraft[]>>({});
|
||||
const [newElementName, setNewElementName] = useState("");
|
||||
const [newElementKind, setNewElementKind] = useState<IdeFormElementDraft["controlKind"]>("input");
|
||||
const selectedFormQualifiedName = data.editorSelectedForm;
|
||||
useEffect(() => {
|
||||
if (selectedFormQualifiedName) {
|
||||
const requestedForm = objectForms.find((item) => item.form.qualified_name === selectedFormQualifiedName);
|
||||
if (requestedForm && requestedForm.form.lineage_id !== selectedFormId) {
|
||||
setSelectedFormId(requestedForm.form.lineage_id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (objectForms.length > 0 && !objectForms.some((item) => item.form.lineage_id === selectedFormId)) {
|
||||
setSelectedFormId(objectForms[0].form.lineage_id);
|
||||
}
|
||||
}, [objectForms, selectedFormId]);
|
||||
const form = objectForms.find((item) => item.form.lineage_id === selectedFormId) ?? objectForms[0];
|
||||
const elements = form?.elements.slice(0, 8) ?? [];
|
||||
}, [objectForms, selectedFormId, selectedFormQualifiedName]);
|
||||
const form =
|
||||
objectForms.find((item) => item.form.qualified_name === selectedFormQualifiedName) ??
|
||||
objectForms.find((item) => item.form.lineage_id === selectedFormId) ??
|
||||
objectForms[0];
|
||||
const commands = form?.commands.slice(0, 6) ?? [];
|
||||
const formTitle = form?.form.name ?? "ФормаДокумента";
|
||||
const formObjectCaption = language === "ru" ? `${formTitle} (форма 1С)` : `${formTitle} (1C form)`;
|
||||
const commandLabels = [t.postAndClose, ...commands.slice(0, 2).map((command) => command.name)];
|
||||
const formKey = form?.form.lineage_id ?? "draft";
|
||||
const baseElements = useMemo(() => buildIdeFormElements(form), [form]);
|
||||
const elements = elementDrafts[formKey] ?? baseElements;
|
||||
const flatElements = useMemo(() => flattenIdeFormElements(elements), [elements]);
|
||||
const sidebarElements = flatElements.slice(0, 160);
|
||||
const propertyElements = flatElements.slice(0, 48);
|
||||
const formTitle = titleByForm[formKey] ?? form?.form.name ?? "ФормаДокумента";
|
||||
const formObjectCaption = language === "ru" ? `${formTitle} (форма 1С 8.5)` : `${formTitle} (1C 8.5 form)`;
|
||||
const layout = layoutByForm[formKey] ?? "auto";
|
||||
|
||||
useEffect(() => {
|
||||
if (form && !elementDrafts[form.form.lineage_id]) {
|
||||
setElementDrafts((current) => ({ ...current, [form.form.lineage_id]: buildIdeFormElements(form) }));
|
||||
}
|
||||
}, [elementDrafts, form]);
|
||||
|
||||
const updateElement = (id: string, patch: Partial<IdeFormElementDraft>) => {
|
||||
setElementDrafts((current) => ({
|
||||
...current,
|
||||
[formKey]: updateIdeFormElementTree(current[formKey] ?? baseElements, id, patch)
|
||||
}));
|
||||
};
|
||||
|
||||
const addElement = () => {
|
||||
const name = newElementName.trim();
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
const next: IdeFormElementDraft = {
|
||||
id: `draft.${formKey}.${Date.now()}`,
|
||||
name,
|
||||
caption: name,
|
||||
controlKind: newElementKind,
|
||||
binding: name,
|
||||
width: "stretch",
|
||||
children: []
|
||||
};
|
||||
setElementDrafts((current) => ({ ...current, [formKey]: [...(current[formKey] ?? baseElements), next] }));
|
||||
setNewElementName("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="min-h-0 rounded-none border-0 p-0 shadow-none" data-active-mode={modeId}>
|
||||
<PanelTitle icon={Layers3} title={t.formDesigner} />
|
||||
<div className="grid h-[calc(100%-45px)] min-h-0 grid-cols-[260px_minmax(0,1fr)]">
|
||||
<div className="grid h-[calc(100%-45px)] min-h-0 grid-cols-[260px_minmax(0,1fr)_340px]">
|
||||
<div className="min-h-0 overflow-auto border-r border-border bg-[#f4f4f4] p-3">
|
||||
<div className="flex items-center justify-between border-b border-border pb-2">
|
||||
<div className="text-xs font-semibold uppercase text-muted-foreground">{t.forms}</div>
|
||||
@@ -3082,19 +3134,24 @@ function FormDesignerPanel({
|
||||
</div>
|
||||
<div className="mt-5 border-t border-border pt-3 text-xs font-semibold uppercase text-muted-foreground">{t.formElements}</div>
|
||||
<div className="mt-3 space-y-1">
|
||||
{elements.length === 0 ? (
|
||||
{flatElements.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">{t.none}</div>
|
||||
) : (
|
||||
elements.map((element) => (
|
||||
<div className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-background" key={element.lineage_id}>
|
||||
<OneCTreeIcon kind={kindForTreeLabel(element.kind)} />
|
||||
sidebarElements.map((element) => (
|
||||
<div className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-background" key={element.id}>
|
||||
<OneCTreeIcon kind={element.controlKind === "table" ? "tabular" : "attribute"} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-foreground">{element.name}</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">{element.kind}</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">{element.controlKind} · {element.binding}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{flatElements.length > sidebarElements.length ? (
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground">
|
||||
+{flatElements.length - sidebarElements.length} {language === "ru" ? "элементов в макете" : "layout items"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-5 border-t border-border pt-3 text-xs font-semibold uppercase text-muted-foreground">{t.commands}</div>
|
||||
<div className="mt-3 space-y-1">
|
||||
@@ -3107,146 +3164,335 @@ function FormDesignerPanel({
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 overflow-auto bg-[#ececec] p-5">
|
||||
<div className="mx-auto flex min-h-[760px] max-w-7xl flex-col rounded-xl border border-black/5 bg-white px-5 py-5 shadow-[0_18px_55px_rgba(15,23,42,0.18)]">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="mx-auto flex min-h-[760px] max-w-5xl flex-col border border-[#b8c0ca] bg-[#fbfbfc] shadow-[0_18px_55px_rgba(15,23,42,0.18)]" data-ide-form-window data-ide-form-layout={layout}>
|
||||
<div className="flex min-h-9 items-center justify-between border-b border-[#cbd3df] bg-gradient-to-b from-[#f8f9fb] to-[#e9edf3] px-3 text-sm text-slate-950">
|
||||
<div>
|
||||
<div className="text-lg font-medium text-slate-950">{formObjectCaption}</div>
|
||||
<button className="mt-8 inline-flex items-center gap-1 text-sm text-slate-900" type="button">
|
||||
{t.mainSection}
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div className="font-semibold">{formObjectCaption}</div>
|
||||
<div className="text-[11px] text-slate-500">{data.selectedObjectSchema?.object.qualified_name ?? form?.form.qualified_name ?? data.projectId}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-slate-500">
|
||||
<button className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 hover:bg-slate-200" type="button" title="menu">
|
||||
<button className="flex h-7 w-7 items-center justify-center rounded-full bg-slate-100 hover:bg-slate-200" type="button" title="menu">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<button className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 hover:bg-slate-200" type="button" title="expand">
|
||||
<button className="flex h-7 w-7 items-center justify-center rounded-full bg-slate-100 hover:bg-slate-200" type="button" title="expand">
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 hover:bg-slate-200" type="button" title="close">
|
||||
<button className="flex h-7 w-7 items-center justify-center rounded-full bg-slate-100 hover:bg-slate-200" type="button" title="close">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-7 grid max-w-3xl grid-cols-[minmax(0,360px)_minmax(0,360px)] gap-x-3 gap-y-3">
|
||||
<label className="grid gap-1 text-xs text-slate-700">
|
||||
<span><span className="text-red-500">*</span>{t.nameField}</span>
|
||||
<span className="flex h-8 items-center rounded-md border border-slate-300 bg-white px-2 text-sm text-slate-900">{formTitle}</span>
|
||||
</label>
|
||||
<label className="grid gap-1 text-xs text-slate-700">
|
||||
{t.code}
|
||||
<span className="flex h-8 items-center gap-2 rounded-md border border-slate-300 bg-slate-100 px-2 text-sm text-slate-700">
|
||||
X-00044
|
||||
<Lock className="ml-auto h-3.5 w-3.5 text-slate-400" />
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-4 pt-5 text-sm text-slate-900">
|
||||
{[t.client, t.agent, t.supplier].map((label, index) => (
|
||||
<label className="inline-flex items-center gap-2" key={label}>
|
||||
<span className={index === 1 ? "flex h-5 w-5 items-center justify-center rounded border border-blue-500 bg-white text-blue-600" : "h-5 w-5 rounded border border-slate-400 bg-white"}>
|
||||
{index === 1 ? "✓" : ""}
|
||||
</span>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="grid gap-1 text-xs text-slate-700">
|
||||
{t.status}
|
||||
<span className="relative flex h-8 items-center rounded-md border border-blue-600 bg-white px-2 text-sm">
|
||||
<span className="h-4 w-px bg-slate-950" />
|
||||
<ChevronDown className="ml-auto h-4 w-4 text-blue-600" />
|
||||
<span className="absolute left-0 top-9 z-10 grid w-32 gap-1 rounded-lg bg-[#3f3f3f] p-3 text-xs text-white shadow-xl">
|
||||
<span>{t.agent}</span>
|
||||
<span>{language === "ru" ? "Прямой клиент" : "Direct client"}</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="grid gap-1 text-xs text-slate-700">
|
||||
{t.comment}
|
||||
<span className="h-8 rounded-md border border-slate-300 bg-white" />
|
||||
</label>
|
||||
<div className="flex min-h-11 flex-wrap items-center gap-1 border-b border-[#ccd4df] bg-[#f4f6f9] px-3 py-2">
|
||||
{commands.length ? commands.map((command) => (
|
||||
<button className="h-7 border border-[#aeb8c6] bg-gradient-to-b from-white to-[#edf1f6] px-3 text-xs font-medium text-[#1f2937]" key={command.lineage_id} type="button">
|
||||
{command.name}
|
||||
</button>
|
||||
)) : <span className="text-xs text-muted-foreground">{language === "ru" ? "Команды формы не описаны" : "No form commands"}</span>}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-b border-slate-200">
|
||||
<div className="flex gap-5 text-sm">
|
||||
{[t.goods, t.sites, t.compensationTerms, t.agencyAgreements, t.telegram, t.mail].map((tab, index) => (
|
||||
<button
|
||||
className={index === 0 ? "border-b-2 border-yellow-400 px-2 py-2 text-slate-950" : "px-2 py-2 text-slate-500 hover:text-slate-900"}
|
||||
key={tab}
|
||||
type="button"
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
<div className={[
|
||||
"grid grid-cols-12 gap-x-3 gap-y-2 p-5",
|
||||
layout === "compact" ? "gap-y-1" : ""
|
||||
].join(" ")}>
|
||||
{elements.length ? (
|
||||
elements.map((element) => (
|
||||
<IdeFormControl element={element} forceHalf={layout === "columns"} key={element.id} />
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-12 border border-dashed border-[#aeb8c6] bg-white px-4 py-6 text-sm text-slate-600" data-ide-form-empty>
|
||||
<div className="font-semibold text-slate-900">{language === "ru" ? "Структура элементов формы не загружена" : "Form element structure is not loaded"}</div>
|
||||
<div className="mt-1 text-xs">
|
||||
{form?.form.qualified_name ?? form?.form.name ?? data.projectId}
|
||||
</div>
|
||||
<div className="mt-3 text-xs">
|
||||
{language === "ru"
|
||||
? "В текущем индексе для этой формы нет узлов элементов. SFERA не подставляет шаблонные поля, чтобы не искажать объект 1С."
|
||||
: "The current index has no element nodes for this form. SFERA does not insert template fields because that would distort the 1C object."}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 overflow-hidden rounded-md border border-slate-100">
|
||||
<div className="flex h-10 items-center gap-3 bg-[#f0f0f0] px-3 text-sm text-slate-800">
|
||||
<button className="inline-flex h-8 items-center gap-2 rounded px-2 hover:bg-white" type="button">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t.create}
|
||||
</button>
|
||||
<span className="h-6 w-px bg-slate-300" />
|
||||
<button className="flex h-8 w-8 items-center justify-center rounded hover:bg-white" type="button" title="copy">
|
||||
<Copy className="h-4 w-4 text-slate-500" />
|
||||
</button>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<button className="inline-flex items-center gap-2 hover:text-slate-950" type="button">
|
||||
<Search className="h-4 w-4" />
|
||||
{t.search}
|
||||
</button>
|
||||
<button className="flex h-8 w-8 items-center justify-center rounded hover:bg-white" type="button" title="filter">
|
||||
<Filter className="h-4 w-4" />
|
||||
</button>
|
||||
<button className="flex h-8 w-8 items-center justify-center rounded hover:bg-white" type="button" title="more">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-[48px_1.1fr_90px_1.6fr_1fr_1fr_1.2fr] border-b border-slate-100 text-xs font-semibold text-slate-950">
|
||||
{["", t.nameField, t.code, t.sentToBankCompanyName, t.mergeProject, t.legalEntity, t.result].map((heading) => (
|
||||
<div className="px-3 py-3" key={heading}>{heading}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-h-[210px] items-center justify-center text-sm text-slate-500">{t.emptyList}</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto grid max-w-3xl grid-cols-[minmax(0,330px)_minmax(0,360px)] gap-x-11 gap-y-3 pt-6">
|
||||
{[
|
||||
[t.author, t.none],
|
||||
[t.creationDate, t.none],
|
||||
[t.editor, t.none],
|
||||
[t.editDate, t.none]
|
||||
].map(([label, value]) => (
|
||||
<label className="grid gap-1 text-xs text-slate-700" key={label}>
|
||||
{label}
|
||||
<span className="flex h-8 items-center rounded-md border border-slate-300 bg-slate-100 px-2 text-sm text-slate-700">{value}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-end gap-3">
|
||||
{commandLabels.map((command) => (
|
||||
<button className="h-8 rounded-md bg-slate-100 px-3 text-xs font-medium text-slate-700 hover:bg-slate-200" key={command} type="button">
|
||||
{command}
|
||||
<div className="mt-auto border-t border-[#ccd4df] bg-white px-4 py-3">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{commands.slice(0, 3).map((command) => (
|
||||
<button className="h-8 border border-[#aeb8c6] bg-gradient-to-b from-white to-[#edf1f6] px-3 text-xs font-medium text-slate-700" key={`bottom-${command.lineage_id}`} type="button">
|
||||
{command.name}
|
||||
</button>
|
||||
))}
|
||||
<button className="h-9 rounded-md bg-yellow-400 px-4 text-sm font-semibold text-slate-950 hover:bg-yellow-300" type="button">
|
||||
<button className="h-8 bg-yellow-400 px-4 text-sm font-semibold text-slate-950 hover:bg-yellow-300" type="button">
|
||||
{t.saveAndClose}
|
||||
</button>
|
||||
<button className="h-9 rounded-md bg-slate-200 px-4 text-sm font-semibold text-slate-900 hover:bg-slate-300" type="button">
|
||||
<button className="h-8 bg-slate-200 px-4 text-sm font-semibold text-slate-900 hover:bg-slate-300" type="button">
|
||||
{t.save}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<aside className="min-h-0 overflow-auto border-l border-border bg-background">
|
||||
<div className="border-b border-border p-3">
|
||||
<div className="text-sm font-medium">{language === "ru" ? "Свойства формы" : "Form properties"}</div>
|
||||
<label className="mt-3 grid gap-1 text-xs font-medium text-muted-foreground">
|
||||
{language === "ru" ? "Заголовок" : "Title"}
|
||||
<input className="h-8 border border-border bg-background px-2 text-sm text-foreground" value={formTitle} onChange={(event) => setTitleByForm((current) => ({ ...current, [formKey]: event.target.value }))} />
|
||||
</label>
|
||||
<label className="mt-2 grid gap-1 text-xs font-medium text-muted-foreground">
|
||||
{language === "ru" ? "Размещение" : "Layout"}
|
||||
<select className="h-8 border border-border bg-background px-2 text-sm text-foreground" value={layout} onChange={(event) => setLayoutByForm((current) => ({ ...current, [formKey]: event.target.value as "auto" | "columns" | "compact" }))}>
|
||||
<option value="auto">{language === "ru" ? "Авто 1С" : "1C auto"}</option>
|
||||
<option value="columns">{language === "ru" ? "Колонки" : "Columns"}</option>
|
||||
<option value="compact">{language === "ru" ? "Компактно" : "Compact"}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 border-b border-border p-3">
|
||||
<IdeFormMetric label="elements" value={flatElements.length} />
|
||||
<IdeFormMetric label="commands" value={commands.length} />
|
||||
</div>
|
||||
<div className="border-b border-border p-3">
|
||||
<div className="text-xs font-medium text-muted-foreground">{language === "ru" ? "Добавить элемент" : "Add element"}</div>
|
||||
<div className="mt-2 grid grid-cols-[minmax(0,1fr)_110px] gap-2">
|
||||
<input className="h-8 border border-border bg-background px-2 text-sm text-foreground" placeholder={language === "ru" ? "Новый реквизит" : "New attribute"} value={newElementName} onChange={(event) => setNewElementName(event.target.value)} />
|
||||
<select className="h-8 border border-border bg-background px-2 text-sm text-foreground" value={newElementKind} onChange={(event) => setNewElementKind(event.target.value as IdeFormElementDraft["controlKind"])}>
|
||||
<option value="input">{language === "ru" ? "Поле" : "Input"}</option>
|
||||
<option value="date">{language === "ru" ? "Дата" : "Date"}</option>
|
||||
<option value="checkbox">{language === "ru" ? "Флажок" : "Checkbox"}</option>
|
||||
<option value="table">{language === "ru" ? "Таблица" : "Table"}</option>
|
||||
<option value="group">{language === "ru" ? "Группа" : "Group"}</option>
|
||||
<option value="text">{language === "ru" ? "Текст" : "Text"}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button className="mt-2 h-8 w-full bg-primary px-3 text-sm font-medium text-primary-foreground" onClick={addElement} type="button">
|
||||
{language === "ru" ? "Добавить в макет" : "Add to layout"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{propertyElements.map((element) => (
|
||||
<div className="grid gap-2 p-3" key={`props-${element.id}`}>
|
||||
<div className="truncate text-xs font-semibold">{element.name}</div>
|
||||
<input className="h-8 border border-border bg-background px-2 text-sm text-foreground" value={element.caption} onChange={(event) => updateElement(element.id, { caption: event.target.value })} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<select className="h-8 border border-border bg-background px-2 text-sm text-foreground" value={element.controlKind} onChange={(event) => updateElement(element.id, { controlKind: event.target.value as IdeFormElementDraft["controlKind"] })}>
|
||||
<option value="input">{language === "ru" ? "Поле ввода" : "Input"}</option>
|
||||
<option value="date">{language === "ru" ? "Дата" : "Date"}</option>
|
||||
<option value="checkbox">{language === "ru" ? "Флажок" : "Checkbox"}</option>
|
||||
<option value="table">{language === "ru" ? "Таблица" : "Table"}</option>
|
||||
<option value="group">{language === "ru" ? "Группа" : "Group"}</option>
|
||||
<option value="text">{language === "ru" ? "Текст" : "Text"}</option>
|
||||
</select>
|
||||
<select className="h-8 border border-border bg-background px-2 text-sm text-foreground" value={element.width} onChange={(event) => updateElement(element.id, { width: event.target.value as IdeFormElementDraft["width"] })}>
|
||||
<option value="stretch">{language === "ru" ? "Вся строка" : "Full"}</option>
|
||||
<option value="half">1/2</option>
|
||||
<option value="third">1/3</option>
|
||||
</select>
|
||||
</div>
|
||||
<input className="h-8 border border-border bg-background px-2 text-sm text-foreground" value={element.binding} onChange={(event) => updateElement(element.id, { binding: event.target.value })} />
|
||||
</div>
|
||||
))}
|
||||
{flatElements.length > propertyElements.length ? (
|
||||
<div className="p-3 text-xs text-muted-foreground">
|
||||
{language === "ru"
|
||||
? `Показаны первые ${propertyElements.length} свойств из ${flatElements.length}. Остальные элементы доступны в макете формы.`
|
||||
: `Showing first ${propertyElements.length} properties out of ${flatElements.length}. Other items are available in the form layout.`}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
type IdeFormElementDraft = {
|
||||
id: string;
|
||||
name: string;
|
||||
caption: string;
|
||||
controlKind: "input" | "date" | "checkbox" | "table" | "group" | "text";
|
||||
binding: string;
|
||||
width: "stretch" | "half" | "third";
|
||||
qualifiedName?: string;
|
||||
parentQualifiedName?: string | null;
|
||||
children: IdeFormElementDraft[];
|
||||
};
|
||||
|
||||
function IdeFormControl({ element, forceHalf }: Readonly<{ element: IdeFormElementDraft; forceHalf: boolean }>) {
|
||||
const span = element.controlKind === "table" || element.controlKind === "group" || element.controlKind === "text" ? "col-span-12" : forceHalf || element.width === "half" ? "col-span-6" : element.width === "third" ? "col-span-4" : "col-span-12";
|
||||
if (element.controlKind === "table") {
|
||||
return (
|
||||
<section className={`${span} border border-[#aeb8c6] bg-white`} data-ide-form-element={element.id} data-ide-form-control={element.controlKind}>
|
||||
<div className="flex min-h-8 items-center justify-between border-b border-[#ccd4df] bg-[#eef2f7] px-2 text-xs font-semibold text-[#1f2937]">
|
||||
<span>{element.caption}</span>
|
||||
<span className="text-[11px] font-medium text-[#687385]">{element.binding}</span>
|
||||
</div>
|
||||
{ideFormControlInput(element)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (element.controlKind === "group") {
|
||||
return (
|
||||
<fieldset className={`${span} border border-[#b9c1cd] bg-[#f6f8fb] px-3 pb-3 pt-2`} data-ide-form-element={element.id} data-ide-form-control={element.controlKind}>
|
||||
<legend className="px-1 text-xs font-semibold text-[#4b5563]">{element.caption}</legend>
|
||||
<div className="grid grid-cols-12 gap-x-3 gap-y-2">
|
||||
{element.children.length ? element.children.map((child) => <IdeFormControl element={child} forceHalf={forceHalf} key={child.id} />) : null}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
if (element.controlKind === "text") {
|
||||
return (
|
||||
<div className={`${span} border border-transparent px-1 py-1`} data-ide-form-element={element.id} data-ide-form-control={element.controlKind}>
|
||||
{ideFormControlInput(element)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={`${span} grid min-h-8 grid-cols-[150px_minmax(0,1fr)] items-center gap-2 border border-transparent px-1 py-1 hover:border-[#b9c1cd] hover:bg-white`} data-ide-form-element={element.id} data-ide-form-control={element.controlKind}>
|
||||
<label className="truncate text-xs font-semibold text-[#4b5563]">{element.caption}</label>
|
||||
{ideFormControlInput(element)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ideFormControlInput(element: IdeFormElementDraft) {
|
||||
if (element.controlKind === "table") {
|
||||
const columns = element.children.filter((child) => child.controlKind !== "text" && child.controlKind !== "group");
|
||||
const visibleColumns = columns.length ? columns : [{ ...element, caption: element.caption, name: element.name, id: `${element.id}.column`, children: [] }];
|
||||
return (
|
||||
<div className="min-h-32 overflow-hidden bg-white text-xs">
|
||||
<div className="grid" style={{ gridTemplateColumns: `repeat(${Math.min(visibleColumns.length, 8)}, minmax(120px, 1fr))` }}>
|
||||
{visibleColumns.slice(0, 8).map((column) => (
|
||||
<span className="min-h-7 border-b border-r border-[#d7dde6] bg-[#f4f6f9] px-2 py-1 font-semibold text-[#374151] last:border-r-0" key={column.id}>
|
||||
{column.caption}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{[0, 1, 2].map((row) => (
|
||||
<div className="grid" key={row} style={{ gridTemplateColumns: `repeat(${Math.min(visibleColumns.length, 8)}, minmax(120px, 1fr))` }}>
|
||||
{visibleColumns.slice(0, 8).map((column) => (
|
||||
<span className="min-h-7 border-b border-r border-[#edf1f6] px-2 py-1 last:border-r-0" key={`${row}-${column.id}`} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (element.controlKind === "checkbox") {
|
||||
return <label className="flex items-center gap-2 text-xs text-slate-800"><input checked readOnly type="checkbox" />{element.binding}</label>;
|
||||
}
|
||||
if (element.controlKind === "group") {
|
||||
return <div className="min-h-11 border border-dashed border-[#aeb8c6] bg-[#f6f8fb] px-2 py-2 text-xs font-medium text-muted-foreground">{element.binding}</div>;
|
||||
}
|
||||
if (element.controlKind === "text") {
|
||||
return <div className="whitespace-pre-wrap border border-transparent bg-transparent px-1 py-1 text-xs leading-5 text-[#374151]">{element.caption || element.binding}</div>;
|
||||
}
|
||||
return <input className="h-7 border border-[#aeb8c6] bg-white px-2 text-xs" readOnly value={element.binding} />;
|
||||
}
|
||||
|
||||
function IdeFormMetric({ label, value }: Readonly<{ label: string; value: number }>) {
|
||||
return (
|
||||
<div className="border border-border p-2">
|
||||
<div className="text-base font-semibold">{value}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildIdeFormElements(form: ProjectWorkspaceData["forms"][number] | undefined): IdeFormElementDraft[] {
|
||||
const explicitElements = form?.elements ?? [];
|
||||
if (explicitElements.length) {
|
||||
const formQualifiedName = form?.form.qualified_name ?? "";
|
||||
const drafts = explicitElements
|
||||
.filter((element) => formElementString(element.attributes, ["visible"]) !== "false")
|
||||
.map((element, index) => {
|
||||
const qualifiedName = element.qualified_name ?? element.name;
|
||||
return {
|
||||
id: element.lineage_id || `element.${index}`,
|
||||
name: element.name,
|
||||
caption: formElementCaption(element),
|
||||
controlKind: controlKindForFormNode(element.name, formElementString(element.attributes, ["control_kind", "control", "type", "kind"]) ?? element.kind),
|
||||
binding: formElementString(element.attributes, ["binding", "dataPath", "data_path", "path"]) ?? qualifiedName ?? element.name,
|
||||
width: formElementWidth(element.attributes, index),
|
||||
qualifiedName,
|
||||
parentQualifiedName: parentQualifiedNameForElement(qualifiedName, formQualifiedName),
|
||||
children: []
|
||||
} satisfies IdeFormElementDraft;
|
||||
});
|
||||
return nestIdeFormElements(drafts);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function formElementCaption(element: ProjectWorkspaceData["forms"][number]["elements"][number]) {
|
||||
const title = formElementString(element.attributes, ["caption", "title", "synonym"]);
|
||||
if (title) return title;
|
||||
const dataPath = formElementString(element.attributes, ["dataPath", "data_path", "path"]);
|
||||
if (dataPath?.includes(".")) return dataPath.split(".").at(-1) ?? element.name;
|
||||
return element.name;
|
||||
}
|
||||
|
||||
function parentQualifiedNameForElement(qualifiedName: string, formQualifiedName: string) {
|
||||
const dot = qualifiedName.lastIndexOf(".");
|
||||
if (dot < 0) return null;
|
||||
const parent = qualifiedName.slice(0, dot);
|
||||
return parent === formQualifiedName ? null : parent;
|
||||
}
|
||||
|
||||
function nestIdeFormElements(elements: IdeFormElementDraft[]) {
|
||||
const byQualifiedName = new Map(elements.map((element) => [element.qualifiedName, element]));
|
||||
const roots: IdeFormElementDraft[] = [];
|
||||
for (const element of elements) {
|
||||
const parent = element.parentQualifiedName ? byQualifiedName.get(element.parentQualifiedName) : null;
|
||||
if (parent) {
|
||||
parent.children.push(element);
|
||||
} else {
|
||||
roots.push(element);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function flattenIdeFormElements(elements: IdeFormElementDraft[]): IdeFormElementDraft[] {
|
||||
return elements.flatMap((element) => [element, ...flattenIdeFormElements(element.children)]);
|
||||
}
|
||||
|
||||
function updateIdeFormElementTree(elements: IdeFormElementDraft[], id: string, patch: Partial<IdeFormElementDraft>): IdeFormElementDraft[] {
|
||||
return elements.map((element) => (
|
||||
element.id === id
|
||||
? { ...element, ...patch }
|
||||
: { ...element, children: updateIdeFormElementTree(element.children, id, patch) }
|
||||
));
|
||||
}
|
||||
|
||||
function formElementString(attributes: Record<string, unknown>, keys: string[]): string | null {
|
||||
for (const key of keys) {
|
||||
const value = attributes[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formElementWidth(attributes: Record<string, unknown>, index: number): IdeFormElementDraft["width"] {
|
||||
const raw = formElementString(attributes, ["width", "layout_width", "placement"]);
|
||||
if (raw === "half" || raw === "third" || raw === "stretch") {
|
||||
return raw;
|
||||
}
|
||||
return index < 2 ? "half" : "stretch";
|
||||
}
|
||||
|
||||
function controlKindForFormNode(name: string, kind: string): IdeFormElementDraft["controlKind"] {
|
||||
const normalized = `${name} ${kind}`.toLowerCase();
|
||||
if (normalized.includes("decoration") || normalized.includes("label") || normalized.includes("надп")) return "text";
|
||||
if (normalized.includes("group") || normalized.includes("группа") || normalized.includes("pages") || normalized.includes("page")) return "group";
|
||||
if (normalized.includes("таб") || normalized.includes("table")) return "table";
|
||||
if (normalized.includes("дата") || normalized.includes("date")) return "date";
|
||||
if (normalized.includes("флаг") || normalized.includes("boolean") || normalized.includes("булево")) return "checkbox";
|
||||
return "input";
|
||||
}
|
||||
|
||||
function MetadataDesignerPanel({
|
||||
activeMode,
|
||||
data,
|
||||
|
||||
@@ -772,7 +772,10 @@ export function ProjectSetupClient({ initialSetup }: Readonly<{ initialSetup: Pr
|
||||
return;
|
||||
} else if (mode === "reindex") {
|
||||
setLastSyncPreview(null);
|
||||
await postJson(`/api/sfera/projects/${setup.project_id}/reindex`, undefined);
|
||||
const job = await postJson<OperationJob>(`/api/sfera/projects/${setup.project_id}/reindex/jobs`, undefined);
|
||||
setServerImportJob(job);
|
||||
keepImportRun = true;
|
||||
return;
|
||||
} else {
|
||||
setLastImportResult(null);
|
||||
setServerImportJob(null);
|
||||
@@ -5500,7 +5503,7 @@ function ObjectWorkbench({
|
||||
<ObjectPartList title="Модули" rows={object.modules} />
|
||||
<ObjectRightsList rows={object.rights} />
|
||||
</div>
|
||||
<FormDesignerPanel object={object} />
|
||||
<FormDesignerPanel projectId={projectId} object={object} />
|
||||
<ObjectImpactPanel impact={objectImpact} />
|
||||
<ModuleSourcePanel
|
||||
projectId={projectId}
|
||||
@@ -5512,18 +5515,75 @@ function ObjectWorkbench({
|
||||
);
|
||||
}
|
||||
|
||||
function FormDesignerPanel({ object }: Readonly<{ object: NormalizedMetadataObject }>) {
|
||||
type FormDesignerElementDraft = {
|
||||
id: string;
|
||||
name: string;
|
||||
caption: string;
|
||||
kind: "input" | "date" | "checkbox" | "table" | "group" | "text";
|
||||
binding: string;
|
||||
width: "stretch" | "half" | "third";
|
||||
};
|
||||
|
||||
function FormDesignerPanel({ projectId, object }: Readonly<{ projectId: string; object: NormalizedMetadataObject }>) {
|
||||
const [activeFormName, setActiveFormName] = useState<string | null>(null);
|
||||
const activeForm = object.forms.find((form) => form.name === activeFormName) ?? object.forms[0] ?? null;
|
||||
const formFields = [...object.attributes, ...object.tabular_sections];
|
||||
const activeFormKey = activeForm?.name ?? "";
|
||||
const [titleByForm, setTitleByForm] = useState<Record<string, string>>({});
|
||||
const [layoutByForm, setLayoutByForm] = useState<Record<string, "auto" | "columns" | "compact">>({});
|
||||
const [elementOverrides, setElementOverrides] = useState<Record<string, Partial<FormDesignerElementDraft>>>({});
|
||||
const [extraElementsByForm, setExtraElementsByForm] = useState<Record<string, FormDesignerElementDraft[]>>({});
|
||||
const [newElementName, setNewElementName] = useState("");
|
||||
const [newElementKind, setNewElementKind] = useState<FormDesignerElementDraft["kind"]>("input");
|
||||
const baseElements = activeForm ? buildFormDesignerElements(activeForm) : [];
|
||||
const elements = [...baseElements, ...(extraElementsByForm[activeFormKey] ?? [])].map((element) => ({
|
||||
...element,
|
||||
...(elementOverrides[element.id] ?? {})
|
||||
}));
|
||||
const formTitle = titleByForm[activeFormKey] ?? activeForm?.name ?? "Форма";
|
||||
const layout = layoutByForm[activeFormKey] ?? "auto";
|
||||
const html5EditorHref = activeForm
|
||||
? `/html5/projects/${encodeURIComponent(projectId)}/forms/editor?form=${encodeURIComponent(formQualifiedName(object, activeForm))}`
|
||||
: `/html5/projects/${encodeURIComponent(projectId)}/forms/editor`;
|
||||
|
||||
const updateElement = (id: string, patch: Partial<FormDesignerElementDraft>) => {
|
||||
setElementOverrides((current) => ({ ...current, [id]: { ...(current[id] ?? {}), ...patch } }));
|
||||
};
|
||||
|
||||
const addElement = () => {
|
||||
const name = newElementName.trim();
|
||||
if (!name || !activeFormKey) {
|
||||
return;
|
||||
}
|
||||
const next: FormDesignerElementDraft = {
|
||||
id: `draft-${activeFormKey}-${Date.now()}`,
|
||||
name,
|
||||
caption: name,
|
||||
kind: newElementKind,
|
||||
binding: name,
|
||||
width: "stretch"
|
||||
};
|
||||
setExtraElementsByForm((current) => ({ ...current, [activeFormKey]: [...(current[activeFormKey] ?? []), next] }));
|
||||
setNewElementName("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-5 rounded-md border border-border">
|
||||
<div className="flex h-10 items-center justify-between border-b border-border px-3">
|
||||
<div className="text-sm font-medium">Form designer</div>
|
||||
<div className="mt-5 overflow-hidden rounded-md border border-border">
|
||||
<div className="flex min-h-10 items-center justify-between gap-3 border-b border-border px-3 py-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Редактор формы</div>
|
||||
<div className="text-xs text-muted-foreground">Управляемая форма 1С 8.5: элементы, команды, реквизиты и модуль остаются частью объекта.</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{activeForm ? (
|
||||
<a className="rounded-md border border-border px-2 py-1 text-xs font-medium hover:bg-muted" href={html5EditorHref}>
|
||||
HTML5 редактор
|
||||
</a>
|
||||
) : null}
|
||||
<Badge>{object.forms.length}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{activeForm ? (
|
||||
<div className="grid min-h-72 grid-cols-[220px_minmax(0,1fr)_260px]">
|
||||
<div className="grid min-h-[520px] grid-cols-[220px_minmax(0,1fr)_340px]">
|
||||
<div className="border-r border-border p-2">
|
||||
{object.forms.map((form) => (
|
||||
<button
|
||||
@@ -5535,40 +5595,144 @@ function FormDesignerPanel({ object }: Readonly<{ object: NormalizedMetadataObje
|
||||
}`}
|
||||
>
|
||||
<div className="truncate font-medium">{form.name}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{form.kind}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{formQualifiedName(object, form)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="min-w-0 p-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold">{activeForm.name}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{object.qualified_name}</div>
|
||||
<div className="min-w-0 bg-[#f3f5f8] p-5">
|
||||
<div className="mx-auto min-h-[430px] max-w-[760px] border border-[#b8c0ca] bg-[#fbfbfc] shadow-lg" data-legacy-form-window>
|
||||
<div className="flex min-h-9 items-center justify-between border-b border-[#cbd3df] bg-gradient-to-b from-[#f8f9fb] to-[#e9edf3] px-3 text-sm font-semibold">
|
||||
<span className="truncate">{formTitle}</span>
|
||||
<span className="text-[11px] text-muted-foreground">1C:Enterprise 8.5-style managed form</span>
|
||||
</div>
|
||||
<Badge>{activeForm.kind}</Badge>
|
||||
<div className="flex min-h-11 flex-wrap gap-1 border-b border-[#ccd4df] bg-[#f4f6f9] px-3 py-2">
|
||||
{object.commands.length ? object.commands.map((command) => (
|
||||
<button
|
||||
key={`form-command-${command.name}`}
|
||||
type="button"
|
||||
className="h-7 rounded-none border border-[#aeb8c6] bg-gradient-to-b from-white to-[#edf1f6] px-3 text-xs font-medium text-[#1f2937]"
|
||||
>
|
||||
{command.name}
|
||||
</button>
|
||||
)) : <span className="text-xs text-muted-foreground">Команды не описаны</span>}
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||||
{formFields.slice(0, 18).map((field) => (
|
||||
<div key={`field-${field.kind}-${field.name}`} className="rounded-md border border-border p-2">
|
||||
<div className="truncate text-sm font-medium">{field.name}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{field.kind}</div>
|
||||
<div
|
||||
className={`grid grid-cols-12 gap-x-3 gap-y-2 p-4 ${layout === "compact" ? "gap-y-1" : ""}`}
|
||||
data-legacy-form-layout={layout}
|
||||
>
|
||||
{elements.length ? (
|
||||
elements.map((element) => (
|
||||
<LegacyFormControl key={element.id} element={element} forceHalf={layout === "columns"} />
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-12 border border-dashed border-[#aeb8c6] bg-white px-4 py-6 text-sm text-muted-foreground">
|
||||
<div className="font-semibold text-foreground">Структура элементов формы не загружена</div>
|
||||
<div className="mt-1 text-xs">{formQualifiedName(object, activeForm)}</div>
|
||||
<div className="mt-3 text-xs">SFERA не подставляет реквизиты объекта вместо элементов формы, чтобы не искажать объект 1С.</div>
|
||||
</div>
|
||||
))}
|
||||
{formFields.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">Поля формы не найдены в structure-only metadata.</div>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-l border-border p-3">
|
||||
<div className="text-sm font-medium">Form context</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<SmallMetric label="fields" value={formFields.length} />
|
||||
</div>
|
||||
<div className="overflow-auto border-l border-border bg-background">
|
||||
<div className="border-b border-border p-3">
|
||||
<div className="text-sm font-medium">Свойства формы</div>
|
||||
<div className="mt-3 grid gap-2">
|
||||
<label className="grid gap-1 text-xs font-medium text-muted-foreground">
|
||||
Заголовок
|
||||
<input
|
||||
value={formTitle}
|
||||
onChange={(event) => setTitleByForm((current) => ({ ...current, [activeFormKey]: event.target.value }))}
|
||||
className="h-8 border border-border bg-background px-2 text-sm text-foreground"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-xs font-medium text-muted-foreground">
|
||||
Размещение
|
||||
<select
|
||||
value={layout}
|
||||
onChange={(event) => setLayoutByForm((current) => ({ ...current, [activeFormKey]: event.target.value as "auto" | "columns" | "compact" }))}
|
||||
className="h-8 border border-border bg-background px-2 text-sm text-foreground"
|
||||
>
|
||||
<option value="auto">Авто 1С</option>
|
||||
<option value="columns">Колонки</option>
|
||||
<option value="compact">Компактно</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 border-b border-border p-3">
|
||||
<SmallMetric label="elements" value={elements.length} />
|
||||
<SmallMetric label="commands" value={object.commands.length} />
|
||||
<SmallMetric label="modules" value={object.modules.length} />
|
||||
<SmallMetric label="events" value={object.events.length} />
|
||||
</div>
|
||||
<ObjectMetaList title="Commands" rows={object.commands} />
|
||||
<ObjectMetaList title="Form metadata" rows={Object.entries(activeForm.attributes ?? {}).map(([key, value]) => `${key}: ${String(value)}`)} />
|
||||
<div className="border-b border-border p-3">
|
||||
<div className="text-xs font-medium text-muted-foreground">Добавить элемент</div>
|
||||
<div className="mt-2 grid grid-cols-[minmax(0,1fr)_110px] gap-2">
|
||||
<input
|
||||
value={newElementName}
|
||||
onChange={(event) => setNewElementName(event.target.value)}
|
||||
placeholder="Новый реквизит"
|
||||
className="h-8 border border-border bg-background px-2 text-sm text-foreground"
|
||||
/>
|
||||
<select
|
||||
value={newElementKind}
|
||||
onChange={(event) => setNewElementKind(event.target.value as FormDesignerElementDraft["kind"])}
|
||||
className="h-8 border border-border bg-background px-2 text-sm text-foreground"
|
||||
>
|
||||
<option value="input">Поле</option>
|
||||
<option value="date">Дата</option>
|
||||
<option value="checkbox">Флажок</option>
|
||||
<option value="table">Таблица</option>
|
||||
<option value="group">Группа</option>
|
||||
<option value="text">Текст</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" onClick={addElement} className="mt-2 h-8 w-full rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground">
|
||||
Добавить в макет
|
||||
</button>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{elements.map((element) => (
|
||||
<div key={`props-${element.id}`} className="grid gap-2 p-3">
|
||||
<div className="truncate text-xs font-semibold">{element.name}</div>
|
||||
<input
|
||||
value={element.caption}
|
||||
onChange={(event) => updateElement(element.id, { caption: event.target.value })}
|
||||
className="h-8 border border-border bg-background px-2 text-sm text-foreground"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<select
|
||||
value={element.kind}
|
||||
onChange={(event) => updateElement(element.id, { kind: event.target.value as FormDesignerElementDraft["kind"] })}
|
||||
className="h-8 border border-border bg-background px-2 text-sm text-foreground"
|
||||
>
|
||||
<option value="input">Поле ввода</option>
|
||||
<option value="date">Дата</option>
|
||||
<option value="checkbox">Флажок</option>
|
||||
<option value="table">Таблица</option>
|
||||
<option value="group">Группа</option>
|
||||
<option value="text">Текст</option>
|
||||
</select>
|
||||
<select
|
||||
value={element.width}
|
||||
onChange={(event) => updateElement(element.id, { width: event.target.value as FormDesignerElementDraft["width"] })}
|
||||
className="h-8 border border-border bg-background px-2 text-sm text-foreground"
|
||||
>
|
||||
<option value="stretch">Вся строка</option>
|
||||
<option value="half">1/2</option>
|
||||
<option value="third">1/3</option>
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
value={element.binding}
|
||||
onChange={(event) => updateElement(element.id, { binding: event.target.value })}
|
||||
className="h-8 border border-border bg-background px-2 text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ObjectMetaList title="Модуль/метаданные формы" rows={Object.entries(activeForm.attributes ?? {}).map(([key, value]) => `${key}: ${String(value)}`)} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -5578,6 +5742,87 @@ function FormDesignerPanel({ object }: Readonly<{ object: NormalizedMetadataObje
|
||||
);
|
||||
}
|
||||
|
||||
function LegacyFormControl({ element, forceHalf }: Readonly<{ element: FormDesignerElementDraft; forceHalf: boolean }>) {
|
||||
const span = element.kind === "table" || element.kind === "group" ? "col-span-12" : forceHalf || element.width === "half" ? "col-span-6" : element.width === "third" ? "col-span-4" : "col-span-12";
|
||||
return (
|
||||
<div className={`${span} grid min-h-8 grid-cols-[150px_minmax(0,1fr)] items-center gap-2 border border-transparent px-1 py-1 hover:border-[#b9c1cd] hover:bg-white`}>
|
||||
<label className="truncate text-xs font-semibold text-[#4b5563]">{element.caption}</label>
|
||||
{legacyFormControlInput(element)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function legacyFormControlInput(element: FormDesignerElementDraft): ReactNode {
|
||||
if (element.kind === "table") {
|
||||
return (
|
||||
<div className="min-h-24 border border-[#aeb8c6] bg-white text-xs">
|
||||
<div className="grid grid-cols-[2fr_1fr_1fr] bg-[#eef2f7] font-semibold">
|
||||
<span className="border-b border-r border-[#d7dde6] px-2 py-1">{element.binding}</span>
|
||||
<span className="border-b border-r border-[#d7dde6] px-2 py-1">Количество</span>
|
||||
<span className="border-b border-[#d7dde6] px-2 py-1">Сумма</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[2fr_1fr_1fr]"><span className="min-h-7 border-r border-[#d7dde6]" /><span className="border-r border-[#d7dde6]" /><span /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (element.kind === "checkbox") {
|
||||
return <label className="flex items-center gap-2 text-xs"><input type="checkbox" checked readOnly />{element.binding}</label>;
|
||||
}
|
||||
if (element.kind === "group") {
|
||||
return <div className="min-h-11 border border-dashed border-[#aeb8c6] bg-[#f6f8fb] px-2 py-2 text-xs font-medium text-muted-foreground">{element.binding}</div>;
|
||||
}
|
||||
if (element.kind === "text") {
|
||||
return <textarea value={element.binding} readOnly className="min-h-16 resize-none border border-[#aeb8c6] bg-white px-2 py-1 text-xs" />;
|
||||
}
|
||||
return <input value={element.binding} readOnly className="h-7 border border-[#aeb8c6] bg-white px-2 text-xs" />;
|
||||
}
|
||||
|
||||
function buildFormDesignerElements(form: ObjectPart): FormDesignerElementDraft[] {
|
||||
const rawElements = form.attributes.elements;
|
||||
if (Array.isArray(rawElements)) {
|
||||
return rawElements
|
||||
.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
|
||||
.map((item, index) => {
|
||||
const name = String(item.name ?? item.caption ?? `Элемент${index + 1}`);
|
||||
const kind = String(item.control_kind ?? item.control ?? item.type ?? item.kind ?? "");
|
||||
return {
|
||||
id: String(item.lineage_id ?? item.id ?? `${form.name}-${name}-${index}`),
|
||||
name,
|
||||
caption: String(item.caption ?? name),
|
||||
kind: formDesignerKindFor(kind, name),
|
||||
binding: String(item.binding ?? item.path ?? name),
|
||||
width: String(item.width ?? "") === "half" ? "half" : String(item.width ?? "") === "third" ? "third" : "stretch"
|
||||
};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function formDesignerKindFor(kind: string, name: string): FormDesignerElementDraft["kind"] {
|
||||
const raw = `${kind} ${name}`.toLowerCase();
|
||||
if (raw.includes("table") || raw.includes("табли") || raw.includes("список")) {
|
||||
return "table";
|
||||
}
|
||||
if (raw.includes("check") || raw.includes("boolean") || raw.includes("флаж") || raw.includes("булево")) {
|
||||
return "checkbox";
|
||||
}
|
||||
if (raw.includes("date") || raw.includes("дата")) {
|
||||
return "date";
|
||||
}
|
||||
if (raw.includes("group") || raw.includes("груп")) {
|
||||
return "group";
|
||||
}
|
||||
if (raw.includes("text") || raw.includes("надпись")) {
|
||||
return "text";
|
||||
}
|
||||
return "input";
|
||||
}
|
||||
|
||||
function formQualifiedName(object: NormalizedMetadataObject, form: ObjectPart): string {
|
||||
const qualified = form.attributes.qualified_name ?? form.attributes.qualifiedName;
|
||||
return typeof qualified === "string" && qualified ? qualified : `${object.qualified_name}.${form.name}`;
|
||||
}
|
||||
|
||||
function ObjectMetaList({ title, rows }: Readonly<{ title: string; rows: ObjectPart[] | string[] }>) {
|
||||
return (
|
||||
<div className="mt-3">
|
||||
|
||||
@@ -139,6 +139,7 @@ export type NamedNode = {
|
||||
kind: string;
|
||||
name: string;
|
||||
qualified_name: string;
|
||||
attributes: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type SourceLocation = {
|
||||
@@ -816,6 +817,22 @@ export async function getBslCompletions(
|
||||
return getJson<BslCompletionItem[]>(apiUrl, `/projects/${projectId}/bsl/completions?${params.toString()}`);
|
||||
}
|
||||
|
||||
function ownerQualifiedNameForForm(formQualifiedName: string) {
|
||||
const parts = formQualifiedName.split(".");
|
||||
if (parts[0] === "ОбщаяФорма") {
|
||||
return formQualifiedName;
|
||||
}
|
||||
return parts.length > 1 ? parts.slice(0, -1).join(".") : formQualifiedName;
|
||||
}
|
||||
|
||||
function looksLikeObjectFormQualifiedName(qualifiedName: string) {
|
||||
const parts = qualifiedName.split(".");
|
||||
if (parts[0] === "ОбщаяФорма") {
|
||||
return parts.length === 2;
|
||||
}
|
||||
return parts.length >= 3;
|
||||
}
|
||||
|
||||
export async function getProjectWorkspaceData(projectId: string, apiUrl = resolveApiUrl(), selectedRoutine?: string | null, activeMode?: string | null) {
|
||||
const selectedRoutineName = selectedRoutine?.trim() ?? null;
|
||||
const workspaceMode = activeMode?.trim() || "overview";
|
||||
@@ -850,7 +867,18 @@ export async function getProjectWorkspaceData(projectId: string, apiUrl = resolv
|
||||
);
|
||||
selectedTreeNode = firstCommonModulePage?.children[0] ?? null;
|
||||
}
|
||||
const selectedObjectName = selectedRoutineName ?? selectedTreeNode?.qualified_name ?? null;
|
||||
const selectedFormQualifiedName =
|
||||
selectedTreeNode?.kind === "FORM"
|
||||
? selectedTreeNode.qualified_name
|
||||
: selectedRoutineName && (
|
||||
selectedRoutineName.split(".").at(-1)?.toLocaleLowerCase("ru-RU").includes("форма") ||
|
||||
looksLikeObjectFormQualifiedName(selectedRoutineName)
|
||||
)
|
||||
? selectedRoutineName
|
||||
: null;
|
||||
const selectedObjectName = selectedFormQualifiedName
|
||||
? ownerQualifiedNameForForm(selectedFormQualifiedName)
|
||||
: selectedRoutineName ?? selectedTreeNode?.qualified_name ?? null;
|
||||
const selectedObjectModules = selectedObjectName
|
||||
? getOptionalJson<WorkspaceModuleSource[]>(
|
||||
apiUrl,
|
||||
@@ -956,7 +984,7 @@ export async function getProjectWorkspaceData(projectId: string, apiUrl = resolv
|
||||
apiUrl,
|
||||
metadataCatalog,
|
||||
metadataTree,
|
||||
selectedMetadataNode: selectedMetadataSearch?.results[0] ?? null,
|
||||
selectedMetadataNode: selectedTreeNode,
|
||||
selectedObjectSchema,
|
||||
selectedObjectUi,
|
||||
selectedObjectImpact,
|
||||
@@ -977,6 +1005,7 @@ export async function getProjectWorkspaceData(projectId: string, apiUrl = resolv
|
||||
editorProposedText: authoringProposedText,
|
||||
editorSelectedObject,
|
||||
editorSelectedRoutine,
|
||||
editorSelectedForm: selectedFormQualifiedName,
|
||||
editorModules: objectModules,
|
||||
editorModuleName: selectedObjectModule?.name ?? snapshotModule?.name ?? null,
|
||||
editorSourcePath: selectedObjectModule?.source_path ?? snapshotModule?.source_path ?? null
|
||||
|
||||
@@ -29,6 +29,7 @@ http://server/base/hs/sfera/v1/metadata/apply
|
||||
- `data.read` - чтение данных через ограниченный запрос или менеджер объекта.
|
||||
- `data.write` - изменение данных только при явном `allow_mutation`.
|
||||
- `metadata.apply` - изменение структуры не выполняется из HTTP runtime. Возвращает план установки `.cfe`; применение делает Windows Agent через Designer.
|
||||
- `access.profile.apply` - dry-run проверки плана профиля доступа через `/v1/metadata/apply`. Универсальный мост подтверждает профиль, роли и операции, но реальную запись профилей доступа выполняет только отдельный адаптер под конкретную конфигурацию/БСП или Windows Agent.
|
||||
|
||||
## Безопасность
|
||||
|
||||
@@ -41,4 +42,3 @@ http://server/base/hs/sfera/v1/metadata/apply
|
||||
- `dry_run=false`.
|
||||
|
||||
Без этого операции изменения возвращают блокировку.
|
||||
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
КонецЕсли;
|
||||
Возврат ОтветJSON(BridgeMetadataApply(
|
||||
ПолучитьПоле(Контекст, "payload", Новый Структура),
|
||||
ПолучитьПоле(Контекст, "dry_run", Истина)));
|
||||
ПолучитьПоле(Контекст, "dry_run", Истина),
|
||||
ПолучитьПоле(Контекст, "allow_mutation", Ложь)));
|
||||
КонецФункции
|
||||
|
||||
#КонецОбласти
|
||||
@@ -64,6 +65,7 @@
|
||||
Результат.Вставить("timestamp", ТекущаяДата());
|
||||
Результат.Вставить("mutation_supported", Истина);
|
||||
Результат.Вставить("metadata_apply_supported", Ложь);
|
||||
Результат.Вставить("access_profile_apply_supported", Истина);
|
||||
Возврат Результат;
|
||||
КонецФункции
|
||||
|
||||
@@ -153,7 +155,11 @@
|
||||
Возврат ОшибкаSFERA("Generic write adapter is intentionally not enabled yet. Implement object-specific handlers first.");
|
||||
КонецФункции
|
||||
|
||||
Функция BridgeMetadataApply(Параметры, DryRun)
|
||||
Функция BridgeMetadataApply(Параметры, DryRun, AllowMutation)
|
||||
Операция = Строка(ПолучитьПоле(Параметры, "operation", ""));
|
||||
Если Операция = "access.profile.apply" Тогда
|
||||
Возврат BridgeAccessProfileApply(Параметры, DryRun, AllowMutation);
|
||||
КонецЕсли;
|
||||
Результат = Новый Структура;
|
||||
Результат.Вставить("status", "planned");
|
||||
Результат.Вставить("message", "Changing configuration structure is performed by SFERA Windows Agent through Designer and .cfe update, not by runtime HTTP.");
|
||||
@@ -162,6 +168,27 @@
|
||||
Возврат Результат;
|
||||
КонецФункции
|
||||
|
||||
Функция BridgeAccessProfileApply(Параметры, DryRun, AllowMutation)
|
||||
Профиль = ПолучитьПоле(Параметры, "profile", Новый Структура);
|
||||
Операции = ПолучитьПоле(Параметры, "operations", Новый Массив);
|
||||
ИмяПрофиля = Строка(ПолучитьПоле(Профиль, "qualified_name", ПолучитьПоле(Профиль, "name", "")));
|
||||
Если ПустаяСтрока(ИмяПрофиля) Тогда
|
||||
Возврат ОшибкаSFERA("profile.name or profile.qualified_name is required for access.profile.apply");
|
||||
КонецЕсли;
|
||||
Если Не DryRun И Не AllowMutation Тогда
|
||||
Возврат ОшибкаSFERA("Access profile mutation is blocked. Use dry_run=true or allow_mutation=true with project mutation guard enabled.");
|
||||
КонецЕсли;
|
||||
Результат = Новый Структура;
|
||||
Результат.Вставить("status", ?(DryRun, "dry_run", "planned"));
|
||||
Результат.Вставить("operation", "access.profile.apply");
|
||||
Результат.Вставить("profile", ИмяПрофиля);
|
||||
Результат.Вставить("operations_count", КоличествоЭлементовSFERA(Операции));
|
||||
Результат.Вставить("operations", Операции);
|
||||
Результат.Вставить("message", "Access profile plan was accepted by SFERA extension. Runtime mutation is not executed by the generic bridge; apply through a configuration-specific adapter or Windows Agent.");
|
||||
Результат.Вставить("dry_run", DryRun);
|
||||
Возврат Результат;
|
||||
КонецФункции
|
||||
|
||||
#КонецОбласти
|
||||
|
||||
#Область СлужебныеПроцедурыИФункции
|
||||
@@ -210,6 +237,16 @@
|
||||
Возврат ЗначениеПоУмолчанию;
|
||||
КонецФункции
|
||||
|
||||
Функция КоличествоЭлементовSFERA(Значение)
|
||||
Если Значение = Неопределено Тогда
|
||||
Возврат 0;
|
||||
КонецЕсли;
|
||||
Если ТипЗнч(Значение) = Тип("Массив") Или ТипЗнч(Значение) = Тип("Структура") Или ТипЗнч(Значение) = Тип("Соответствие") Тогда
|
||||
Возврат Значение.Количество();
|
||||
КонецЕсли;
|
||||
Возврат 0;
|
||||
КонецФункции
|
||||
|
||||
Процедура ДобавитьКоллекциюМетаданных(Коллекции, ИмяКоллекции, КоллекцияМетаданных)
|
||||
Объекты = Новый Массив;
|
||||
Для Каждого ОбъектМетаданных Из КоллекцияМетаданных Цикл
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
@@ -71,6 +71,49 @@ class Rights(ObjectPart):
|
||||
permissions: dict = {}
|
||||
|
||||
|
||||
class AccessRoleAssignment(BaseModel):
|
||||
role: str
|
||||
role_qualified_name: str | None = None
|
||||
source: str | None = None
|
||||
attributes: dict = {}
|
||||
|
||||
|
||||
class AccessProfile(BaseModel):
|
||||
name: str
|
||||
qualified_name: str | None = None
|
||||
source_path: str | None = None
|
||||
attributes: dict = {}
|
||||
roles: list[AccessRoleAssignment] = []
|
||||
|
||||
|
||||
class AccessGroup(BaseModel):
|
||||
name: str
|
||||
qualified_name: str | None = None
|
||||
source_path: str | None = None
|
||||
profile: str | None = None
|
||||
profile_qualified_name: str | None = None
|
||||
attributes: dict = {}
|
||||
roles: list[AccessRoleAssignment] = []
|
||||
users: list[str] = []
|
||||
|
||||
|
||||
class AccessUser(BaseModel):
|
||||
name: str
|
||||
qualified_name: str | None = None
|
||||
source_path: str | None = None
|
||||
full_name: str | None = None
|
||||
disabled: bool = False
|
||||
attributes: dict = {}
|
||||
roles: list[AccessRoleAssignment] = []
|
||||
groups: list[str] = []
|
||||
|
||||
|
||||
class AccessModel(BaseModel):
|
||||
profiles: list[AccessProfile] = []
|
||||
groups: list[AccessGroup] = []
|
||||
users: list[AccessUser] = []
|
||||
|
||||
|
||||
class Extension(ObjectPart):
|
||||
kind: str = "EXTENSION"
|
||||
version: str | None = None
|
||||
@@ -137,6 +180,7 @@ class NormalizedProject(BaseModel):
|
||||
project_id: str | None = None
|
||||
configuration: ConfigurationRoot
|
||||
source_path: str | None = None
|
||||
access: AccessModel = Field(default_factory=AccessModel)
|
||||
|
||||
|
||||
def normalize_bsl_source(text: str) -> str:
|
||||
@@ -171,7 +215,7 @@ def parse_one_c_xml_file(path: str | Path) -> list[OneCXmlObject]:
|
||||
source_path = normalize_source_path(path)
|
||||
root = ET.fromstring(_read_text_file(Path(path)))
|
||||
result: list[OneCXmlObject] = []
|
||||
_walk_xml_objects(source_path, root, result, current_role=None, parent_qualified_name=None)
|
||||
_walk_xml_objects(source_path, root, result, current_role=None, parent_qualified_name=None, parent_object_kind=None)
|
||||
return result
|
||||
|
||||
|
||||
@@ -207,6 +251,9 @@ def build_normalized_project(
|
||||
extension_metadata_objects: dict[str, dict[str, MetadataObject]] = {}
|
||||
part_owners: dict[str, tuple[MetadataObject, ObjectPart]] = {}
|
||||
pending_roles: dict[str, Role] = {}
|
||||
access_profiles: dict[str, AccessProfile] = {}
|
||||
access_groups: dict[str, AccessGroup] = {}
|
||||
access_users: dict[str, AccessUser] = {}
|
||||
extensions: list[Extension] = []
|
||||
extension_by_qualified_name: dict[str, Extension] = {}
|
||||
saw_configuration = False
|
||||
@@ -248,6 +295,19 @@ def build_normalized_project(
|
||||
metadata=dict(item.attributes),
|
||||
rights=role.rights,
|
||||
)
|
||||
elif item.object_kind == "ACCESS_PROFILE":
|
||||
profile = _access_profile_from_item(item)
|
||||
access_profiles[_access_key(profile.qualified_name, profile.name)] = profile
|
||||
elif item.object_kind == "ACCESS_GROUP":
|
||||
group = _access_group_from_item(item)
|
||||
access_groups[_access_key(group.qualified_name, group.name)] = group
|
||||
elif item.object_kind == "ACCESS_USER":
|
||||
user = _access_user_from_item(item)
|
||||
access_users[_access_key(user.qualified_name, user.name)] = user
|
||||
elif item.object_kind == "ACCESS_ROLE_ASSIGNMENT":
|
||||
_attach_access_role_assignment(item, access_profiles, access_groups, access_users)
|
||||
elif item.object_kind == "ACCESS_GROUP_MEMBERSHIP":
|
||||
_attach_access_group_membership(item, access_groups, access_users)
|
||||
elif item.object_kind in _ROOT_METADATA_OBJECT_KINDS:
|
||||
target_objects = _metadata_target_for_item(
|
||||
item,
|
||||
@@ -292,6 +352,11 @@ def build_normalized_project(
|
||||
return NormalizedProject(
|
||||
project_id=project_id,
|
||||
source_path=source_path,
|
||||
access=AccessModel(
|
||||
profiles=sorted(access_profiles.values(), key=lambda item: item.name.casefold()),
|
||||
groups=sorted(access_groups.values(), key=lambda item: item.name.casefold()),
|
||||
users=sorted(access_users.values(), key=lambda item: item.name.casefold()),
|
||||
),
|
||||
configuration=ConfigurationRoot(
|
||||
name=configuration_name,
|
||||
metadata=configuration_metadata,
|
||||
@@ -344,6 +409,143 @@ def _all_metadata_objects(
|
||||
return result
|
||||
|
||||
|
||||
def _access_key(qualified_name: str | None, name: str) -> str:
|
||||
return (qualified_name or name).casefold()
|
||||
|
||||
|
||||
def _access_role_assignment_from_attributes(attributes: dict, *, source: str | None = None) -> AccessRoleAssignment | None:
|
||||
role = _first_attr(attributes, "role", "Role", "Роль", "roleName", "ИмяРоли")
|
||||
if not role:
|
||||
return None
|
||||
role_qualified_name = role if "." in role else f"Роль.{role}"
|
||||
return AccessRoleAssignment(
|
||||
role=role.split(".")[-1],
|
||||
role_qualified_name=role_qualified_name,
|
||||
source=source or _first_attr(attributes, "source", "Source", "Источник"),
|
||||
attributes=dict(attributes),
|
||||
)
|
||||
|
||||
|
||||
def _access_profile_from_item(item: OneCXmlObject) -> AccessProfile:
|
||||
attributes = dict(item.attributes)
|
||||
profile = AccessProfile(
|
||||
name=item.name,
|
||||
qualified_name=item.qualified_name,
|
||||
source_path=item.source_path,
|
||||
attributes=attributes,
|
||||
)
|
||||
for role in _split_attr_list(attributes, "roles", "Roles", "Роли"):
|
||||
assignment = _access_role_assignment_from_attributes({"role": role}, source=item.qualified_name)
|
||||
if assignment is not None:
|
||||
profile.roles.append(assignment)
|
||||
return profile
|
||||
|
||||
|
||||
def _access_group_from_item(item: OneCXmlObject) -> AccessGroup:
|
||||
attributes = dict(item.attributes)
|
||||
group = AccessGroup(
|
||||
name=item.name,
|
||||
qualified_name=item.qualified_name,
|
||||
source_path=item.source_path,
|
||||
profile=_first_attr(attributes, "profile", "Profile", "Профиль", "accessProfile", "ПрофильГруппыДоступа"),
|
||||
profile_qualified_name=_first_attr(attributes, "profileQualifiedName", "ProfileQualifiedName", "ПрофильПолноеИмя"),
|
||||
attributes=attributes,
|
||||
)
|
||||
for role in _split_attr_list(attributes, "roles", "Roles", "Роли"):
|
||||
assignment = _access_role_assignment_from_attributes({"role": role}, source=item.qualified_name)
|
||||
if assignment is not None:
|
||||
group.roles.append(assignment)
|
||||
group.users.extend(_split_attr_list(attributes, "users", "Users", "Пользователи", "members", "Members", "Участники"))
|
||||
return group
|
||||
|
||||
|
||||
def _access_user_from_item(item: OneCXmlObject) -> AccessUser:
|
||||
attributes = dict(item.attributes)
|
||||
user = AccessUser(
|
||||
name=item.name,
|
||||
qualified_name=item.qualified_name,
|
||||
source_path=item.source_path,
|
||||
full_name=_first_attr(attributes, "fullName", "FullName", "ПолноеИмя", "full_name"),
|
||||
disabled=_truthy(_first_attr(attributes, "disabled", "Disabled", "Недействителен", "isDisabled")),
|
||||
attributes=attributes,
|
||||
)
|
||||
for role in _split_attr_list(attributes, "roles", "Roles", "Роли"):
|
||||
assignment = _access_role_assignment_from_attributes({"role": role}, source=item.qualified_name)
|
||||
if assignment is not None:
|
||||
user.roles.append(assignment)
|
||||
user.groups.extend(_split_attr_list(attributes, "groups", "Groups", "Группы", "accessGroups", "ГруппыДоступа"))
|
||||
return user
|
||||
|
||||
|
||||
def _attach_access_role_assignment(
|
||||
item: OneCXmlObject,
|
||||
profiles: dict[str, AccessProfile],
|
||||
groups: dict[str, AccessGroup],
|
||||
users: dict[str, AccessUser],
|
||||
) -> None:
|
||||
assignment = _access_role_assignment_from_attributes(item.attributes, source=item.qualified_name)
|
||||
if assignment is None:
|
||||
return
|
||||
owner = _first_attr(item.attributes, "owner", "Owner", "Владелец", "profile", "Profile", "Профиль", "group", "Group", "Группа", "user", "User", "Пользователь")
|
||||
owner_key = owner.casefold() if owner else item.qualified_name.rsplit(".", 1)[0].casefold()
|
||||
for collection in (profiles, groups, users):
|
||||
target = collection.get(owner_key)
|
||||
if target is None:
|
||||
target = next(
|
||||
(
|
||||
value
|
||||
for value in collection.values()
|
||||
if value.name.casefold() == owner_key or str(value.qualified_name or "").casefold() == owner_key
|
||||
),
|
||||
None,
|
||||
)
|
||||
if target is not None:
|
||||
target.roles.append(assignment)
|
||||
return
|
||||
|
||||
|
||||
def _attach_access_group_membership(
|
||||
item: OneCXmlObject,
|
||||
groups: dict[str, AccessGroup],
|
||||
users: dict[str, AccessUser],
|
||||
) -> None:
|
||||
group_name = _first_attr(item.attributes, "group", "Group", "Группа", "accessGroup", "ГруппаДоступа")
|
||||
user_name = _first_attr(item.attributes, "user", "User", "Пользователь", "member", "Member", "Участник")
|
||||
if not group_name or not user_name:
|
||||
return
|
||||
group = groups.get(group_name.casefold()) or next(
|
||||
(value for value in groups.values() if value.name.casefold() == group_name.casefold()),
|
||||
None,
|
||||
)
|
||||
user = users.get(user_name.casefold()) or next(
|
||||
(value for value in users.values() if value.name.casefold() == user_name.casefold()),
|
||||
None,
|
||||
)
|
||||
if group is not None and user_name not in group.users:
|
||||
group.users.append(user_name)
|
||||
if user is not None and group_name not in user.groups:
|
||||
user.groups.append(group_name)
|
||||
|
||||
|
||||
def _first_attr(attributes: dict, *keys: str) -> str:
|
||||
for key in keys:
|
||||
value = attributes.get(key)
|
||||
if value not in (None, ""):
|
||||
return str(value)
|
||||
return ""
|
||||
|
||||
|
||||
def _split_attr_list(attributes: dict, *keys: str) -> list[str]:
|
||||
value = _first_attr(attributes, *keys)
|
||||
if not value:
|
||||
return []
|
||||
return [item.strip() for item in re.split(r"[,;\n]", value) if item.strip()]
|
||||
|
||||
|
||||
def _truthy(value: str) -> bool:
|
||||
return value.casefold() in {"true", "1", "yes", "да", "истина"}
|
||||
|
||||
|
||||
def _walk_xml_objects(
|
||||
source_path: str,
|
||||
element: ET.Element,
|
||||
@@ -351,26 +553,37 @@ def _walk_xml_objects(
|
||||
*,
|
||||
current_role: OneCXmlObject | None,
|
||||
parent_qualified_name: str | None,
|
||||
parent_object_kind: str | None,
|
||||
) -> None:
|
||||
role_context = current_role
|
||||
child_parent_qualified_name = parent_qualified_name
|
||||
object_kind = _xml_object_kind(element)
|
||||
child_parent_object_kind = parent_object_kind
|
||||
object_kind = _xml_object_kind(element, parent_object_kind=parent_object_kind)
|
||||
if object_kind == "RIGHT":
|
||||
right = _xml_right_object(source_path, element, role_context)
|
||||
if right is not None:
|
||||
result.append(right)
|
||||
elif object_kind == "ACCESS_ROLE_ASSIGNMENT":
|
||||
assignment = _xml_access_role_assignment(source_path, element, parent_qualified_name)
|
||||
if assignment is not None:
|
||||
result.append(assignment)
|
||||
elif object_kind == "ACCESS_GROUP_MEMBERSHIP":
|
||||
membership = _xml_access_group_membership(source_path, element, parent_qualified_name)
|
||||
if membership is not None:
|
||||
result.append(membership)
|
||||
elif object_kind is not None:
|
||||
name = _xml_name(element)
|
||||
name = _xml_name(element, source_path=source_path)
|
||||
if name:
|
||||
xml_object = OneCXmlObject(
|
||||
source_path=source_path,
|
||||
object_kind=object_kind,
|
||||
name=name,
|
||||
qualified_name=_xml_qualified_name(element, name, object_kind, parent_qualified_name),
|
||||
qualified_name=_xml_qualified_name(element, name, object_kind, parent_qualified_name, source_path),
|
||||
attributes=_xml_attributes(element),
|
||||
)
|
||||
result.append(xml_object)
|
||||
child_parent_qualified_name = xml_object.qualified_name
|
||||
child_parent_object_kind = object_kind
|
||||
if object_kind == "ROLE":
|
||||
role_context = xml_object
|
||||
|
||||
@@ -381,6 +594,7 @@ def _walk_xml_objects(
|
||||
result,
|
||||
current_role=role_context,
|
||||
parent_qualified_name=child_parent_qualified_name,
|
||||
parent_object_kind=child_parent_object_kind,
|
||||
)
|
||||
|
||||
|
||||
@@ -426,6 +640,56 @@ def _xml_role_reference(element: ET.Element) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _xml_access_role_assignment(
|
||||
source_path: str,
|
||||
element: ET.Element,
|
||||
parent_qualified_name: str | None,
|
||||
) -> OneCXmlObject | None:
|
||||
attributes = _xml_attributes(element)
|
||||
role = _first_attr(attributes, "role", "Role", "Роль", "name", "Name", "Имя")
|
||||
if not role:
|
||||
text = (element.text or "").strip()
|
||||
role = text if text else ""
|
||||
if not role:
|
||||
return None
|
||||
owner = _first_attr(attributes, "profile", "Profile", "Профиль", "group", "Group", "Группа", "user", "User", "Пользователь")
|
||||
if not owner and parent_qualified_name:
|
||||
owner = parent_qualified_name
|
||||
attributes.setdefault("role", role)
|
||||
if owner:
|
||||
attributes.setdefault("owner", owner)
|
||||
return OneCXmlObject(
|
||||
source_path=source_path,
|
||||
object_kind="ACCESS_ROLE_ASSIGNMENT",
|
||||
name=role,
|
||||
qualified_name=f"{owner}.{role}" if owner else role,
|
||||
attributes=attributes,
|
||||
)
|
||||
|
||||
|
||||
def _xml_access_group_membership(
|
||||
source_path: str,
|
||||
element: ET.Element,
|
||||
parent_qualified_name: str | None,
|
||||
) -> OneCXmlObject | None:
|
||||
attributes = _xml_attributes(element)
|
||||
user = _first_attr(attributes, "user", "User", "Пользователь", "member", "Member", "Участник", "name", "Name", "Имя")
|
||||
group = _first_attr(attributes, "group", "Group", "Группа", "accessGroup", "ГруппаДоступа")
|
||||
if not group and parent_qualified_name:
|
||||
group = parent_qualified_name
|
||||
if not user or not group:
|
||||
return None
|
||||
attributes.setdefault("user", user)
|
||||
attributes.setdefault("group", group)
|
||||
return OneCXmlObject(
|
||||
source_path=source_path,
|
||||
object_kind="ACCESS_GROUP_MEMBERSHIP",
|
||||
name=user,
|
||||
qualified_name=f"{group}.{user}",
|
||||
attributes=attributes,
|
||||
)
|
||||
|
||||
|
||||
_OBJECT_KIND_BY_TAG = {
|
||||
"configuration": "PROJECT",
|
||||
"конфигурация": "PROJECT",
|
||||
@@ -502,6 +766,13 @@ _OBJECT_KIND_BY_TAG = {
|
||||
"subsystem": "SUBSYSTEM",
|
||||
"subsystems": "SUBSYSTEM",
|
||||
"подсистема": "SUBSYSTEM",
|
||||
"sequence": "SEQUENCE",
|
||||
"sequences": "SEQUENCE",
|
||||
"последовательность": "SEQUENCE",
|
||||
"documentnumerator": "DOCUMENT_NUMERATOR",
|
||||
"documentnumerators": "DOCUMENT_NUMERATOR",
|
||||
"нумератордокументов": "DOCUMENT_NUMERATOR",
|
||||
"нумератор": "DOCUMENT_NUMERATOR",
|
||||
"httpservice": "HTTP_SERVICE",
|
||||
"httpservices": "HTTP_SERVICE",
|
||||
"httpсервис": "HTTP_SERVICE",
|
||||
@@ -529,6 +800,41 @@ _OBJECT_KIND_BY_TAG = {
|
||||
"пакетxdto": "XDTO_PACKAGE",
|
||||
"role": "ROLE",
|
||||
"роль": "ROLE",
|
||||
"accessprofile": "ACCESS_PROFILE",
|
||||
"accessprofiles": "ACCESS_PROFILE",
|
||||
"accessgroupprofile": "ACCESS_PROFILE",
|
||||
"accessgroupprofiles": "ACCESS_PROFILE",
|
||||
"профильгруппыдоступа": "ACCESS_PROFILE",
|
||||
"профилигруппдоступа": "ACCESS_PROFILE",
|
||||
"accessgroup": "ACCESS_GROUP",
|
||||
"accessgroups": "ACCESS_GROUP",
|
||||
"группадоступа": "ACCESS_GROUP",
|
||||
"группыдоступа": "ACCESS_GROUP",
|
||||
"infobaseuser": "ACCESS_USER",
|
||||
"infobaseusers": "ACCESS_USER",
|
||||
"accessuser": "ACCESS_USER",
|
||||
"accessusers": "ACCESS_USER",
|
||||
"user": "ACCESS_USER",
|
||||
"users": "ACCESS_USER",
|
||||
"пользователь": "ACCESS_USER",
|
||||
"пользователи": "ACCESS_USER",
|
||||
"roleassignment": "ACCESS_ROLE_ASSIGNMENT",
|
||||
"roleassignments": "ACCESS_ROLE_ASSIGNMENT",
|
||||
"accessrole": "ACCESS_ROLE_ASSIGNMENT",
|
||||
"accessroles": "ACCESS_ROLE_ASSIGNMENT",
|
||||
"profilerole": "ACCESS_ROLE_ASSIGNMENT",
|
||||
"grouprole": "ACCESS_ROLE_ASSIGNMENT",
|
||||
"userrole": "ACCESS_ROLE_ASSIGNMENT",
|
||||
"рольдоступа": "ACCESS_ROLE_ASSIGNMENT",
|
||||
"роли": "ACCESS_ROLE_ASSIGNMENT",
|
||||
"member": "ACCESS_GROUP_MEMBERSHIP",
|
||||
"members": "ACCESS_GROUP_MEMBERSHIP",
|
||||
"membership": "ACCESS_GROUP_MEMBERSHIP",
|
||||
"memberships": "ACCESS_GROUP_MEMBERSHIP",
|
||||
"participant": "ACCESS_GROUP_MEMBERSHIP",
|
||||
"participants": "ACCESS_GROUP_MEMBERSHIP",
|
||||
"участник": "ACCESS_GROUP_MEMBERSHIP",
|
||||
"участники": "ACCESS_GROUP_MEMBERSHIP",
|
||||
"sessionparameter": "SESSION_PARAMETER",
|
||||
"sessionparameters": "SESSION_PARAMETER",
|
||||
"параметрсеанса": "SESSION_PARAMETER",
|
||||
@@ -695,10 +1001,38 @@ _OBJECT_KIND_BY_TAG = {
|
||||
"предопределенный": "PREDEFINED",
|
||||
}
|
||||
|
||||
_FORM_ELEMENT_TAGS = {
|
||||
"item",
|
||||
"items",
|
||||
"element",
|
||||
"elements",
|
||||
"formitem",
|
||||
"formitems",
|
||||
"field",
|
||||
"fields",
|
||||
"group",
|
||||
"groups",
|
||||
"table",
|
||||
"tables",
|
||||
"button",
|
||||
"buttons",
|
||||
"page",
|
||||
"pages",
|
||||
}
|
||||
|
||||
|
||||
_QUALIFIED_PREFIX_BY_KIND = {
|
||||
"CATALOG": "Справочник",
|
||||
"DOCUMENT": "Документ",
|
||||
"CONSTANT": "Константа",
|
||||
"DOCUMENT_JOURNAL": "ЖурналДокументов",
|
||||
"ENUM": "Перечисление",
|
||||
"REPORT": "Отчет",
|
||||
"DATA_PROCESSOR": "Обработка",
|
||||
"CHART_OF_CHARACTERISTIC_TYPES": "ПланВидовХарактеристик",
|
||||
"CHART_OF_ACCOUNTS": "ПланСчетов",
|
||||
"CHART_OF_CALCULATION_TYPES": "ПланВидовРасчета",
|
||||
"EXTERNAL_DATA_SOURCE": "ВнешнийИсточникДанных",
|
||||
"REGISTER": "Регистр",
|
||||
"INFORMATION_REGISTER": "РегистрСведений",
|
||||
"ACCUMULATION_REGISTER": "РегистрНакопления",
|
||||
@@ -711,6 +1045,8 @@ _QUALIFIED_PREFIX_BY_KIND = {
|
||||
"BUSINESS_PROCESS": "БизнесПроцесс",
|
||||
"TASK": "Задача",
|
||||
"SUBSYSTEM": "Подсистема",
|
||||
"SEQUENCE": "Последовательность",
|
||||
"DOCUMENT_NUMERATOR": "НумераторДокументов",
|
||||
"HTTP_SERVICE": "HTTPСервис",
|
||||
"WEB_SERVICE": "WebСервис",
|
||||
"WS_REFERENCE": "WSСсылка",
|
||||
@@ -738,6 +1074,9 @@ _QUALIFIED_PREFIX_BY_KIND = {
|
||||
"STYLE_ITEM": "ЭлементСтиля",
|
||||
"STYLE": "Стиль",
|
||||
"LANGUAGE": "Язык",
|
||||
"ACCESS_PROFILE": "ПрофильГруппыДоступа",
|
||||
"ACCESS_GROUP": "ГруппаДоступа",
|
||||
"ACCESS_USER": "Пользователь",
|
||||
"FORM": "Форма",
|
||||
"COMMAND": "Команда",
|
||||
"URL_TEMPLATE": "ШаблонURL",
|
||||
@@ -780,6 +1119,8 @@ _QUALIFIED_PREFIX_BY_TAG = {
|
||||
"bot": "Бот",
|
||||
"interface": "Интерфейс",
|
||||
"fulltextsearchdictionary": "СловарьПолнотекстовогоПоиска",
|
||||
"sequence": "Последовательность",
|
||||
"documentnumerator": "НумераторДокументов",
|
||||
}
|
||||
|
||||
_ROOT_METADATA_OBJECT_KINDS = {
|
||||
@@ -807,6 +1148,8 @@ _ROOT_METADATA_OBJECT_KINDS = {
|
||||
"BUSINESS_PROCESS",
|
||||
"TASK",
|
||||
"SUBSYSTEM",
|
||||
"SEQUENCE",
|
||||
"DOCUMENT_NUMERATOR",
|
||||
"HTTP_SERVICE",
|
||||
"WEB_SERVICE",
|
||||
"WS_REFERENCE",
|
||||
@@ -828,6 +1171,7 @@ _ROOT_METADATA_OBJECT_KINDS = {
|
||||
"COMMON_LAYOUT",
|
||||
"COMMON_PICTURE",
|
||||
"INTEGRATION_SERVICE",
|
||||
"EXTENSION",
|
||||
"PALETTE_COLOR",
|
||||
"STYLE_ITEM",
|
||||
"STYLE",
|
||||
@@ -838,8 +1182,11 @@ _ROOT_METADATA_OBJECT_KINDS = {
|
||||
_GROUP_BY_OBJECT_KIND = {
|
||||
"PROJECT": "Конфигурация",
|
||||
"COMMON_MODULE": "Общие модули",
|
||||
"CONSTANT": "Константы",
|
||||
"CATALOG": "Справочники",
|
||||
"DOCUMENT": "Документы",
|
||||
"DOCUMENT_JOURNAL": "Журналы документов",
|
||||
"ENUM": "Перечисления",
|
||||
"REGISTER": "Регистры",
|
||||
"INFORMATION_REGISTER": "Регистры сведений",
|
||||
"ACCUMULATION_REGISTER": "Регистры накопления",
|
||||
@@ -847,10 +1194,18 @@ _GROUP_BY_OBJECT_KIND = {
|
||||
"CALCULATION_REGISTER": "Регистры расчета",
|
||||
"REPORT": "Отчеты",
|
||||
"DATA_PROCESSOR": "Обработки",
|
||||
"CHART_OF_CHARACTERISTIC_TYPES": "Планы видов характеристик",
|
||||
"CHART_OF_ACCOUNTS": "Планы счетов",
|
||||
"CHART_OF_CALCULATION_TYPES": "Планы видов расчета",
|
||||
"BUSINESS_PROCESS": "Бизнес-процессы",
|
||||
"TASK": "Задачи",
|
||||
"EXTENSION": "Расширения конфигурации",
|
||||
"FORM": "Формы",
|
||||
"COMMAND": "Команды",
|
||||
"ROLE": "Роли",
|
||||
"SUBSYSTEM": "Подсистемы",
|
||||
"SEQUENCE": "Последовательности",
|
||||
"DOCUMENT_NUMERATOR": "Нумераторы документов",
|
||||
"HTTP_SERVICE": "HTTP-сервисы",
|
||||
"WEB_SERVICE": "Web-сервисы",
|
||||
"WS_REFERENCE": "WS-ссылки",
|
||||
@@ -1095,9 +1450,15 @@ def _attach_bsl_modules(root: Path, normalized: NormalizedProject) -> None:
|
||||
"original_hash": source.original_hash,
|
||||
"source_text": source.text,
|
||||
"module_role": role,
|
||||
"owner_qualified_name": owner.qualified_name,
|
||||
"owner_kind": owner.object_kind,
|
||||
"object_part": _module_object_part(role, form_name),
|
||||
}
|
||||
if form_name:
|
||||
attributes["form_name"] = form_name
|
||||
form = _find_owner_form(owner, form_name)
|
||||
if form is not None:
|
||||
attributes["form_qualified_name"] = form.qualified_name
|
||||
owner.modules.append(
|
||||
Module(
|
||||
name=source_file.stem,
|
||||
@@ -1189,6 +1550,29 @@ def _module_qualified_name(owner: MetadataObject, role: str, form_name: str, mod
|
||||
return f"{owner.qualified_name}.{role_suffix}"
|
||||
|
||||
|
||||
def _module_object_part(role: str, form_name: str = "") -> str:
|
||||
return {
|
||||
"OBJECT_MODULE": "object.module",
|
||||
"MANAGER_MODULE": "object.manager",
|
||||
"RECORD_SET_MODULE": "object.record_set",
|
||||
"FORM_MODULE": f"form.{form_name}.module" if form_name else "form.module",
|
||||
"MODULE": "module",
|
||||
}.get(role, "module")
|
||||
|
||||
|
||||
def _find_owner_form(owner: MetadataObject, form_name: str) -> Form | None:
|
||||
normalized = form_name.casefold()
|
||||
return next(
|
||||
(
|
||||
form
|
||||
for form in owner.forms
|
||||
if form.name.casefold() == normalized
|
||||
or str(form.qualified_name or "").casefold().endswith(f".{normalized}")
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _form_name_for_module(root: Path, source_file: Path) -> str:
|
||||
parts = list(_relative_path(source_file, root).parts)
|
||||
normalized_parts = [_normalize_path_part(part) for part in parts]
|
||||
@@ -1259,8 +1643,23 @@ _PATH_METADATA_ALIASES = {
|
||||
}
|
||||
|
||||
|
||||
def _xml_object_kind(element: ET.Element) -> str | None:
|
||||
def _xml_object_kind(element: ET.Element, *, parent_object_kind: str | None = None) -> str | None:
|
||||
tag = _local_name(element.tag).lower()
|
||||
if parent_object_kind in {"FORM", "ELEMENT"} and tag in _FORM_ELEMENT_TAGS and _xml_name(element):
|
||||
return "ELEMENT"
|
||||
if parent_object_kind in {"ACCESS_PROFILE", "ACCESS_GROUP", "ACCESS_USER"} and tag in {
|
||||
"role",
|
||||
"roles",
|
||||
"роль",
|
||||
"роли",
|
||||
"accessrole",
|
||||
"profilerole",
|
||||
"grouprole",
|
||||
"userrole",
|
||||
}:
|
||||
return "ACCESS_ROLE_ASSIGNMENT"
|
||||
if parent_object_kind == "ACCESS_GROUP" and tag in {"member", "members", "user", "users", "участник", "участники", "пользователь", "пользователи"}:
|
||||
return "ACCESS_GROUP_MEMBERSHIP"
|
||||
if tag in {"metadataobject", "object"}:
|
||||
type_name = _xml_type_name(element)
|
||||
if type_name:
|
||||
@@ -1278,7 +1677,7 @@ def _xml_type_name(element: ET.Element) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _xml_name(element: ET.Element) -> str:
|
||||
def _xml_name(element: ET.Element, *, source_path: str = "") -> str:
|
||||
for key in ("name", "Name", "Имя"):
|
||||
if key in element.attrib:
|
||||
return element.attrib[key]
|
||||
@@ -1286,6 +1685,9 @@ def _xml_name(element: ET.Element) -> str:
|
||||
if _local_name(child.tag).lower() in {"name", "имя"} and child.text:
|
||||
return child.text.strip()
|
||||
tag = _local_name(element.tag).lower()
|
||||
edt_form = _edt_form_context_from_path(source_path)
|
||||
if tag == "form" and edt_form is not None:
|
||||
return edt_form[0]
|
||||
fallback_keys = {
|
||||
"urltemplate": ("template", "url", "path", "Шаблон", "URL"),
|
||||
"urltemplates": ("template", "url", "path", "Шаблон", "URL"),
|
||||
@@ -1334,6 +1736,7 @@ def _xml_qualified_name(
|
||||
name: str,
|
||||
object_kind: str,
|
||||
parent_qualified_name: str | None,
|
||||
source_path: str = "",
|
||||
) -> str:
|
||||
for key in ("qualifiedName", "QualifiedName", "ПолноеИмя"):
|
||||
if key in element.attrib:
|
||||
@@ -1341,6 +1744,10 @@ def _xml_qualified_name(
|
||||
for child in _xml_property_children(element):
|
||||
if _local_name(child.tag).lower() in {"qualifiedname", "полноеимя"} and child.text:
|
||||
return child.text.strip()
|
||||
if object_kind == "FORM" and parent_qualified_name is None:
|
||||
edt_form = _edt_form_context_from_path(source_path)
|
||||
if edt_form is not None:
|
||||
return edt_form[1]
|
||||
if parent_qualified_name:
|
||||
if object_kind in _ROOT_METADATA_OBJECT_KINDS and object_kind not in {"PROJECT", "ROLE"}:
|
||||
prefix = _QUALIFIED_PREFIX_BY_KIND.get(object_kind, object_kind)
|
||||
@@ -1353,8 +1760,55 @@ def _xml_qualified_name(
|
||||
return name
|
||||
|
||||
|
||||
_EDT_OWNER_PREFIX_BY_DIRECTORY = {
|
||||
"AccountingRegisters": "РегистрБухгалтерии",
|
||||
"AccumulationRegisters": "РегистрНакопления",
|
||||
"BusinessProcesses": "БизнесПроцесс",
|
||||
"CalculationRegisters": "РегистрРасчета",
|
||||
"Catalogs": "Справочник",
|
||||
"ChartsOfAccounts": "ПланСчетов",
|
||||
"ChartsOfCalculationTypes": "ПланВидовРасчета",
|
||||
"ChartsOfCharacteristicTypes": "ПланВидовХарактеристик",
|
||||
"DataProcessors": "Обработка",
|
||||
"DocumentJournals": "ЖурналДокументов",
|
||||
"Documents": "Документ",
|
||||
"Enums": "Перечисление",
|
||||
"ExchangePlans": "ПланОбмена",
|
||||
"ExternalDataSources": "ВнешнийИсточникДанных",
|
||||
"InformationRegisters": "РегистрСведений",
|
||||
"Reports": "Отчет",
|
||||
"Tasks": "Задача",
|
||||
}
|
||||
|
||||
|
||||
def _edt_form_context_from_path(source_path: str) -> tuple[str, str] | None:
|
||||
if not source_path or PurePosixPath(source_path).name.casefold() != "form.form":
|
||||
return None
|
||||
parts = PurePosixPath(source_path).parts
|
||||
try:
|
||||
forms_index = parts.index("Forms")
|
||||
except ValueError:
|
||||
forms_index = -1
|
||||
if forms_index > 1 and forms_index + 1 < len(parts):
|
||||
owner_directory = parts[forms_index - 2]
|
||||
owner_name = parts[forms_index - 1]
|
||||
form_name = parts[forms_index + 1]
|
||||
owner_prefix = _EDT_OWNER_PREFIX_BY_DIRECTORY.get(owner_directory)
|
||||
if owner_prefix:
|
||||
return form_name, f"{owner_prefix}.{owner_name}.{form_name}"
|
||||
if len(parts) >= 3 and parts[-3] == "CommonForms":
|
||||
form_name = parts[-2]
|
||||
return form_name, f"ОбщаяФорма.{form_name}"
|
||||
return None
|
||||
|
||||
|
||||
def _xml_attributes(element: ET.Element) -> dict:
|
||||
attributes = dict(element.attrib)
|
||||
for key, value in element.attrib.items():
|
||||
local_key = _local_name(key)
|
||||
attributes.setdefault(local_key, value)
|
||||
if local_key.lower() == "type":
|
||||
attributes.setdefault("control_kind", value.split(":")[-1].split(".")[-1])
|
||||
attribute_role = _xml_attribute_role(element)
|
||||
if attribute_role:
|
||||
attributes.setdefault("attribute_role", attribute_role)
|
||||
@@ -1386,10 +1840,18 @@ def _xml_nested_text_value(element: ET.Element) -> str:
|
||||
return localized.get("ru") or localized.get("ru_RU") or next(iter(localized.values()))
|
||||
if _local_name(element.tag).lower() == "value":
|
||||
return _element_text_content(element)
|
||||
path_segments = [
|
||||
_element_text_content(child)
|
||||
for child in element
|
||||
if _local_name(child.tag).lower() in {"segment", "segments", "pathsegment"}
|
||||
]
|
||||
path_segments = [value for value in path_segments if value]
|
||||
if path_segments:
|
||||
return ".".join(path_segments)
|
||||
values = [
|
||||
_element_text_content(child)
|
||||
for child in element
|
||||
if _local_name(child.tag).lower() in {"value", "text", "строка", "представление"}
|
||||
if _local_name(child.tag).lower() in {"value", "text", "строка", "представление", "caption"}
|
||||
]
|
||||
values = [value for value in values if value]
|
||||
if values:
|
||||
@@ -1463,6 +1925,11 @@ def _read_text_file(path: Path) -> str:
|
||||
|
||||
__all__ = [
|
||||
"COMMON_BRANCH_CHILDREN",
|
||||
"AccessGroup",
|
||||
"AccessModel",
|
||||
"AccessProfile",
|
||||
"AccessRoleAssignment",
|
||||
"AccessUser",
|
||||
"Command",
|
||||
"ConfigurationRoot",
|
||||
"Extension",
|
||||
|
||||
@@ -34,6 +34,8 @@ COMMON_BRANCH_CHILDREN = (
|
||||
"Подписки на события",
|
||||
"Критерии отбора",
|
||||
"Регламентные задания",
|
||||
"Последовательности",
|
||||
"Нумераторы документов",
|
||||
"Функциональные опции",
|
||||
"Параметры функциональных опций",
|
||||
"Определяемые типы",
|
||||
@@ -145,6 +147,7 @@ REPORT_CHILDREN = (
|
||||
|
||||
METADATA_TYPE_SPECS: tuple[MetadataTypeSpec, ...] = (
|
||||
MetadataTypeSpec("COMMON", "Общие", "Общие", "common", COMMON_BRANCH_CHILDREN),
|
||||
MetadataTypeSpec("SUBSYSTEM", "Подсистема", "Подсистемы", "subsystem", ("Состав", "Командный интерфейс", "Права")),
|
||||
MetadataTypeSpec(
|
||||
"COMMON_MODULE",
|
||||
"Общий модуль",
|
||||
@@ -186,8 +189,11 @@ METADATA_TYPE_SPECS: tuple[MetadataTypeSpec, ...] = (
|
||||
MetadataTypeSpec("EXTERNAL_DATA_SOURCE", "Внешний источник данных", "Внешние источники данных", "external-source", ("Таблицы", "Кубы", "Функции", "Формы", "Команды", "Макеты")),
|
||||
MetadataTypeSpec("EXCHANGE_PLAN", "План обмена", "Планы обмена", "exchange-plan", STRUCTURED_OBJECT_CHILDREN + ("Состав",), OBJECT_MODULES),
|
||||
MetadataTypeSpec("EVENT_SUBSCRIPTION", "Подписка на событие", "Подписки на события", "event", ("События",), HANDLER_METHOD),
|
||||
MetadataTypeSpec("ROLE", "Роль", "Роли", "role", ("Права", "Ограничения доступа", "Объекты доступа")),
|
||||
MetadataTypeSpec("EXTENSION", "Расширение конфигурации", "Расширения конфигурации", "extension", ("Объекты расширения", "Заимствованные объекты", "Добавленные реквизиты", "Формы", "Команды", "Проверки совместимости")),
|
||||
MetadataTypeSpec("SCHEDULED_JOB", "Регламентное задание", "Регламентные задания", "scheduled-job", ("Расписание", "Параметры"), ("Метод",)),
|
||||
MetadataTypeSpec("SEQUENCE", "Последовательность", "Последовательности", "sequence", ("Измерения", "Документы", "Границы")),
|
||||
MetadataTypeSpec("DOCUMENT_NUMERATOR", "Нумератор документов", "Нумераторы документов", "numbering", ("Документы", "Периодичность", "Длина номера")),
|
||||
MetadataTypeSpec("SESSION_PARAMETER", "Параметр сеанса", "Параметры сеанса", "parameter"),
|
||||
MetadataTypeSpec("COMMON_ATTRIBUTE", "Общий реквизит", "Общие реквизиты", "attribute"),
|
||||
MetadataTypeSpec("FILTER_CRITERION", "Критерий отбора", "Критерии отбора", "filter"),
|
||||
@@ -217,6 +223,7 @@ METADATA_TYPE_SPECS: tuple[MetadataTypeSpec, ...] = (
|
||||
|
||||
METADATA_TYPE_DESCRIPTIONS = {
|
||||
"COMMON": "Служебная ветка дерева конфигурации, объединяющая общие объекты метаданных.",
|
||||
"SUBSYSTEM": "Подсистема группирует прикладные объекты и участвует в построении командного интерфейса.",
|
||||
"COMMON_MODULE": "Общий модуль содержит процедуры и функции, доступные из разных областей выполнения конфигурации.",
|
||||
"CONSTANT": "Константа хранит единичное значение конфигурации и может иметь формы, команды, права и модуль менеджера.",
|
||||
"CATALOG": "Справочник описывает прикладной список объектов с реквизитами, табличными частями, формами, командами, макетами, правами и предопределенными данными.",
|
||||
@@ -237,8 +244,11 @@ METADATA_TYPE_DESCRIPTIONS = {
|
||||
"EXTERNAL_DATA_SOURCE": "Внешний источник данных описывает подключение к внешним таблицам, кубам и функциям.",
|
||||
"EXCHANGE_PLAN": "План обмена описывает узлы и состав данных для распределенного обмена.",
|
||||
"EVENT_SUBSCRIPTION": "Подписка на событие связывает событие платформы или объекта с обработчиком.",
|
||||
"ROLE": "Роль описывает набор прав доступа к объектам конфигурации и их данным.",
|
||||
"EXTENSION": "Расширение конфигурации содержит добавленные и заимствованные объекты, а также проверки совместимости.",
|
||||
"SCHEDULED_JOB": "Регламентное задание описывает метод, параметры и расписание фонового выполнения.",
|
||||
"SEQUENCE": "Последовательность управляет сквозной последовательностью проведения документов и границами восстановления.",
|
||||
"DOCUMENT_NUMERATOR": "Нумератор документов задает общие правила нумерации для одного или нескольких видов документов.",
|
||||
"SESSION_PARAMETER": "Параметр сеанса задает значение, доступное в течение пользовательского сеанса.",
|
||||
"COMMON_ATTRIBUTE": "Общий реквизит добавляет реквизит сразу к выбранному набору объектов конфигурации.",
|
||||
"FILTER_CRITERION": "Критерий отбора задает состав реквизитов для универсального отбора ссылочных данных.",
|
||||
@@ -282,6 +292,7 @@ METADATA_TYPE_DOCUMENTATION_URLS.update(
|
||||
|
||||
METADATA_TYPE_PROPERTIES: dict[str, tuple[str, ...]] = {
|
||||
"COMMON": ("Состав общих объектов",),
|
||||
"SUBSYSTEM": STANDARD_PROPERTIES + ("Состав", "Включать в командный интерфейс", "Картинка", "Родитель"),
|
||||
"COMMON_MODULE": STANDARD_PROPERTIES + ("Клиент", "Сервер", "Внешнее соединение", "Глобальный", "Вызов сервера", "Повторное использование возвращаемых значений"),
|
||||
"CONSTANT": STANDARD_PROPERTIES + ("Тип значения", "Основная форма", "Форма выбора"),
|
||||
"CATALOG": REFERENCE_OBJECT_PROPERTIES,
|
||||
@@ -302,8 +313,11 @@ METADATA_TYPE_PROPERTIES: dict[str, tuple[str, ...]] = {
|
||||
"EXTERNAL_DATA_SOURCE": STANDARD_PROPERTIES + ("Соединение", "Таблицы", "Кубы", "Функции"),
|
||||
"EXCHANGE_PLAN": DATA_OBJECT_PROPERTIES + ("Состав обмена", "Распределенная ИБ", "Авторегистрация изменений"),
|
||||
"EVENT_SUBSCRIPTION": STANDARD_PROPERTIES + ("Источник", "Событие", "Обработчик", "Перед/после события"),
|
||||
"ROLE": STANDARD_PROPERTIES + ("Права", "RLS", "Ограничения доступа"),
|
||||
"EXTENSION": ("Имя", "Назначение", "Версия", "Режим совместимости", "Заимствованные объекты", "Проверки совместимости"),
|
||||
"SCHEDULED_JOB": STANDARD_PROPERTIES + ("Метод", "Расписание", "Использование", "Параметры", "Предопределенное"),
|
||||
"SEQUENCE": STANDARD_PROPERTIES + ("Документы", "Измерения", "Периодичность", "Заполнение", "Граница"),
|
||||
"DOCUMENT_NUMERATOR": STANDARD_PROPERTIES + ("Длина номера", "Тип номера", "Периодичность", "Документы"),
|
||||
"SESSION_PARAMETER": STANDARD_PROPERTIES + ("Тип значения",),
|
||||
"COMMON_ATTRIBUTE": STANDARD_PROPERTIES + ("Тип значения", "Состав", "Разделение данных", "Автоиспользование"),
|
||||
"FILTER_CRITERION": STANDARD_PROPERTIES + ("Тип значения", "Состав реквизитов"),
|
||||
|
||||
@@ -219,6 +219,70 @@ def test_normalize_edt_project_preserves_source_path_and_common_object_descripti
|
||||
assert common_forms[0].metadata["comment"] == "Используется в подборе товаров"
|
||||
|
||||
|
||||
def test_normalize_edt_project_knows_full_common_metadata_catalog(tmp_path: Path):
|
||||
for file_name, class_name, object_name in [
|
||||
("Продажи.mdo", "Subsystem", "Продажи"),
|
||||
("Менеджер.mdo", "Role", "Менеджер"),
|
||||
("ПроведениеДокументов.mdo", "Sequence", "ПроведениеДокументов"),
|
||||
("ОбщийНумератор.mdo", "DocumentNumerator", "ОбщийНумератор"),
|
||||
("ДоступностьСкидок.mdo", "FunctionalOption", "ДоступностьСкидок"),
|
||||
("ФормаПодбора.mdo", "CommonForm", "ФормаПодбора"),
|
||||
("ПубличныйAPI.mdo", "HTTPService", "ПубличныйAPI"),
|
||||
]:
|
||||
(tmp_path / file_name).write_text(
|
||||
f"""
|
||||
<mdclass:{class_name} xmlns:mdclass="http://g5.1c.ru/v8/dt/metadata/mdclass">
|
||||
<name>{object_name}</name>
|
||||
</mdclass:{class_name}>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
normalized = normalize_one_c_project(tmp_path, project_id="edt-full-common")
|
||||
objects = {
|
||||
item.qualified_name
|
||||
for group in normalized.configuration.groups
|
||||
for item in group.objects
|
||||
}
|
||||
|
||||
assert {
|
||||
"Подсистема.Продажи",
|
||||
"Роль.Менеджер",
|
||||
"Последовательность.ПроведениеДокументов",
|
||||
"НумераторДокументов.ОбщийНумератор",
|
||||
"ФункциональнаяОпция.ДоступностьСкидок",
|
||||
"ОбщаяФорма.ФормаПодбора",
|
||||
"HTTPСервис.ПубличныйAPI",
|
||||
}.issubset(objects)
|
||||
|
||||
|
||||
def test_normalize_project_loads_access_profiles_groups_and_users(tmp_path: Path):
|
||||
xml = tmp_path / "access.xml"
|
||||
xml.write_text(
|
||||
"""
|
||||
<AccessData>
|
||||
<Role name="ЧтениеПродаж" qualifiedName="Роль.ЧтениеПродаж" />
|
||||
<AccessProfile name="МенеджерПродаж">
|
||||
<Role name="ЧтениеПродаж" />
|
||||
</AccessProfile>
|
||||
<AccessGroup name="ОтделПродаж" profile="МенеджерПродаж">
|
||||
<Member user="ivanov" />
|
||||
</AccessGroup>
|
||||
<InfobaseUser name="ivanov" fullName="Иванов Иван" />
|
||||
</AccessData>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
normalized = normalize_one_c_project(tmp_path, project_id="access-data")
|
||||
|
||||
assert normalized.access.profiles[0].name == "МенеджерПродаж"
|
||||
assert normalized.access.profiles[0].roles[0].role_qualified_name == "Роль.ЧтениеПродаж"
|
||||
assert normalized.access.groups[0].profile == "МенеджерПродаж"
|
||||
assert normalized.access.groups[0].users == ["ivanov"]
|
||||
assert normalized.access.users[0].full_name == "Иванов Иван"
|
||||
|
||||
|
||||
def test_normalize_edt_project_preserves_localized_descriptions(tmp_path: Path):
|
||||
catalog = tmp_path / "Контрагенты.mdo"
|
||||
catalog.write_text(
|
||||
|
||||
@@ -78,6 +78,40 @@ _METADATA_OWNER_KINDS = {
|
||||
NodeKind.SCHEDULED_JOB,
|
||||
NodeKind.BUSINESS_PROCESS,
|
||||
NodeKind.TASK,
|
||||
NodeKind.SUBSYSTEM,
|
||||
NodeKind.SEQUENCE,
|
||||
NodeKind.DOCUMENT_NUMERATOR,
|
||||
NodeKind.EVENT_SUBSCRIPTION,
|
||||
NodeKind.SESSION_PARAMETER,
|
||||
NodeKind.COMMON_ATTRIBUTE,
|
||||
NodeKind.FILTER_CRITERION,
|
||||
NodeKind.FUNCTIONAL_OPTION,
|
||||
NodeKind.FUNCTIONAL_OPTION_PARAMETER,
|
||||
NodeKind.DEFINED_TYPE,
|
||||
NodeKind.SETTINGS_STORAGE,
|
||||
NodeKind.COMMON_COMMAND,
|
||||
NodeKind.COMMAND_GROUP,
|
||||
NodeKind.COMMON_FORM,
|
||||
NodeKind.COMMON_LAYOUT,
|
||||
NodeKind.COMMON_PICTURE,
|
||||
NodeKind.WEB_SERVICE,
|
||||
NodeKind.HTTP_SERVICE,
|
||||
NodeKind.WS_REFERENCE,
|
||||
NodeKind.WEBSOCKET_CLIENT,
|
||||
NodeKind.INTEGRATION_SERVICE,
|
||||
NodeKind.BOT,
|
||||
NodeKind.INTERFACE,
|
||||
NodeKind.FULL_TEXT_SEARCH_DICTIONARY,
|
||||
NodeKind.PALETTE_COLOR,
|
||||
NodeKind.STYLE_ITEM,
|
||||
NodeKind.STYLE,
|
||||
NodeKind.LANGUAGE,
|
||||
NodeKind.ACCESS_PROFILE,
|
||||
NodeKind.ACCESS_GROUP,
|
||||
NodeKind.ACCESS_USER,
|
||||
NodeKind.XDTO_PACKAGE,
|
||||
NodeKind.EXTENSION,
|
||||
NodeKind.ROLE,
|
||||
}
|
||||
_PATH_METADATA_ALIASES = {
|
||||
"catalogs": ("Справочник", NodeKind.CATALOG),
|
||||
@@ -120,6 +154,69 @@ _PATH_METADATA_ALIASES = {
|
||||
"бизнеспроцессы": ("БизнесПроцесс", NodeKind.BUSINESS_PROCESS),
|
||||
"tasks": ("Задача", NodeKind.TASK),
|
||||
"задачи": ("Задача", NodeKind.TASK),
|
||||
"subsystems": ("Подсистема", NodeKind.SUBSYSTEM),
|
||||
"подсистемы": ("Подсистема", NodeKind.SUBSYSTEM),
|
||||
"roles": ("Роль", NodeKind.ROLE),
|
||||
"роли": ("Роль", NodeKind.ROLE),
|
||||
"sequences": ("Последовательность", NodeKind.SEQUENCE),
|
||||
"последовательности": ("Последовательность", NodeKind.SEQUENCE),
|
||||
"documentnumerators": ("НумераторДокументов", NodeKind.DOCUMENT_NUMERATOR),
|
||||
"нумераторыдокументов": ("НумераторДокументов", NodeKind.DOCUMENT_NUMERATOR),
|
||||
"eventsubscriptions": ("ПодпискаНаСобытие", NodeKind.EVENT_SUBSCRIPTION),
|
||||
"подпискинасобытия": ("ПодпискаНаСобытие", NodeKind.EVENT_SUBSCRIPTION),
|
||||
"sessionparameters": ("ПараметрСеанса", NodeKind.SESSION_PARAMETER),
|
||||
"параметрысеанса": ("ПараметрСеанса", NodeKind.SESSION_PARAMETER),
|
||||
"commonattributes": ("ОбщийРеквизит", NodeKind.COMMON_ATTRIBUTE),
|
||||
"общиереквизиты": ("ОбщийРеквизит", NodeKind.COMMON_ATTRIBUTE),
|
||||
"filtercriteria": ("КритерийОтбора", NodeKind.FILTER_CRITERION),
|
||||
"критерииотбора": ("КритерийОтбора", NodeKind.FILTER_CRITERION),
|
||||
"functionaloptions": ("ФункциональнаяОпция", NodeKind.FUNCTIONAL_OPTION),
|
||||
"функциональныеопции": ("ФункциональнаяОпция", NodeKind.FUNCTIONAL_OPTION),
|
||||
"functionaloptionsparameters": ("ПараметрФункциональнойОпции", NodeKind.FUNCTIONAL_OPTION_PARAMETER),
|
||||
"параметрыфункциональныхопций": ("ПараметрФункциональнойОпции", NodeKind.FUNCTIONAL_OPTION_PARAMETER),
|
||||
"definedtypes": ("ОпределяемыйТип", NodeKind.DEFINED_TYPE),
|
||||
"определяемыетипы": ("ОпределяемыйТип", NodeKind.DEFINED_TYPE),
|
||||
"settingsstorages": ("ХранилищеНастроек", NodeKind.SETTINGS_STORAGE),
|
||||
"хранилищанастроек": ("ХранилищеНастроек", NodeKind.SETTINGS_STORAGE),
|
||||
"commoncommands": ("ОбщаяКоманда", NodeKind.COMMON_COMMAND),
|
||||
"общиекоманды": ("ОбщаяКоманда", NodeKind.COMMON_COMMAND),
|
||||
"commandgroups": ("ГруппаКоманд", NodeKind.COMMAND_GROUP),
|
||||
"группыкоманд": ("ГруппаКоманд", NodeKind.COMMAND_GROUP),
|
||||
"commonforms": ("ОбщаяФорма", NodeKind.COMMON_FORM),
|
||||
"общиеформы": ("ОбщаяФорма", NodeKind.COMMON_FORM),
|
||||
"commontemplates": ("ОбщийМакет", NodeKind.COMMON_LAYOUT),
|
||||
"commonlayouts": ("ОбщийМакет", NodeKind.COMMON_LAYOUT),
|
||||
"общиемакеты": ("ОбщийМакет", NodeKind.COMMON_LAYOUT),
|
||||
"commonpictures": ("ОбщаяКартинка", NodeKind.COMMON_PICTURE),
|
||||
"общиекартинки": ("ОбщаяКартинка", NodeKind.COMMON_PICTURE),
|
||||
"xdtopackages": ("XDTO", NodeKind.XDTO_PACKAGE),
|
||||
"xdtoпакеты": ("XDTO", NodeKind.XDTO_PACKAGE),
|
||||
"webservices": ("WebСервис", NodeKind.WEB_SERVICE),
|
||||
"webсервисы": ("WebСервис", NodeKind.WEB_SERVICE),
|
||||
"httpservices": ("HTTPСервис", NodeKind.HTTP_SERVICE),
|
||||
"httpсервисы": ("HTTPСервис", NodeKind.HTTP_SERVICE),
|
||||
"wsreferences": ("WSСсылка", NodeKind.WS_REFERENCE),
|
||||
"wsссылки": ("WSСсылка", NodeKind.WS_REFERENCE),
|
||||
"websocketclients": ("WebSocketКлиент", NodeKind.WEBSOCKET_CLIENT),
|
||||
"websocketклиенты": ("WebSocketКлиент", NodeKind.WEBSOCKET_CLIENT),
|
||||
"integrationservices": ("СервисИнтеграции", NodeKind.INTEGRATION_SERVICE),
|
||||
"сервисыинтеграции": ("СервисИнтеграции", NodeKind.INTEGRATION_SERVICE),
|
||||
"bots": ("Бот", NodeKind.BOT),
|
||||
"боты": ("Бот", NodeKind.BOT),
|
||||
"interfaces": ("Интерфейс", NodeKind.INTERFACE),
|
||||
"интерфейсы": ("Интерфейс", NodeKind.INTERFACE),
|
||||
"fulltextsearchdictionaries": ("СловарьПолнотекстовогоПоиска", NodeKind.FULL_TEXT_SEARCH_DICTIONARY),
|
||||
"словариполнотекстовогопоиска": ("СловарьПолнотекстовогоПоиска", NodeKind.FULL_TEXT_SEARCH_DICTIONARY),
|
||||
"palettecolors": ("ЦветПалитры", NodeKind.PALETTE_COLOR),
|
||||
"цветапалитры": ("ЦветПалитры", NodeKind.PALETTE_COLOR),
|
||||
"styleitems": ("ЭлементСтиля", NodeKind.STYLE_ITEM),
|
||||
"элементыстиля": ("ЭлементСтиля", NodeKind.STYLE_ITEM),
|
||||
"styles": ("Стиль", NodeKind.STYLE),
|
||||
"стили": ("Стиль", NodeKind.STYLE),
|
||||
"languages": ("Язык", NodeKind.LANGUAGE),
|
||||
"языки": ("Язык", NodeKind.LANGUAGE),
|
||||
"extensions": ("Расширение", NodeKind.EXTENSION),
|
||||
"расширения": ("Расширение", NodeKind.EXTENSION),
|
||||
}
|
||||
|
||||
|
||||
@@ -199,6 +296,8 @@ def index_project(path: str | Path, *, project_id: str | None = None, structure_
|
||||
command_nodes: list[SemanticNode] = []
|
||||
form_nodes: list[SemanticNode] = []
|
||||
role_rights: list[dict] = []
|
||||
access_role_assignments: list[dict] = []
|
||||
access_group_memberships: list[dict] = []
|
||||
|
||||
for source_file in source_files:
|
||||
text = _read_text_file(source_file)
|
||||
@@ -306,6 +405,12 @@ def index_project(path: str | Path, *, project_id: str | None = None, structure_
|
||||
if xml_object.object_kind == "RIGHT":
|
||||
role_rights.append(xml_object.attributes)
|
||||
continue
|
||||
if xml_object.object_kind == "ACCESS_ROLE_ASSIGNMENT":
|
||||
access_role_assignments.append(xml_object.attributes)
|
||||
continue
|
||||
if xml_object.object_kind == "ACCESS_GROUP_MEMBERSHIP":
|
||||
access_group_memberships.append(xml_object.attributes)
|
||||
continue
|
||||
kind = _xml_node_kind(xml_object.object_kind)
|
||||
if kind is None:
|
||||
continue
|
||||
@@ -348,14 +453,49 @@ def index_project(path: str | Path, *, project_id: str | None = None, structure_
|
||||
NodeKind.SCHEDULED_JOB,
|
||||
NodeKind.BUSINESS_PROCESS,
|
||||
NodeKind.TASK,
|
||||
NodeKind.SUBSYSTEM,
|
||||
NodeKind.SEQUENCE,
|
||||
NodeKind.DOCUMENT_NUMERATOR,
|
||||
NodeKind.EVENT_SUBSCRIPTION,
|
||||
NodeKind.SESSION_PARAMETER,
|
||||
NodeKind.COMMON_ATTRIBUTE,
|
||||
NodeKind.FILTER_CRITERION,
|
||||
NodeKind.FUNCTIONAL_OPTION,
|
||||
NodeKind.FUNCTIONAL_OPTION_PARAMETER,
|
||||
NodeKind.DEFINED_TYPE,
|
||||
NodeKind.SETTINGS_STORAGE,
|
||||
NodeKind.COMMON_COMMAND,
|
||||
NodeKind.COMMAND_GROUP,
|
||||
NodeKind.COMMON_FORM,
|
||||
NodeKind.COMMON_LAYOUT,
|
||||
NodeKind.COMMON_PICTURE,
|
||||
NodeKind.WEB_SERVICE,
|
||||
NodeKind.HTTP_SERVICE,
|
||||
NodeKind.WS_REFERENCE,
|
||||
NodeKind.WEBSOCKET_CLIENT,
|
||||
NodeKind.INTEGRATION_SERVICE,
|
||||
NodeKind.BOT,
|
||||
NodeKind.INTERFACE,
|
||||
NodeKind.FULL_TEXT_SEARCH_DICTIONARY,
|
||||
NodeKind.PALETTE_COLOR,
|
||||
NodeKind.STYLE_ITEM,
|
||||
NodeKind.STYLE,
|
||||
NodeKind.LANGUAGE,
|
||||
NodeKind.XDTO_PACKAGE,
|
||||
NodeKind.EXTENSION,
|
||||
NodeKind.ACCESS_PROFILE,
|
||||
NodeKind.ACCESS_GROUP,
|
||||
NodeKind.ACCESS_USER,
|
||||
NodeKind.ROLE,
|
||||
NodeKind.FORM,
|
||||
NodeKind.TABULAR_SECTION,
|
||||
}:
|
||||
parent_by_prefix[node.qualified_name] = node
|
||||
|
||||
edges.extend(_link_metadata_to_modules(root, module_nodes, metadata_nodes))
|
||||
edges.extend(_link_metadata_to_modules(root, module_nodes, metadata_nodes, form_nodes))
|
||||
edges.extend(_link_role_rights(nodes, role_rights))
|
||||
edges.extend(_link_access_role_assignments(nodes, access_role_assignments))
|
||||
edges.extend(_link_access_group_memberships(nodes, access_group_memberships))
|
||||
edges.extend(_link_scheduled_jobs_to_routines(scheduled_job_nodes, routine_by_name))
|
||||
edges.extend(_link_commands_to_handlers(command_nodes, routine_by_name))
|
||||
edges.extend(_link_forms_to_handlers(form_nodes, routine_by_name))
|
||||
@@ -958,7 +1098,36 @@ def _xml_node_kind(object_kind: str) -> NodeKind | None:
|
||||
"BUSINESS_PROCESS": NodeKind.BUSINESS_PROCESS,
|
||||
"TASK": NodeKind.TASK,
|
||||
"SUBSYSTEM": NodeKind.SUBSYSTEM,
|
||||
"SEQUENCE": NodeKind.SEQUENCE,
|
||||
"DOCUMENT_NUMERATOR": NodeKind.DOCUMENT_NUMERATOR,
|
||||
"EVENT_SUBSCRIPTION": NodeKind.EVENT_SUBSCRIPTION,
|
||||
"SESSION_PARAMETER": NodeKind.SESSION_PARAMETER,
|
||||
"COMMON_ATTRIBUTE": NodeKind.COMMON_ATTRIBUTE,
|
||||
"FILTER_CRITERION": NodeKind.FILTER_CRITERION,
|
||||
"FUNCTIONAL_OPTION": NodeKind.FUNCTIONAL_OPTION,
|
||||
"FUNCTIONAL_OPTION_PARAMETER": NodeKind.FUNCTIONAL_OPTION_PARAMETER,
|
||||
"DEFINED_TYPE": NodeKind.DEFINED_TYPE,
|
||||
"SETTINGS_STORAGE": NodeKind.SETTINGS_STORAGE,
|
||||
"COMMON_COMMAND": NodeKind.COMMON_COMMAND,
|
||||
"COMMAND_GROUP": NodeKind.COMMAND_GROUP,
|
||||
"COMMON_FORM": NodeKind.COMMON_FORM,
|
||||
"COMMON_LAYOUT": NodeKind.COMMON_LAYOUT,
|
||||
"COMMON_PICTURE": NodeKind.COMMON_PICTURE,
|
||||
"WEB_SERVICE": NodeKind.WEB_SERVICE,
|
||||
"HTTP_SERVICE": NodeKind.HTTP_SERVICE,
|
||||
"WS_REFERENCE": NodeKind.WS_REFERENCE,
|
||||
"WEBSOCKET_CLIENT": NodeKind.WEBSOCKET_CLIENT,
|
||||
"INTEGRATION_SERVICE": NodeKind.INTEGRATION_SERVICE,
|
||||
"BOT": NodeKind.BOT,
|
||||
"INTERFACE": NodeKind.INTERFACE,
|
||||
"FULL_TEXT_SEARCH_DICTIONARY": NodeKind.FULL_TEXT_SEARCH_DICTIONARY,
|
||||
"PALETTE_COLOR": NodeKind.PALETTE_COLOR,
|
||||
"STYLE_ITEM": NodeKind.STYLE_ITEM,
|
||||
"STYLE": NodeKind.STYLE,
|
||||
"LANGUAGE": NodeKind.LANGUAGE,
|
||||
"ACCESS_PROFILE": NodeKind.ACCESS_PROFILE,
|
||||
"ACCESS_GROUP": NodeKind.ACCESS_GROUP,
|
||||
"ACCESS_USER": NodeKind.ACCESS_USER,
|
||||
"XDTO_PACKAGE": NodeKind.XDTO_PACKAGE,
|
||||
"EXTENSION": NodeKind.EXTENSION,
|
||||
"LAYOUT": NodeKind.LAYOUT,
|
||||
@@ -983,7 +1152,9 @@ def _xml_edge_kind(kind: NodeKind) -> EdgeKind:
|
||||
return EdgeKind.HAS_TABULAR_SECTION
|
||||
if kind == NodeKind.ROLE:
|
||||
return EdgeKind.HAS_ROLE
|
||||
if kind == NodeKind.FORM_ELEMENT:
|
||||
return EdgeKind.HAS_ELEMENT
|
||||
return EdgeKind.CONTAINS
|
||||
|
||||
|
||||
def _find_xml_parent(parents: dict[str, SemanticNode], qualified_name: str) -> SemanticNode | None:
|
||||
@@ -1025,6 +1196,7 @@ def _link_metadata_to_modules(
|
||||
root: Path,
|
||||
module_nodes: dict[str, SemanticNode],
|
||||
metadata_nodes: list[SemanticNode],
|
||||
form_nodes: list[SemanticNode],
|
||||
) -> list[SemanticEdge]:
|
||||
if not metadata_nodes:
|
||||
return []
|
||||
@@ -1034,6 +1206,7 @@ def _link_metadata_to_modules(
|
||||
(node.kind, _normalize_lookup_key(node.name)): node
|
||||
for node in metadata_nodes
|
||||
}
|
||||
forms_by_qualified = {_normalize_lookup_key(node.qualified_name): node for node in form_nodes}
|
||||
|
||||
edges: list[SemanticEdge] = []
|
||||
for source_path, module in module_nodes.items():
|
||||
@@ -1042,6 +1215,26 @@ def _link_metadata_to_modules(
|
||||
if owner is None:
|
||||
continue
|
||||
line = module.source_ref.line_start or 1
|
||||
module_role = _module_role(source_file)
|
||||
form_name = _form_name_for_module(root, source_file)
|
||||
object_part = _module_object_part(module_role, form_name)
|
||||
module.attributes.update(
|
||||
{
|
||||
"owner_lineage_id": owner.lineage_id,
|
||||
"owner_qualified_name": owner.qualified_name,
|
||||
"owner_kind": owner.kind.value,
|
||||
"object_part": object_part,
|
||||
"module_role": module_role,
|
||||
}
|
||||
)
|
||||
if form_name:
|
||||
module.attributes["form_name"] = form_name
|
||||
edge_attributes = {
|
||||
"link_type": "METADATA_MODULE",
|
||||
"module_role": module_role,
|
||||
"object_part": object_part,
|
||||
"form_name": form_name,
|
||||
}
|
||||
edges.append(
|
||||
_edge(
|
||||
EdgeKind.CONTAINS,
|
||||
@@ -1049,16 +1242,61 @@ def _link_metadata_to_modules(
|
||||
module,
|
||||
source_path,
|
||||
line,
|
||||
{
|
||||
"link_type": "METADATA_MODULE",
|
||||
"module_role": _module_role(source_file),
|
||||
"form_name": _form_name_for_module(root, source_file),
|
||||
},
|
||||
edge_attributes,
|
||||
)
|
||||
)
|
||||
if module_role == "FORM_MODULE" and form_name:
|
||||
form_node = _find_form_node_for_module(owner, form_name, forms_by_qualified)
|
||||
if form_node is not None:
|
||||
module.attributes["form_lineage_id"] = form_node.lineage_id
|
||||
module.attributes["form_qualified_name"] = form_node.qualified_name
|
||||
edges.append(
|
||||
_edge(
|
||||
EdgeKind.CONTAINS,
|
||||
form_node,
|
||||
module,
|
||||
source_path,
|
||||
line,
|
||||
{**edge_attributes, "link_type": "FORM_MODULE"},
|
||||
)
|
||||
)
|
||||
return edges
|
||||
|
||||
|
||||
def _find_form_node_for_module(
|
||||
owner: SemanticNode,
|
||||
form_name: str,
|
||||
forms_by_qualified: dict[str, SemanticNode],
|
||||
) -> SemanticNode | None:
|
||||
candidates = [
|
||||
f"{owner.qualified_name}.{form_name}",
|
||||
f"{owner.qualified_name}.Форма.{form_name}",
|
||||
]
|
||||
for candidate in candidates:
|
||||
form = forms_by_qualified.get(_normalize_lookup_key(candidate))
|
||||
if form is not None:
|
||||
return form
|
||||
suffix = f".{form_name}".casefold()
|
||||
return next(
|
||||
(
|
||||
form
|
||||
for key, form in forms_by_qualified.items()
|
||||
if key.endswith(suffix) and key.startswith(_normalize_lookup_key(owner.qualified_name))
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _module_object_part(module_role: str, form_name: str = "") -> str:
|
||||
return {
|
||||
"OBJECT_MODULE": "object.module",
|
||||
"MANAGER_MODULE": "object.manager",
|
||||
"RECORD_SET_MODULE": "object.record_set",
|
||||
"FORM_MODULE": f"form.{form_name}.module" if form_name else "form.module",
|
||||
"MODULE": "module",
|
||||
}.get(module_role, "module")
|
||||
|
||||
|
||||
def _link_role_rights(nodes: list[SemanticNode], role_rights: list[dict]) -> list[SemanticEdge]:
|
||||
if not role_rights:
|
||||
return []
|
||||
@@ -1094,6 +1332,57 @@ def _link_role_rights(nodes: list[SemanticNode], role_rights: list[dict]) -> lis
|
||||
return edges
|
||||
|
||||
|
||||
def _link_access_role_assignments(nodes: list[SemanticNode], assignments: list[dict]) -> list[SemanticEdge]:
|
||||
if not assignments:
|
||||
return []
|
||||
by_qualified = {_normalize_lookup_key(node.qualified_name): node for node in nodes}
|
||||
by_name_kind = {
|
||||
(node.kind, _normalize_lookup_key(node.name)): node
|
||||
for node in nodes
|
||||
if node.kind in {NodeKind.ACCESS_PROFILE, NodeKind.ACCESS_GROUP, NodeKind.ACCESS_USER, NodeKind.ROLE}
|
||||
}
|
||||
edges: list[SemanticEdge] = []
|
||||
for assignment in assignments:
|
||||
owner_name = str(assignment.get("owner") or assignment.get("profile") or assignment.get("group") or assignment.get("user") or "")
|
||||
role_name = str(assignment.get("role") or assignment.get("Role") or assignment.get("Роль") or "")
|
||||
if not owner_name or not role_name:
|
||||
continue
|
||||
owner = by_qualified.get(_normalize_lookup_key(owner_name)) or next(
|
||||
(
|
||||
by_name_kind.get((kind, _normalize_lookup_key(owner_name)))
|
||||
for kind in (NodeKind.ACCESS_PROFILE, NodeKind.ACCESS_GROUP, NodeKind.ACCESS_USER)
|
||||
if by_name_kind.get((kind, _normalize_lookup_key(owner_name))) is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
role = by_qualified.get(_normalize_lookup_key(role_name)) or by_qualified.get(_normalize_lookup_key(f"Роль.{role_name}")) or by_name_kind.get((NodeKind.ROLE, _normalize_lookup_key(role_name)))
|
||||
if owner is None or role is None:
|
||||
continue
|
||||
edges.append(_edge(EdgeKind.ASSIGNS_ROLE, owner, role, owner.source_ref.source_path, 1, dict(assignment)))
|
||||
return edges
|
||||
|
||||
|
||||
def _link_access_group_memberships(nodes: list[SemanticNode], memberships: list[dict]) -> list[SemanticEdge]:
|
||||
if not memberships:
|
||||
return []
|
||||
by_qualified = {_normalize_lookup_key(node.qualified_name): node for node in nodes}
|
||||
by_name_kind = {
|
||||
(node.kind, _normalize_lookup_key(node.name)): node
|
||||
for node in nodes
|
||||
if node.kind in {NodeKind.ACCESS_GROUP, NodeKind.ACCESS_USER}
|
||||
}
|
||||
edges: list[SemanticEdge] = []
|
||||
for membership in memberships:
|
||||
group_name = str(membership.get("group") or membership.get("Group") or membership.get("Группа") or "")
|
||||
user_name = str(membership.get("user") or membership.get("User") or membership.get("Пользователь") or "")
|
||||
group = by_qualified.get(_normalize_lookup_key(group_name)) or by_name_kind.get((NodeKind.ACCESS_GROUP, _normalize_lookup_key(group_name)))
|
||||
user = by_qualified.get(_normalize_lookup_key(user_name)) or by_name_kind.get((NodeKind.ACCESS_USER, _normalize_lookup_key(user_name)))
|
||||
if group is None or user is None:
|
||||
continue
|
||||
edges.append(_edge(EdgeKind.MEMBER_OF, user, group, user.source_ref.source_path, 1, dict(membership)))
|
||||
return edges
|
||||
|
||||
|
||||
def _link_scheduled_jobs_to_routines(
|
||||
scheduled_jobs: list[SemanticNode],
|
||||
routine_by_name: dict[str, SemanticNode],
|
||||
|
||||
@@ -70,6 +70,70 @@ def test_index_project_extracts_1c_metadata_objects(tmp_path: Path):
|
||||
assert any(edge.kind == EdgeKind.HAS_COMMAND for edge in snapshot.edges)
|
||||
|
||||
|
||||
def test_index_project_keeps_extended_1c_metadata_objects(tmp_path: Path):
|
||||
xml = tmp_path / "metadata.xml"
|
||||
xml.write_text(
|
||||
"""
|
||||
<Configuration>
|
||||
<Subsystem name="Продажи" qualifiedName="Подсистема.Продажи" />
|
||||
<Role name="Менеджер" qualifiedName="Роль.Менеджер" />
|
||||
<Sequence name="ПроведениеДокументов" qualifiedName="Последовательность.ПроведениеДокументов" />
|
||||
<DocumentNumerator name="ОбщийНумератор" qualifiedName="НумераторДокументов.ОбщийНумератор" />
|
||||
<CommonForm name="ФормаПодбора" qualifiedName="ОбщаяФорма.ФормаПодбора" />
|
||||
<FunctionalOption name="ДоступностьСкидок" qualifiedName="ФункциональнаяОпция.ДоступностьСкидок" />
|
||||
<WebService name="Обмен" qualifiedName="WebСервис.Обмен" />
|
||||
<WSReference name="ВнешнийСервис" qualifiedName="WSСсылка.ВнешнийСервис" />
|
||||
<WebSocketClient name="Чат" qualifiedName="WebSocketКлиент.Чат" />
|
||||
<IntegrationService name="Интеграция" qualifiedName="СервисИнтеграции.Интеграция" />
|
||||
</Configuration>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
snapshot = index_project(tmp_path, project_id="extended-metadata")
|
||||
by_kind = {node.kind for node in snapshot.nodes}
|
||||
|
||||
assert {
|
||||
NodeKind.SUBSYSTEM,
|
||||
NodeKind.ROLE,
|
||||
NodeKind.SEQUENCE,
|
||||
NodeKind.DOCUMENT_NUMERATOR,
|
||||
NodeKind.COMMON_FORM,
|
||||
NodeKind.FUNCTIONAL_OPTION,
|
||||
NodeKind.WEB_SERVICE,
|
||||
NodeKind.WS_REFERENCE,
|
||||
NodeKind.WEBSOCKET_CLIENT,
|
||||
NodeKind.INTEGRATION_SERVICE,
|
||||
}.issubset(by_kind)
|
||||
|
||||
|
||||
def test_index_project_links_access_profiles_groups_and_users(tmp_path: Path):
|
||||
xml = tmp_path / "access.xml"
|
||||
xml.write_text(
|
||||
"""
|
||||
<AccessData>
|
||||
<Role name="ЧтениеПродаж" qualifiedName="Роль.ЧтениеПродаж" />
|
||||
<AccessProfile name="МенеджерПродаж">
|
||||
<Role name="ЧтениеПродаж" />
|
||||
</AccessProfile>
|
||||
<AccessGroup name="ОтделПродаж" profile="МенеджерПродаж">
|
||||
<Member user="ivanov" />
|
||||
</AccessGroup>
|
||||
<InfobaseUser name="ivanov" fullName="Иванов Иван" />
|
||||
</AccessData>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
snapshot = index_project(tmp_path, project_id="access-graph")
|
||||
|
||||
assert any(node.kind == NodeKind.ACCESS_PROFILE and node.name == "МенеджерПродаж" for node in snapshot.nodes)
|
||||
assert any(node.kind == NodeKind.ACCESS_GROUP and node.name == "ОтделПродаж" for node in snapshot.nodes)
|
||||
assert any(node.kind == NodeKind.ACCESS_USER and node.name == "ivanov" for node in snapshot.nodes)
|
||||
assert any(edge.kind == EdgeKind.ASSIGNS_ROLE for edge in snapshot.edges)
|
||||
assert any(edge.kind == EdgeKind.MEMBER_OF for edge in snapshot.edges)
|
||||
|
||||
|
||||
def test_index_project_remaps_edges_from_duplicate_metadata_nodes(tmp_path: Path):
|
||||
first = tmp_path / "first.xml"
|
||||
first.write_text(
|
||||
@@ -346,6 +410,65 @@ def test_index_project_links_form_command_to_handler(tmp_path: Path):
|
||||
assert any(edge.kind == EdgeKind.HANDLES for edge in snapshot.edges)
|
||||
|
||||
|
||||
def test_index_project_extracts_managed_form_items_without_layouts(tmp_path: Path):
|
||||
xml = tmp_path / "form.xml"
|
||||
xml.write_text(
|
||||
"""
|
||||
<Form name="ФормаДокумента" qualifiedName="Документ.Заказ.ФормаДокумента">
|
||||
<items xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="form:FormGroup" name="Основное">
|
||||
<caption>Основное</caption>
|
||||
<items xsi:type="form:FormField" name="Номер">
|
||||
<caption>Номер</caption>
|
||||
<dataPath>Объект.Номер</dataPath>
|
||||
</items>
|
||||
</items>
|
||||
<Layout name="ПечатнаяФорма" qualifiedName="Документ.Заказ.ФормаДокумента.ПечатнаяФорма" />
|
||||
</Form>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
snapshot = index_project(tmp_path, project_id="ui-form-items")
|
||||
|
||||
form = form_semantics(snapshot)[0]
|
||||
assert [element.name for element in form.elements] == ["Основное", "Номер"]
|
||||
assert all(element.kind == NodeKind.FORM_ELEMENT for element in form.elements)
|
||||
assert form.elements[1].attributes["dataPath"] == "Объект.Номер"
|
||||
assert not any(element.name == "ПечатнаяФорма" for element in form.elements)
|
||||
|
||||
|
||||
def test_index_project_extracts_edt_form_items_from_form_file_path(tmp_path: Path):
|
||||
form_dir = tmp_path / "src" / "Catalogs" / "ВидыЗаказовПокупателей" / "Forms" / "ФормаСписка"
|
||||
form_dir.mkdir(parents=True)
|
||||
(form_dir / "Form.form").write_text(
|
||||
"""
|
||||
<form:Form xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:form="http://g5.1c.ru/v8/dt/form">
|
||||
<items xsi:type="form:Table">
|
||||
<name>Список</name>
|
||||
<dataPath xsi:type="form:DataPath">
|
||||
<segments>Список</segments>
|
||||
</dataPath>
|
||||
<items xsi:type="form:FormField">
|
||||
<name>Наименование</name>
|
||||
<dataPath xsi:type="form:DataPath">
|
||||
<segments>Список.Description</segments>
|
||||
</dataPath>
|
||||
</items>
|
||||
</items>
|
||||
</form:Form>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
snapshot = index_project(tmp_path, project_id="edt-form-items")
|
||||
|
||||
form = next(item for item in form_semantics(snapshot) if item.form.name == "ФормаСписка")
|
||||
assert form.form.qualified_name == "Справочник.ВидыЗаказовПокупателей.ФормаСписка"
|
||||
assert [element.name for element in form.elements] == ["Список", "Наименование"]
|
||||
assert form.elements[0].attributes["control_kind"] == "Table"
|
||||
assert form.elements[1].attributes["dataPath"] == "Список.Description"
|
||||
|
||||
|
||||
def test_index_project_links_form_events_to_handlers(tmp_path: Path):
|
||||
xml = tmp_path / "form.xml"
|
||||
xml.write_text(
|
||||
|
||||
@@ -26,6 +26,35 @@ class NodeKind(str, Enum):
|
||||
BUSINESS_PROCESS = "BUSINESS_PROCESS"
|
||||
TASK = "TASK"
|
||||
SUBSYSTEM = "SUBSYSTEM"
|
||||
SEQUENCE = "SEQUENCE"
|
||||
DOCUMENT_NUMERATOR = "DOCUMENT_NUMERATOR"
|
||||
EVENT_SUBSCRIPTION = "EVENT_SUBSCRIPTION"
|
||||
SESSION_PARAMETER = "SESSION_PARAMETER"
|
||||
COMMON_ATTRIBUTE = "COMMON_ATTRIBUTE"
|
||||
FILTER_CRITERION = "FILTER_CRITERION"
|
||||
FUNCTIONAL_OPTION = "FUNCTIONAL_OPTION"
|
||||
FUNCTIONAL_OPTION_PARAMETER = "FUNCTIONAL_OPTION_PARAMETER"
|
||||
DEFINED_TYPE = "DEFINED_TYPE"
|
||||
SETTINGS_STORAGE = "SETTINGS_STORAGE"
|
||||
COMMON_COMMAND = "COMMON_COMMAND"
|
||||
COMMAND_GROUP = "COMMAND_GROUP"
|
||||
COMMON_FORM = "COMMON_FORM"
|
||||
COMMON_LAYOUT = "COMMON_LAYOUT"
|
||||
COMMON_PICTURE = "COMMON_PICTURE"
|
||||
WEB_SERVICE = "WEB_SERVICE"
|
||||
WS_REFERENCE = "WS_REFERENCE"
|
||||
WEBSOCKET_CLIENT = "WEBSOCKET_CLIENT"
|
||||
INTEGRATION_SERVICE = "INTEGRATION_SERVICE"
|
||||
BOT = "BOT"
|
||||
INTERFACE = "INTERFACE"
|
||||
FULL_TEXT_SEARCH_DICTIONARY = "FULL_TEXT_SEARCH_DICTIONARY"
|
||||
PALETTE_COLOR = "PALETTE_COLOR"
|
||||
STYLE_ITEM = "STYLE_ITEM"
|
||||
STYLE = "STYLE"
|
||||
LANGUAGE = "LANGUAGE"
|
||||
ACCESS_PROFILE = "ACCESS_PROFILE"
|
||||
ACCESS_GROUP = "ACCESS_GROUP"
|
||||
ACCESS_USER = "ACCESS_USER"
|
||||
HTTP_SERVICE = "HTTP_SERVICE"
|
||||
XDTO_PACKAGE = "XDTO_PACKAGE"
|
||||
EXTENSION = "EXTENSION"
|
||||
@@ -57,3 +86,5 @@ class EdgeKind(str, Enum):
|
||||
RUNS = "RUNS"
|
||||
USES_INTEGRATION = "USES_INTEGRATION"
|
||||
HANDLES = "HANDLES"
|
||||
ASSIGNS_ROLE = "ASSIGNS_ROLE"
|
||||
MEMBER_OF = "MEMBER_OF"
|
||||
|
||||
@@ -19,15 +19,20 @@ def form_semantics(snapshot: SirSnapshot) -> list[FormSemantics]:
|
||||
for node in snapshot.nodes
|
||||
if node.kind == NodeKind.FORM
|
||||
}
|
||||
element_children: dict[str, list[SemanticNode]] = {}
|
||||
for edge in snapshot.edges:
|
||||
form = forms.get(edge.source_lineage)
|
||||
target = nodes.get(edge.target_lineage)
|
||||
if form is None or target is None:
|
||||
if target is None:
|
||||
continue
|
||||
if edge.kind == EdgeKind.HAS_ELEMENT and target.kind == NodeKind.FORM_ELEMENT:
|
||||
element_children.setdefault(edge.source_lineage, []).append(target)
|
||||
if form is None:
|
||||
continue
|
||||
if edge.kind == EdgeKind.HAS_COMMAND:
|
||||
form.commands.append(target)
|
||||
elif edge.kind == EdgeKind.HAS_ELEMENT:
|
||||
form.elements.append(target)
|
||||
for form in forms.values():
|
||||
form.elements.extend(_flatten_form_elements(form.form.lineage_id, element_children))
|
||||
command_to_form = {
|
||||
command.lineage_id: form
|
||||
for form in forms.values()
|
||||
@@ -43,4 +48,20 @@ def form_semantics(snapshot: SirSnapshot) -> list[FormSemantics]:
|
||||
return sorted(forms.values(), key=lambda item: item.form.qualified_name)
|
||||
|
||||
|
||||
def _flatten_form_elements(root_lineage: str, element_children: dict[str, list[SemanticNode]]) -> list[SemanticNode]:
|
||||
result: list[SemanticNode] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def visit(parent_lineage: str) -> None:
|
||||
for element in element_children.get(parent_lineage, []):
|
||||
if element.lineage_id in seen:
|
||||
continue
|
||||
seen.add(element.lineage_id)
|
||||
result.append(element)
|
||||
visit(element.lineage_id)
|
||||
|
||||
visit(root_lineage)
|
||||
return result
|
||||
|
||||
|
||||
__all__ = ["FormSemantics", "form_semantics"]
|
||||
|
||||
@@ -694,6 +694,100 @@ function Export-CfOrCfeFromInfobase {
|
||||
return $exportRoot
|
||||
}
|
||||
|
||||
function Convert-LocalCfOrCfeToMetadataExport {
|
||||
param([object]$Job, [string[]]$PlatformBins)
|
||||
$payloadPath = [string]$Job.local_path
|
||||
if ([string]::IsNullOrWhiteSpace($payloadPath)) {
|
||||
throw "local_path is required for direct CF/CFE conversion."
|
||||
}
|
||||
if (!(Test-Path -LiteralPath $payloadPath)) {
|
||||
throw "Local CF/CFE path not found on agent machine: $payloadPath"
|
||||
}
|
||||
|
||||
$designerPath = Get-DesignerBinPath -Job $Job -PlatformBins $PlatformBins
|
||||
$workRoot = Join-Path $env:TEMP "sfera-agent"
|
||||
$exportRoot = Join-Path $workRoot "$($Job.job_id)-local-binary"
|
||||
if (Test-Path -LiteralPath $exportRoot) { Remove-Item -LiteralPath $exportRoot -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force -Path $exportRoot | Out-Null
|
||||
|
||||
$builderInfobase = Join-Path $exportRoot "builder-infobase"
|
||||
$createLog = Join-Path $exportRoot "create-builder-infobase.log"
|
||||
Invoke-1CCommand `
|
||||
-PlatformPath $designerPath `
|
||||
-Arguments @("CREATEINFOBASE", "File=$builderInfobase;") `
|
||||
-LogPath $createLog `
|
||||
-JobId $Job.job_id `
|
||||
-ActionTitle "1C CREATEINFOBASE for local CF/CFE conversion" `
|
||||
-TimeoutSeconds 180
|
||||
|
||||
$builderArgs = @("/F", $builderInfobase)
|
||||
$sourceKind = [string]$Job.source
|
||||
$fileName = [System.IO.Path]::GetFileName($payloadPath)
|
||||
$artifactsRoot = Join-Path $exportRoot "artifacts"
|
||||
New-Item -ItemType Directory -Force -Path $artifactsRoot | Out-Null
|
||||
Copy-Item -LiteralPath $payloadPath -Destination (Join-Path $artifactsRoot $fileName) -Force
|
||||
|
||||
if ($sourceKind -eq "CF_FILE") {
|
||||
$loadLog = Join-Path $exportRoot "designer-loadcfg-local-cf.log"
|
||||
Invoke-DesignerCommand `
|
||||
-DesignerPath $designerPath `
|
||||
-Arguments (@($builderArgs) + @("/LoadCfg", $payloadPath)) `
|
||||
-LogPath $loadLog `
|
||||
-JobId $Job.job_id `
|
||||
-ActionTitle "1C LoadCfg local CF" `
|
||||
-TimeoutSeconds 180
|
||||
|
||||
$metadataRoot = Join-Path $exportRoot "configuration"
|
||||
New-Item -ItemType Directory -Force -Path $metadataRoot | Out-Null
|
||||
$metadataLog = Join-Path $exportRoot "designer-dumpconfigtofiles-local-cf.log"
|
||||
Invoke-DesignerCommand `
|
||||
-DesignerPath $designerPath `
|
||||
-Arguments (@($builderArgs) + @("/DumpConfigToFiles", $metadataRoot, "-Format", "Hierarchical")) `
|
||||
-LogPath $metadataLog `
|
||||
-JobId $Job.job_id `
|
||||
-ActionTitle "1C DumpConfigToFiles from local CF" `
|
||||
-TimeoutSeconds 180
|
||||
Copy-Item -LiteralPath $payloadPath -Destination (Join-Path $metadataRoot $fileName) -Force
|
||||
Send-JobLogs -JobId $Job.job_id -Logs @("Local .cf converted to metadata export for server-side parsing.")
|
||||
return $exportRoot
|
||||
}
|
||||
|
||||
if ($sourceKind -eq "CFE_FILE") {
|
||||
$extensionName = Get-JobMetadataValue -Job $Job -Key "one_c_extension"
|
||||
if ([string]::IsNullOrWhiteSpace($extensionName)) {
|
||||
$extensionName = [System.IO.Path]::GetFileNameWithoutExtension($payloadPath)
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($extensionName)) {
|
||||
throw "Extension name is required for local CFE conversion."
|
||||
}
|
||||
|
||||
$loadLog = Join-Path $exportRoot "designer-loadcfg-local-cfe.log"
|
||||
Invoke-DesignerCommand `
|
||||
-DesignerPath $designerPath `
|
||||
-Arguments (@($builderArgs) + @("/LoadCfg", $payloadPath, "-Extension", $extensionName, "/UpdateDBCfg")) `
|
||||
-LogPath $loadLog `
|
||||
-JobId $Job.job_id `
|
||||
-ActionTitle "1C LoadCfg local CFE" `
|
||||
-TimeoutSeconds 180
|
||||
|
||||
$metadataRoot = Join-Path $exportRoot "extension"
|
||||
New-Item -ItemType Directory -Force -Path $metadataRoot | Out-Null
|
||||
$metadataLog = Join-Path $exportRoot "designer-dumpconfigtofiles-local-cfe.log"
|
||||
Invoke-DesignerCommand `
|
||||
-DesignerPath $designerPath `
|
||||
-Arguments (@($builderArgs) + @("/DumpConfigToFiles", $metadataRoot, "-Format", "Hierarchical", "-Extension", $extensionName)) `
|
||||
-LogPath $metadataLog `
|
||||
-JobId $Job.job_id `
|
||||
-ActionTitle "1C DumpConfigToFiles from local CFE" `
|
||||
-TimeoutSeconds 180
|
||||
Copy-Item -LiteralPath $payloadPath -Destination (Join-Path $metadataRoot $fileName) -Force
|
||||
Send-JobLogs -JobId $Job.job_id -Logs @("Local .cfe converted to metadata export for server-side parsing.")
|
||||
return $exportRoot
|
||||
}
|
||||
|
||||
throw "Unsupported source for local CF/CFE conversion: $sourceKind"
|
||||
}
|
||||
|
||||
function Install-SferaExtensionJob {
|
||||
param([object]$Job, [string[]]$PlatformBins)
|
||||
$workRoot = Join-Path $env:TEMP "sfera-agent"
|
||||
@@ -1138,9 +1232,13 @@ while ($true) {
|
||||
continue
|
||||
}
|
||||
$payloadPath = $job.local_path
|
||||
if (($job.source -eq "CF_FILE") -or (($job.source -eq "CFE_FILE") -and [string]::IsNullOrWhiteSpace($payloadPath))) {
|
||||
if (($job.source -eq "CF_FILE") -or ($job.source -eq "CFE_FILE")) {
|
||||
if (![string]::IsNullOrWhiteSpace($payloadPath)) {
|
||||
$payloadPath = Convert-LocalCfOrCfeToMetadataExport -Job $job -PlatformBins $platformBins
|
||||
} else {
|
||||
$payloadPath = Export-CfOrCfeFromInfobase -Job $job -PlatformBins $platformBins
|
||||
}
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($payloadPath)) {
|
||||
throw "Job does not contain local_path or enough 1C infobase settings for agent export."
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ dependencies = [
|
||||
"sfera-ui-semantics",
|
||||
"smbprotocol>=1.15",
|
||||
"uvicorn>=0.30",
|
||||
"python-multipart>=0.0.20",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from api_server.import_source_models import ImportMode, ImportSourceKind
|
||||
|
||||
|
||||
class AgentImportJobStatus(str, Enum):
|
||||
QUEUED = "QUEUED"
|
||||
RUNNING = "RUNNING"
|
||||
SUCCEEDED = "SUCCEEDED"
|
||||
FAILED = "FAILED"
|
||||
CANCELLED = "CANCELLED"
|
||||
|
||||
|
||||
class AgentImportJobRequest(BaseModel):
|
||||
agent_id: str
|
||||
source: ImportSourceKind
|
||||
local_path: str | None = None
|
||||
bin_path: str | None = None
|
||||
infobase: str | None = None
|
||||
credentials_ref: str | None = None
|
||||
mode: ImportMode = ImportMode.FULL_REPLACE
|
||||
metadata: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AgentImportJob(BaseModel):
|
||||
job_id: str
|
||||
project_id: str
|
||||
agent_id: str
|
||||
source: ImportSourceKind
|
||||
mode: ImportMode = ImportMode.FULL_REPLACE
|
||||
status: AgentImportJobStatus = AgentImportJobStatus.QUEUED
|
||||
local_path: str | None = None
|
||||
bin_path: str | None = None
|
||||
infobase: str | None = None
|
||||
credentials_ref: str | None = None
|
||||
metadata: dict = Field(default_factory=dict)
|
||||
created_at: str
|
||||
updated_at: str
|
||||
claimed_at: str | None = None
|
||||
completed_at: str | None = None
|
||||
server_path: str | None = None
|
||||
logs: list[str] = Field(default_factory=list)
|
||||
error: str | None = None
|
||||
import_summary: dict | None = None
|
||||
|
||||
|
||||
class AgentImportJobResult(BaseModel):
|
||||
status: AgentImportJobStatus = AgentImportJobStatus.SUCCEEDED
|
||||
server_path: str | None = None
|
||||
logs: list[str] = Field(default_factory=list)
|
||||
error: str | None = None
|
||||
metadata: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AgentImportJobLogRequest(BaseModel):
|
||||
logs: list[str] = Field(default_factory=list)
|
||||
status: AgentImportJobStatus | None = None
|
||||
|
||||
|
||||
class AgentBrowseRequestStatus(str, Enum):
|
||||
QUEUED = "QUEUED"
|
||||
RUNNING = "RUNNING"
|
||||
SUCCEEDED = "SUCCEEDED"
|
||||
FAILED = "FAILED"
|
||||
|
||||
|
||||
class AgentBrowseRequestCreate(BaseModel):
|
||||
agent_id: str
|
||||
path: str | None = None
|
||||
|
||||
|
||||
class AgentFolderEntry(BaseModel):
|
||||
name: str
|
||||
path: str
|
||||
is_directory: bool = True
|
||||
|
||||
|
||||
class AgentBrowseRequest(BaseModel):
|
||||
request_id: str
|
||||
agent_id: str
|
||||
path: str | None = None
|
||||
status: AgentBrowseRequestStatus = AgentBrowseRequestStatus.QUEUED
|
||||
created_at: str
|
||||
updated_at: str
|
||||
claimed_at: str | None = None
|
||||
completed_at: str | None = None
|
||||
entries: list[AgentFolderEntry] = Field(default_factory=list)
|
||||
parent_path: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class AgentBrowseResult(BaseModel):
|
||||
status: AgentBrowseRequestStatus = AgentBrowseRequestStatus.SUCCEEDED
|
||||
entries: list[AgentFolderEntry] = Field(default_factory=list)
|
||||
parent_path: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class AgentHeartbeatRequest(BaseModel):
|
||||
agent_id: str
|
||||
host: str | None = None
|
||||
user: str | None = None
|
||||
version: str | None = None
|
||||
started_at: str | None = None
|
||||
network_roots: list[str] = Field(default_factory=list)
|
||||
platform_bins: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentStatus(BaseModel):
|
||||
agent_id: str
|
||||
status: str = "offline"
|
||||
last_seen_at: str | None = None
|
||||
host: str | None = None
|
||||
user: str | None = None
|
||||
version: str | None = None
|
||||
started_at: str | None = None
|
||||
network_roots: list[str] = Field(default_factory=list)
|
||||
platform_bins: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentProjectConfig(BaseModel):
|
||||
project_id: str
|
||||
project_name: str
|
||||
one_c_bin: str | None = None
|
||||
edt_path: str | None = None
|
||||
default_local_path: str | None = None
|
||||
network_roots: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentConfigResponse(BaseModel):
|
||||
agent_id: str
|
||||
poll_seconds: int = 5
|
||||
projects: list[AgentProjectConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ServerBrowseResponse(BaseModel):
|
||||
path: str
|
||||
parent_path: str | None = None
|
||||
entries: list[AgentFolderEntry] = Field(default_factory=list)
|
||||
error: str | None = None
|
||||
@@ -0,0 +1,751 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from one_c_normalizer import NormalizedProject, normalize_one_c_project
|
||||
from semantic_kernel import index_project
|
||||
from sir import SirSnapshot, snapshot_to_json
|
||||
|
||||
|
||||
AI_STRUCTURE_VERSION = "1.0"
|
||||
_PARSEABLE_SUFFIXES = {".xml", ".mdo", ".bsl"}
|
||||
_BINARY_1C_SUFFIXES = {".cf", ".cfe"}
|
||||
_CODEX_SOURCE_SUFFIXES = {".xml", ".mdo", ".bsl", ".json", ".txt"}
|
||||
_MAX_CODEX_SOURCE_FILE_BYTES = 2_000_000
|
||||
_ROOT_XML_NAMES = {"metadata.xml", "configuration.xml"}
|
||||
|
||||
|
||||
def prepare_ai_structure(
|
||||
*,
|
||||
project_id: str,
|
||||
input_path: Path,
|
||||
output_path: Path,
|
||||
structure_only: bool = False,
|
||||
display_input_path: str | None = None,
|
||||
display_output_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not input_path.exists():
|
||||
raise FileNotFoundError(f"Входная папка не найдена: {input_path}")
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
files = _inventory(input_path)
|
||||
parseable = any(Path(item["relative_path"]).suffix.casefold() in _PARSEABLE_SUFFIXES for item in files)
|
||||
binaries = [item for item in files if Path(item["relative_path"]).suffix.casefold() in _BINARY_1C_SUFFIXES]
|
||||
diagnostics: list[str] = []
|
||||
snapshot: SirSnapshot | None = None
|
||||
normalized: NormalizedProject | None = None
|
||||
if parseable:
|
||||
snapshot = index_project(input_path, project_id=project_id, structure_only=structure_only)
|
||||
try:
|
||||
normalized = normalize_one_c_project(input_path, project_id=project_id)
|
||||
except Exception as error:
|
||||
diagnostics.append(f"Не удалось построить NormalizedProject: {error}")
|
||||
elif binaries:
|
||||
diagnostics.append(
|
||||
"Во входной папке есть только бинарные файлы .cf/.cfe. Для серверной подготовки структуры нужен "
|
||||
"экспорт Designer DumpConfigToFiles или выгрузка через Windows Agent перед семантической индексацией."
|
||||
)
|
||||
else:
|
||||
diagnostics.append("Во входной папке не найдены файлы метаданных 1С, XML, BSL или бинарные .cf/.cfe.")
|
||||
|
||||
codex_root = output_path / _codex_folder_name(project_id)
|
||||
manifest = _manifest(
|
||||
project_id,
|
||||
display_input_path or str(input_path),
|
||||
display_output_path or str(output_path),
|
||||
_join_display_path(display_output_path, codex_root.name) if display_output_path else str(codex_root),
|
||||
files,
|
||||
snapshot,
|
||||
normalized,
|
||||
diagnostics,
|
||||
binaries,
|
||||
_source_layout_summary(input_path),
|
||||
_source_preview_summary(input_path),
|
||||
)
|
||||
_write_json(output_path / "manifest.json", manifest)
|
||||
_write_json(output_path / "source_inventory.json", {"files": files})
|
||||
_write_json(output_path / "source_preview.json", manifest.get("source_preview") or [])
|
||||
if snapshot is not None:
|
||||
(output_path / "sir_snapshot.json").write_bytes(snapshot_to_json(snapshot))
|
||||
_write_json(output_path / "ai_objects.json", _ai_objects(snapshot))
|
||||
_write_json(output_path / "ai_modules.json", _ai_modules(snapshot))
|
||||
_write_json(output_path / "ai_edges.json", [edge.model_dump(mode="json") for edge in snapshot.edges])
|
||||
if normalized is not None:
|
||||
_write_json(output_path / "normalized_project.json", normalized.model_dump(mode="json"))
|
||||
_write_json(output_path / "project_layout.json", manifest.get("source_layout") or {})
|
||||
_write_json(output_path / "compact_objects.json", _compact_objects(normalized))
|
||||
_write_json(output_path / "compact_modules.json", _compact_modules(normalized))
|
||||
_write_text(output_path / "ai_context.md", _ai_context_markdown(manifest, snapshot, normalized))
|
||||
_write_text(output_path / "export_plan.md", _export_plan_markdown(project_id, input_path, output_path, binaries, parseable))
|
||||
_write_codex_package(codex_root, input_path, manifest, files, snapshot, normalized, binaries, parseable)
|
||||
return manifest
|
||||
|
||||
|
||||
def _inventory(root: Path) -> list[dict[str, Any]]:
|
||||
paths = [root] if root.is_file() else sorted(path for path in root.rglob("*") if path.is_file())
|
||||
return [
|
||||
{
|
||||
"relative_path": path.name if root.is_file() else path.relative_to(root).as_posix(),
|
||||
"suffix": path.suffix.casefold(),
|
||||
"size": path.stat().st_size,
|
||||
}
|
||||
for path in paths
|
||||
]
|
||||
|
||||
|
||||
def _manifest(
|
||||
project_id: str,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
codex_root: str,
|
||||
files: list[dict[str, Any]],
|
||||
snapshot: SirSnapshot | None,
|
||||
normalized: NormalizedProject | None,
|
||||
diagnostics: list[str],
|
||||
binaries: list[dict[str, Any]],
|
||||
source_layout: dict[str, Any],
|
||||
source_preview: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"version": AI_STRUCTURE_VERSION,
|
||||
"project_id": project_id,
|
||||
"input_path": input_path,
|
||||
"output_path": output_path,
|
||||
"codex_package_path": codex_root,
|
||||
"codex_package_folder": Path(codex_root).name if not codex_root.startswith("\\\\") else codex_root.rstrip("\\").rsplit("\\", 1)[-1],
|
||||
"status": "ready" if snapshot is not None or normalized is not None else "export_required",
|
||||
"files_count": len(files),
|
||||
"binary_1c_files": binaries,
|
||||
"source_layout": source_layout,
|
||||
"source_preview": source_preview,
|
||||
"artifacts": _artifacts(snapshot, normalized),
|
||||
"snapshot": None
|
||||
if snapshot is None
|
||||
else {
|
||||
"snapshot_id": snapshot.snapshot_id,
|
||||
"snapshot_hash": snapshot.snapshot_hash,
|
||||
"nodes": len(snapshot.nodes),
|
||||
"edges": len(snapshot.edges),
|
||||
"diagnostics": len(snapshot.diagnostics),
|
||||
},
|
||||
"normalized": None
|
||||
if normalized is None
|
||||
else {
|
||||
"objects": sum(len(group.objects) for group in normalized.configuration.groups),
|
||||
"groups": len(normalized.configuration.groups),
|
||||
"extensions": len(normalized.configuration.extensions),
|
||||
"access_profiles": len(normalized.access.profiles),
|
||||
"access_groups": len(normalized.access.groups),
|
||||
"access_users": len(normalized.access.users),
|
||||
},
|
||||
"diagnostics": diagnostics,
|
||||
}
|
||||
|
||||
|
||||
def _artifacts(snapshot: SirSnapshot | None, normalized: NormalizedProject | None) -> list[str]:
|
||||
artifacts = [
|
||||
"manifest.json",
|
||||
"source_inventory.json",
|
||||
"ai_context.md",
|
||||
"export_plan.md",
|
||||
"codex_package",
|
||||
"project_layout.json",
|
||||
"source_preview.json",
|
||||
"compact_objects.json",
|
||||
"compact_modules.json",
|
||||
]
|
||||
if snapshot is not None:
|
||||
artifacts.extend(["sir_snapshot.json", "ai_objects.json", "ai_modules.json", "ai_edges.json"])
|
||||
if normalized is not None:
|
||||
artifacts.append("normalized_project.json")
|
||||
return artifacts
|
||||
|
||||
|
||||
def _codex_folder_name(project_id: str) -> str:
|
||||
safe = re.sub(r"[^A-Za-z0-9_.-]+", "-", project_id).strip("-._") or "project"
|
||||
return f"codex-1c-context-{safe}"
|
||||
|
||||
|
||||
def _join_display_path(root: str | None, child: str) -> str:
|
||||
if not root:
|
||||
return child
|
||||
separator = "\\" if root.startswith("\\\\") or "\\" in root else "/"
|
||||
cleaned = root.rstrip("/\\")
|
||||
return f"{cleaned}{separator}{child}"
|
||||
|
||||
|
||||
def _write_codex_package(
|
||||
root: Path,
|
||||
input_path: Path,
|
||||
manifest: dict[str, Any],
|
||||
files: list[dict[str, Any]],
|
||||
snapshot: SirSnapshot | None,
|
||||
normalized: NormalizedProject | None,
|
||||
binaries: list[dict[str, Any]],
|
||||
parseable: bool,
|
||||
) -> None:
|
||||
(root / "context").mkdir(parents=True, exist_ok=True)
|
||||
(root / "indexes").mkdir(parents=True, exist_ok=True)
|
||||
(root / "objects").mkdir(parents=True, exist_ok=True)
|
||||
(root / "modules").mkdir(parents=True, exist_ok=True)
|
||||
(root / "raw").mkdir(parents=True, exist_ok=True)
|
||||
(root / "compact").mkdir(parents=True, exist_ok=True)
|
||||
source_map = _copy_codex_sources(input_path, root / "source")
|
||||
compact_objects = _compact_objects(normalized)
|
||||
compact_modules = _compact_modules(normalized)
|
||||
_write_text(root / "AGENTS.md", _codex_agents_markdown(manifest))
|
||||
_write_text(root / "README.md", _codex_readme_markdown(manifest))
|
||||
_write_text(root / "context" / "CODEX_START_HERE.md", _codex_start_here_markdown(manifest))
|
||||
_write_text(root / "context" / "project-overview.md", _ai_context_markdown(manifest, snapshot, normalized))
|
||||
_write_text(root / "context" / "project-brief.md", _project_brief_markdown(manifest, compact_objects, compact_modules))
|
||||
_write_text(root / "context" / "export-plan.md", _export_plan_markdown(manifest["project_id"], Path(manifest["input_path"]), root, binaries, parseable))
|
||||
_write_json(root / "indexes" / "manifest.json", manifest)
|
||||
_write_json(root / "indexes" / "codex-navigation.json", _codex_navigation(manifest, source_map))
|
||||
_write_json(root / "indexes" / "source-inventory.json", {"files": files})
|
||||
_write_json(root / "indexes" / "source-map.json", {"files": source_map})
|
||||
_write_json(root / "indexes" / "project-layout.json", manifest.get("source_layout") or {})
|
||||
_write_json(root / "indexes" / "source-preview.json", manifest.get("source_preview") or [])
|
||||
_write_json(root / "indexes" / "objects-compact.json", compact_objects)
|
||||
_write_json(root / "indexes" / "modules-compact.json", compact_modules)
|
||||
_write_json(root / "compact" / "objects.json", compact_objects)
|
||||
_write_json(root / "compact" / "modules.json", compact_modules)
|
||||
if snapshot is not None:
|
||||
(root / "raw" / "sir_snapshot.json").write_bytes(snapshot_to_json(snapshot))
|
||||
source_lookup = _source_lookup(source_map)
|
||||
objects = _ai_objects(snapshot, source_lookup)
|
||||
modules = _ai_modules(snapshot, source_lookup)
|
||||
_write_json(root / "indexes" / "objects.json", objects)
|
||||
_write_json(root / "indexes" / "modules.json", modules)
|
||||
_write_json(root / "indexes" / "edges.json", [edge.model_dump(mode="json") for edge in snapshot.edges])
|
||||
_write_object_markdown_files(root / "objects", objects)
|
||||
_write_module_markdown_files(root / "modules", modules)
|
||||
if normalized is not None:
|
||||
_write_json(root / "raw" / "normalized_project.json", normalized.model_dump(mode="json"))
|
||||
_write_text(root / "context" / "metadata-tree.md", _normalized_tree_markdown(normalized))
|
||||
_write_json(root / "indexes" / "access-model.json", normalized.access.model_dump(mode="json"))
|
||||
|
||||
|
||||
def _copy_codex_sources(input_path: Path, target: Path) -> list[dict[str, Any]]:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
source_files = [input_path] if input_path.is_file() else sorted(path for path in input_path.rglob("*") if path.is_file())
|
||||
copied: list[dict[str, Any]] = []
|
||||
for path in source_files:
|
||||
suffix = path.suffix.casefold()
|
||||
relative = path.name if input_path.is_file() else path.relative_to(input_path).as_posix()
|
||||
if not _should_copy_codex_source(path, input_path):
|
||||
continue
|
||||
size = path.stat().st_size
|
||||
if size > _MAX_CODEX_SOURCE_FILE_BYTES:
|
||||
copied.append(
|
||||
{
|
||||
"original_path": str(path),
|
||||
"relative_path": relative,
|
||||
"copied": False,
|
||||
"reason": f"file is larger than {_MAX_CODEX_SOURCE_FILE_BYTES} bytes",
|
||||
"size": size,
|
||||
}
|
||||
)
|
||||
continue
|
||||
destination = target / relative
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8-sig")
|
||||
except UnicodeDecodeError:
|
||||
text = path.read_text(encoding="cp1251", errors="replace")
|
||||
destination.write_text(text, encoding="utf-8")
|
||||
copied.append(
|
||||
{
|
||||
"original_path": str(path),
|
||||
"relative_path": relative,
|
||||
"codex_path": f"source/{relative}",
|
||||
"copied": True,
|
||||
"size": size,
|
||||
}
|
||||
)
|
||||
return copied
|
||||
|
||||
|
||||
def _should_copy_codex_source(path: Path, root: Path) -> bool:
|
||||
suffix = path.suffix.casefold()
|
||||
if suffix == ".bsl" or suffix == ".mdo":
|
||||
return True
|
||||
if suffix not in _CODEX_SOURCE_SUFFIXES:
|
||||
return False
|
||||
if suffix == ".xml":
|
||||
if path.name.casefold() in _ROOT_XML_NAMES:
|
||||
return True
|
||||
parts = [part.casefold() for part in path.relative_to(root).parts[:-1]] if root.is_dir() else []
|
||||
return any(part in {"forms", "configuration", "конфигурация"} for part in parts)
|
||||
return suffix in {".json", ".txt"}
|
||||
|
||||
|
||||
def _source_lookup(source_map: list[dict[str, Any]]) -> dict[str, str]:
|
||||
lookup: dict[str, str] = {}
|
||||
for item in source_map:
|
||||
if not item.get("copied"):
|
||||
continue
|
||||
codex_path = str(item.get("codex_path") or "")
|
||||
if not codex_path:
|
||||
continue
|
||||
for key in ["relative_path", "original_path"]:
|
||||
value = str(item.get(key) or "")
|
||||
if not value:
|
||||
continue
|
||||
normalized = value.replace("\\", "/")
|
||||
lookup[normalized] = codex_path
|
||||
lookup[str(Path(value)).replace("\\", "/")] = codex_path
|
||||
return lookup
|
||||
|
||||
|
||||
def _codex_agents_markdown(manifest: dict[str, Any]) -> str:
|
||||
return f"""# AGENTS.md для пакета контекста 1С
|
||||
|
||||
Эта папка сгенерирована SFERA для Codex.
|
||||
|
||||
## Как использовать эту папку
|
||||
|
||||
- Используйте пакет как контекст только для чтения для проекта `{manifest['project_id']}`.
|
||||
- Начинайте с `README.md`, `context/project-brief.md` и `context/project-overview.md`.
|
||||
- Для быстрой навигации сначала используйте `indexes/objects-compact.json`, `indexes/modules-compact.json` и `indexes/project-layout.json`.
|
||||
- К тяжелым файлам `indexes/objects.json`, `indexes/modules.json`, `raw/normalized_project.json` и `source/` переходите только когда компактной сводки уже не хватает.
|
||||
- Для текста BSL/XML/MDO используйте локальную папку `source/`. Это выборочная копия нужных исходников, а не полный дубликат всей выгрузки.
|
||||
- Используйте `indexes/source-map.json`, чтобы сопоставлять исходные пути с локальными путями `source/...`.
|
||||
- Если есть `raw/normalized_project.json`, считайте его основной моделью метаданных 1С.
|
||||
- Модули, формы, команды, реквизиты, табличные части и права являются частями объектов 1С-владельцев. Не рассматривайте модуль формы как отдельный независимый файл.
|
||||
- При генерации BSL сохраняйте контекст объекта-владельца из `qualified_name`, `lineage_id` и `source`.
|
||||
- Если `status` равен `export_required`, сначала выгрузите `.cf/.cfe` через 1C Designer/Windows Agent и затем пересоздайте пакет по выгруженным файлам.
|
||||
|
||||
## Важные файлы
|
||||
|
||||
- `context/project-brief.md` - короткая сводка для быстрого старта Codex.
|
||||
- `context/project-overview.md` - расширенный контекст для человека.
|
||||
- `context/metadata-tree.md` - дерево метаданных из NormalizedProject.
|
||||
- `indexes/*.json` - машиночитаемые индексы; сначала используйте compact-варианты.
|
||||
- `source/` - выборочные UTF-8 копии BSL/MDO и ключевых XML.
|
||||
- `objects/*.md` - карточки объектов.
|
||||
- `modules/*.md` - карточки модулей.
|
||||
- `raw/*.json` - полная сырая модель SFERA.
|
||||
"""
|
||||
|
||||
|
||||
def _codex_readme_markdown(manifest: dict[str, Any]) -> str:
|
||||
snapshot = manifest.get("snapshot") or {}
|
||||
normalized = manifest.get("normalized") or {}
|
||||
lines = [
|
||||
f"# Контекст 1С для Codex: {manifest['project_id']}",
|
||||
"",
|
||||
f"- Статус: `{manifest['status']}`",
|
||||
f"- Источник: `{manifest['input_path']}`",
|
||||
f"- Просканировано файлов: {manifest['files_count']}",
|
||||
f"- Узлов SIR: {snapshot.get('nodes', 0)}",
|
||||
f"- Связей SIR: {snapshot.get('edges', 0)}",
|
||||
f"- Нормализованных объектов: {normalized.get('objects', 0)}",
|
||||
f"- Расширений: {normalized.get('extensions', 0)}",
|
||||
"",
|
||||
"Перенесите эту папку целиком в проект Codex, когда хотите, чтобы Codex писал код для этой конфигурации 1С.",
|
||||
"Для экономии токенов сначала используйте compact-индексы и brief-контекст, а к `source/` и `raw/` переходите только при необходимости.",
|
||||
]
|
||||
layout = manifest.get("source_layout") or {}
|
||||
if layout:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## Структура выгрузки",
|
||||
f"- Основная конфигурация: `{layout.get('main_configuration_root') or 'не определена'}`",
|
||||
f"- Папок расширений: {len(layout.get('extension_roots') or [])}",
|
||||
]
|
||||
)
|
||||
if manifest.get("diagnostics"):
|
||||
lines.extend(["", "## Диагностика"])
|
||||
lines.extend(f"- {item}" for item in manifest["diagnostics"])
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _codex_start_here_markdown(manifest: dict[str, Any]) -> str:
|
||||
return f"""# Начните здесь для Codex
|
||||
|
||||
Проект: `{manifest['project_id']}`
|
||||
Статус: `{manifest['status']}`
|
||||
|
||||
Читайте в таком порядке:
|
||||
|
||||
1. `AGENTS.md`
|
||||
2. `README.md`
|
||||
3. `context/project-brief.md`
|
||||
4. `context/project-overview.md`
|
||||
5. `indexes/project-layout.json`
|
||||
6. `indexes/objects-compact.json`
|
||||
7. `indexes/modules-compact.json`
|
||||
8. `context/metadata-tree.md`
|
||||
9. `indexes/objects.json`
|
||||
10. `indexes/modules.json`
|
||||
11. `indexes/edges.json`
|
||||
12. `source/`
|
||||
|
||||
При генерации кода:
|
||||
|
||||
- Сначала найдите объект 1С-владельца.
|
||||
- Затем изучите контекст его модуля, формы и команды.
|
||||
- Для точного текста исходника предпочитайте локальные копии в `source/`, но открывайте их только когда compact-индекса уже недостаточно.
|
||||
- Используйте `raw/normalized_project.json`, когда структура объекта важнее, чем сырой XML.
|
||||
- Используйте `indexes/source-map.json`, если нужно сопоставить ссылки SFERA с локальными путями пакета.
|
||||
"""
|
||||
|
||||
|
||||
def _codex_navigation(manifest: dict[str, Any], source_map: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
copied_sources = [item for item in source_map if item.get("copied")]
|
||||
return {
|
||||
"project_id": manifest["project_id"],
|
||||
"status": manifest["status"],
|
||||
"start_here": "context/CODEX_START_HERE.md",
|
||||
"instructions": "AGENTS.md",
|
||||
"overview": "context/project-overview.md",
|
||||
"brief": "context/project-brief.md",
|
||||
"project_layout": "indexes/project-layout.json",
|
||||
"compact_objects_index": "indexes/objects-compact.json",
|
||||
"compact_modules_index": "indexes/modules-compact.json",
|
||||
"metadata_tree": "context/metadata-tree.md",
|
||||
"objects_index": "indexes/objects.json",
|
||||
"modules_index": "indexes/modules.json",
|
||||
"edges_index": "indexes/edges.json",
|
||||
"source_map": "indexes/source-map.json",
|
||||
"raw_normalized_project": "raw/normalized_project.json",
|
||||
"raw_sir_snapshot": "raw/sir_snapshot.json",
|
||||
"local_source_count": len(copied_sources),
|
||||
"first_sources": [item.get("codex_path") for item in copied_sources[:25]],
|
||||
}
|
||||
|
||||
|
||||
def _write_object_markdown_files(root: Path, objects: list[dict[str, Any]]) -> None:
|
||||
for item in objects[:1000]:
|
||||
filename = _safe_context_filename(str(item.get("qualified_name") or item.get("name") or "object")) + ".md"
|
||||
_write_text(root / filename, _object_markdown(item))
|
||||
|
||||
|
||||
def _write_module_markdown_files(root: Path, modules: list[dict[str, Any]]) -> None:
|
||||
for item in modules[:1000]:
|
||||
filename = _safe_context_filename(str(item.get("qualified_name") or item.get("name") or "module")) + ".md"
|
||||
_write_text(root / filename, _module_markdown(item))
|
||||
|
||||
|
||||
def _safe_context_filename(value: str) -> str:
|
||||
safe = re.sub(r"[^A-Za-zА-Яа-яЁё0-9_.-]+", "_", value).strip("._")
|
||||
return (safe or "item")[:140]
|
||||
|
||||
|
||||
def _object_markdown(item: dict[str, Any]) -> str:
|
||||
local_source = _local_source_path(item)
|
||||
return "\n".join(
|
||||
[
|
||||
f"# {item.get('qualified_name') or item.get('name')}",
|
||||
"",
|
||||
f"- Вид: `{item.get('kind')}`",
|
||||
f"- Имя: `{item.get('name')}`",
|
||||
f"- Lineage: `{item.get('lineage_id')}`",
|
||||
f"- Semantic: `{item.get('semantic_id')}`",
|
||||
f"- Источник: `{item.get('source')}`",
|
||||
f"- Локальный исходник: `{local_source}`",
|
||||
"",
|
||||
"## Атрибуты",
|
||||
"```json",
|
||||
json.dumps(item.get("attributes") or {}, ensure_ascii=False, indent=2, default=str),
|
||||
"```",
|
||||
]
|
||||
) + "\n"
|
||||
|
||||
|
||||
def _module_markdown(item: dict[str, Any]) -> str:
|
||||
local_source = _local_source_path(item)
|
||||
return "\n".join(
|
||||
[
|
||||
f"# {item.get('qualified_name') or item.get('name')}",
|
||||
"",
|
||||
f"- Имя: `{item.get('name')}`",
|
||||
f"- Lineage: `{item.get('lineage_id')}`",
|
||||
f"- Источник: `{item.get('source')}`",
|
||||
f"- Локальный исходник: `{local_source}`",
|
||||
"",
|
||||
"## Атрибуты модуля",
|
||||
"```json",
|
||||
json.dumps(item.get("attributes") or {}, ensure_ascii=False, indent=2, default=str),
|
||||
"```",
|
||||
]
|
||||
) + "\n"
|
||||
|
||||
|
||||
def _local_source_path(item: dict[str, Any]) -> str:
|
||||
local = str(item.get("local_source_path") or "")
|
||||
if local:
|
||||
return local
|
||||
source = item.get("source") or {}
|
||||
if not isinstance(source, dict):
|
||||
return ""
|
||||
source_path = str(source.get("source_path") or "")
|
||||
return f"source/{source_path}" if source_path else ""
|
||||
|
||||
|
||||
def _source_ref_local_path(source_ref: object | None, source_lookup: dict[str, str]) -> str:
|
||||
if source_ref is None:
|
||||
return ""
|
||||
source_path = str(getattr(source_ref, "source_path", "") or "")
|
||||
if not source_path:
|
||||
return ""
|
||||
normalized = source_path.replace("\\", "/")
|
||||
return source_lookup.get(normalized) or source_lookup.get(str(Path(source_path)).replace("\\", "/")) or ""
|
||||
|
||||
|
||||
def _normalized_tree_markdown(normalized: NormalizedProject) -> str:
|
||||
lines = [f"# Дерево метаданных: {normalized.project_id or 'project'}", ""]
|
||||
for group in normalized.configuration.groups:
|
||||
lines.append(f"## {group.name}")
|
||||
if not group.objects:
|
||||
lines.append("- нет объектов")
|
||||
continue
|
||||
for item in group.objects[:500]:
|
||||
lines.append(f"- `{item.qualified_name}` ({item.object_kind})")
|
||||
for form in item.forms[:20]:
|
||||
lines.append(f" - form: `{form.name}`")
|
||||
for command in item.commands[:20]:
|
||||
lines.append(f" - command: `{command.name}`")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _ai_objects(snapshot: SirSnapshot, source_lookup: dict[str, str] | None = None) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"kind": node.kind.value if hasattr(node.kind, "value") else str(node.kind),
|
||||
"name": node.name,
|
||||
"qualified_name": node.qualified_name,
|
||||
"lineage_id": node.lineage_id,
|
||||
"semantic_id": node.semantic_id,
|
||||
"source": None if node.source_ref is None else node.source_ref.model_dump(mode="json"),
|
||||
"local_source_path": _source_ref_local_path(node.source_ref, source_lookup or {}),
|
||||
"attributes": node.attributes,
|
||||
}
|
||||
for node in snapshot.nodes
|
||||
if (node.kind.value if hasattr(node.kind, "value") else str(node.kind)) != "MODULE"
|
||||
]
|
||||
|
||||
|
||||
def _ai_modules(snapshot: SirSnapshot, source_lookup: dict[str, str] | None = None) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": node.name,
|
||||
"qualified_name": node.qualified_name,
|
||||
"lineage_id": node.lineage_id,
|
||||
"source": None if node.source_ref is None else node.source_ref.model_dump(mode="json"),
|
||||
"local_source_path": _source_ref_local_path(node.source_ref, source_lookup or {}),
|
||||
"attributes": node.attributes,
|
||||
}
|
||||
for node in snapshot.nodes
|
||||
if (node.kind.value if hasattr(node.kind, "value") else str(node.kind)) == "MODULE"
|
||||
]
|
||||
|
||||
|
||||
def _ai_context_markdown(manifest: dict[str, Any], snapshot: SirSnapshot | None, normalized: NormalizedProject | None) -> str:
|
||||
lines = [
|
||||
f"# Структура SFERA для ИИ: {manifest['project_id']}",
|
||||
"",
|
||||
f"- Статус: {manifest['status']}",
|
||||
f"- Исходных файлов: {manifest['files_count']}",
|
||||
f"- Артефакты: {', '.join(manifest['artifacts'])}",
|
||||
]
|
||||
if snapshot is not None:
|
||||
lines.extend(
|
||||
[
|
||||
f"- Узлов SIR: {len(snapshot.nodes)}",
|
||||
f"- Связей SIR: {len(snapshot.edges)}",
|
||||
f"- Хеш снимка: {snapshot.snapshot_hash}",
|
||||
]
|
||||
)
|
||||
if normalized is not None:
|
||||
lines.append(f"- Групп нормализованных метаданных: {len(normalized.configuration.groups)}")
|
||||
if manifest["diagnostics"]:
|
||||
lines.append("")
|
||||
lines.append("## Диагностика")
|
||||
lines.extend(f"- {item}" for item in manifest["diagnostics"])
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## Как ИИ должен использовать этот пакет",
|
||||
"- Используйте `normalized_project.json` как основную модель объектов 1С.",
|
||||
"- Для экономии токенов начинайте с `project-brief.md`, `project-layout.json`, `objects-compact.json` и `modules-compact.json`.",
|
||||
"- Используйте `sir_snapshot.json`, `ai_objects.json`, `ai_modules.json` и `ai_edges.json` для навигации по коду и анализа влияния.",
|
||||
"- Рассматривайте модули, формы и команды как части объектов 1С-владельцев, а не как отдельные текстовые файлы.",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _source_layout_summary(root: Path) -> dict[str, Any]:
|
||||
if root.is_file():
|
||||
return {"kind": "file", "main_configuration_root": root.name, "extension_roots": []}
|
||||
children = [path for path in sorted(root.iterdir()) if path.is_dir()]
|
||||
config_dir = next((path for path in children if path.name.casefold() in {"configuration", "конфигурация"}), None)
|
||||
extension_roots = [
|
||||
path.name
|
||||
for path in children
|
||||
if path != config_dir and any(item.suffix.casefold() in {".xml", ".mdo", ".bsl"} for item in path.rglob("*") if item.is_file())
|
||||
]
|
||||
kind = "configuration_with_extensions" if config_dir else "flat_or_mixed"
|
||||
return {
|
||||
"kind": kind,
|
||||
"main_configuration_root": config_dir.name if config_dir else root.name,
|
||||
"extension_roots": extension_roots,
|
||||
}
|
||||
|
||||
|
||||
def _source_preview_summary(root: Path) -> list[dict[str, Any]]:
|
||||
files = [root] if root.is_file() else sorted(path for path in root.rglob("*") if path.is_file())
|
||||
object_files = _preview_relative_paths(
|
||||
root,
|
||||
sorted(
|
||||
[path for path in files if path.suffix.casefold() in {".xml", ".mdo"}],
|
||||
key=lambda path: (
|
||||
0 if any(part.casefold() in {"configuration", "конфигурация"} for part in path.parts) else 1,
|
||||
str(path).casefold(),
|
||||
),
|
||||
),
|
||||
limit=6,
|
||||
)
|
||||
module_files = _preview_relative_paths(root, [path for path in files if path.suffix.casefold() == ".bsl"], limit=6)
|
||||
layout = _source_layout_summary(root)
|
||||
return [
|
||||
{"label": "Главная конфигурация", "items": [layout["main_configuration_root"]]},
|
||||
{"label": "Папки расширений", "items": layout["extension_roots"] or ["нет"]},
|
||||
{"label": "Первые файлы объектов", "items": object_files or ["не найдены"]},
|
||||
{"label": "Первые файлы модулей", "items": module_files or ["не найдены"]},
|
||||
]
|
||||
|
||||
|
||||
def _compact_objects(normalized: NormalizedProject | None) -> list[dict[str, Any]]:
|
||||
if normalized is None:
|
||||
return []
|
||||
items: list[dict[str, Any]] = []
|
||||
for group in normalized.configuration.groups:
|
||||
for obj in group.objects:
|
||||
items.append(_compact_object_entry(obj, group.name, extension_name=None))
|
||||
for extension in normalized.configuration.extensions:
|
||||
for group in extension.groups:
|
||||
for obj in group.objects:
|
||||
items.append(_compact_object_entry(obj, group.name, extension_name=extension.name))
|
||||
return items
|
||||
|
||||
|
||||
def _compact_object_entry(obj: Any, group_name: str, extension_name: str | None) -> dict[str, Any]:
|
||||
return {
|
||||
"qualified_name": obj.qualified_name,
|
||||
"name": obj.name,
|
||||
"object_kind": obj.object_kind,
|
||||
"group": group_name,
|
||||
"extension": extension_name,
|
||||
"source_path": obj.source_path,
|
||||
"forms": len(obj.forms),
|
||||
"commands": len(obj.commands),
|
||||
"modules": len(obj.modules),
|
||||
"attributes": len(obj.attributes),
|
||||
"tabular_sections": len(obj.tabular_sections),
|
||||
"layouts": len(obj.layouts),
|
||||
"rights": len(obj.rights),
|
||||
}
|
||||
|
||||
|
||||
def _compact_modules(normalized: NormalizedProject | None) -> list[dict[str, Any]]:
|
||||
if normalized is None:
|
||||
return []
|
||||
items: list[dict[str, Any]] = []
|
||||
for group in normalized.configuration.groups:
|
||||
for obj in group.objects:
|
||||
for module in obj.modules:
|
||||
items.append(_compact_module_entry(obj, module, extension_name=None))
|
||||
for extension in normalized.configuration.extensions:
|
||||
for group in extension.groups:
|
||||
for obj in group.objects:
|
||||
for module in obj.modules:
|
||||
items.append(_compact_module_entry(obj, module, extension_name=extension.name))
|
||||
return items
|
||||
|
||||
|
||||
def _compact_module_entry(owner: Any, module: Any, extension_name: str | None) -> dict[str, Any]:
|
||||
return {
|
||||
"qualified_name": module.qualified_name or module.name,
|
||||
"name": module.name,
|
||||
"module_kind": module.module_kind,
|
||||
"owner": owner.qualified_name,
|
||||
"owner_kind": owner.object_kind,
|
||||
"extension": extension_name,
|
||||
"source_path": module.source_path,
|
||||
}
|
||||
|
||||
|
||||
def _project_brief_markdown(manifest: dict[str, Any], compact_objects: list[dict[str, Any]], compact_modules: list[dict[str, Any]]) -> str:
|
||||
layout = manifest.get("source_layout") or {}
|
||||
top_objects = compact_objects[:40]
|
||||
top_modules = compact_modules[:30]
|
||||
lines = [
|
||||
f"# Brief: {manifest['project_id']}",
|
||||
"",
|
||||
f"- Структура выгрузки: `{layout.get('kind') or 'unknown'}`",
|
||||
f"- Основная конфигурация: `{layout.get('main_configuration_root') or 'не определена'}`",
|
||||
f"- Расширения: {', '.join(layout.get('extension_roots') or []) or 'нет'}",
|
||||
f"- Объектов в compact-индексе: {len(compact_objects)}",
|
||||
f"- Модулей в compact-индексе: {len(compact_modules)}",
|
||||
"",
|
||||
"## Первые объекты",
|
||||
]
|
||||
lines.extend(
|
||||
f"- `{item['qualified_name']}` [{item['object_kind']}] forms={item['forms']} modules={item['modules']} extension={item.get('extension') or 'main'}"
|
||||
for item in top_objects
|
||||
)
|
||||
lines.extend(["", "## Первые модули"])
|
||||
lines.extend(
|
||||
f"- `{item['qualified_name']}` owner=`{item['owner']}` extension={item.get('extension') or 'main'}"
|
||||
for item in top_modules
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _preview_relative_paths(root: Path, files: list[Path], *, limit: int) -> list[str]:
|
||||
preview: list[str] = []
|
||||
for path in files[:limit]:
|
||||
if root.is_file():
|
||||
preview.append(path.name)
|
||||
else:
|
||||
preview.append(path.relative_to(root).as_posix())
|
||||
return preview
|
||||
|
||||
|
||||
def _export_plan_markdown(project_id: str, input_path: Path, output_path: Path, binaries: list[dict[str, Any]], parseable: bool) -> str:
|
||||
lines = [
|
||||
f"# План выгрузки 1С для {project_id}",
|
||||
"",
|
||||
f"- Вход: `{input_path}`",
|
||||
f"- Выход: `{output_path}`",
|
||||
]
|
||||
if parseable:
|
||||
lines.append("- Найдены файлы метаданных; семантическая обработка выполнена напрямую.")
|
||||
if binaries:
|
||||
lines.extend(["", "## Бинарные файлы .cf/.cfe", ""])
|
||||
for item in binaries:
|
||||
lines.append(f"- `{item['relative_path']}`")
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"Для полной структуры выполните экспорт через 1C Designer/Windows Agent:",
|
||||
"- `/DumpConfigToFiles <output>/configuration -Format Hierarchical` для основной конфигурации",
|
||||
"- `/DumpConfigToFiles <output>/extensions/<name> -Format Hierarchical -Extension <name>` для расширений",
|
||||
"- затем повторно запустите подготовку AI-структуры на папке с выгруженными файлами.",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _write_json(path: Path, payload: Any) -> None:
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
|
||||
|
||||
|
||||
def _write_text(path: Path, payload: str) -> None:
|
||||
path.write_text(payload, encoding="utf-8")
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FormEditorCommand:
|
||||
lineage_id: str
|
||||
name: str
|
||||
caption: str
|
||||
handler_name: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FormEditorElement:
|
||||
lineage_id: str
|
||||
name: str
|
||||
qualified_name: str
|
||||
caption: str
|
||||
control_kind: str
|
||||
binding: str
|
||||
width: str = "stretch"
|
||||
row: int = 0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FormEditorDraft:
|
||||
form_lineage_id: str
|
||||
form_name: str
|
||||
form_title: str
|
||||
owner_name: str
|
||||
layout_kind: str = "auto"
|
||||
commands: list[FormEditorCommand] = field(default_factory=list)
|
||||
elements: list[FormEditorElement] = field(default_factory=list)
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sir import NodeKind, SirSnapshot
|
||||
|
||||
|
||||
def select_form_semantics(forms: list[object], form_id: str | None) -> object | None:
|
||||
if form_id:
|
||||
selected = next(
|
||||
(
|
||||
item
|
||||
for item in forms
|
||||
if getattr(getattr(item, "form", None), "lineage_id", None) == form_id
|
||||
or getattr(getattr(item, "form", None), "qualified_name", None) == form_id
|
||||
or getattr(getattr(item, "form", None), "name", None) == form_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if selected is not None:
|
||||
return selected
|
||||
return forms[0] if forms else None
|
||||
|
||||
|
||||
def form_module_for_form(snapshot: SirSnapshot, form: object | None):
|
||||
if form is None:
|
||||
return None
|
||||
form_lineage = str(getattr(form, "lineage_id", "") or "")
|
||||
form_qualified = str(getattr(form, "qualified_name", "") or "")
|
||||
form_name = str(getattr(form, "name", "") or "")
|
||||
for node in snapshot.nodes:
|
||||
if node.kind != NodeKind.MODULE:
|
||||
continue
|
||||
attributes = node.attributes or {}
|
||||
if attributes.get("form_lineage_id") == form_lineage:
|
||||
return node
|
||||
if attributes.get("form_qualified_name") == form_qualified:
|
||||
return node
|
||||
if attributes.get("module_role") == "FORM_MODULE" and attributes.get("form_name") == form_name:
|
||||
owner_name = str(attributes.get("owner_qualified_name") or "")
|
||||
if form_qualified.startswith(owner_name):
|
||||
return node
|
||||
return None
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from api_server.form_editor_models import FormEditorCommand, FormEditorDraft, FormEditorElement
|
||||
|
||||
|
||||
def build_form_editor_draft(
|
||||
form_semantics: object,
|
||||
form_module: object | None,
|
||||
form_data: dict[str, list[str]] | None = None,
|
||||
) -> FormEditorDraft:
|
||||
form = getattr(form_semantics, "form", None)
|
||||
form_name = str(getattr(form, "qualified_name", None) or getattr(form, "name", "Форма"))
|
||||
module_attrs = getattr(form_module, "attributes", {}) or {}
|
||||
owner_name = str(module_attrs.get("owner_qualified_name") or _owner_name_from_form(form_name))
|
||||
draft = FormEditorDraft(
|
||||
form_lineage_id=str(getattr(form, "lineage_id", "") or ""),
|
||||
form_name=form_name,
|
||||
form_title=str(_first_value(form_data, "form_title") or getattr(form, "name", None) or "Форма"),
|
||||
owner_name=owner_name,
|
||||
layout_kind=str(_first_value(form_data, "layout_kind") or "auto"),
|
||||
commands=_commands_from_semantics(form_semantics),
|
||||
elements=_elements_from_semantics(form_semantics),
|
||||
)
|
||||
if form_data:
|
||||
_apply_form_data(draft, form_data)
|
||||
return draft
|
||||
|
||||
|
||||
def _commands_from_semantics(form_semantics: object) -> list[FormEditorCommand]:
|
||||
handlers = getattr(form_semantics, "command_handlers", {}) or {}
|
||||
commands: list[FormEditorCommand] = []
|
||||
for command in getattr(form_semantics, "commands", []) or []:
|
||||
lineage_id = str(getattr(command, "lineage_id", "") or "")
|
||||
handler = handlers.get(lineage_id)
|
||||
handler_name = str(getattr(handler, "name", None) or getattr(handler, "qualified_name", None) or "")
|
||||
name = str(getattr(command, "name", "Команда"))
|
||||
commands.append(FormEditorCommand(lineage_id=lineage_id, name=name, caption=name, handler_name=handler_name))
|
||||
return commands
|
||||
|
||||
|
||||
def _elements_from_semantics(form_semantics: object) -> list[FormEditorElement]:
|
||||
elements: list[FormEditorElement] = []
|
||||
for index, element in enumerate(getattr(form_semantics, "elements", []) or []):
|
||||
attributes = getattr(element, "attributes", {}) or {}
|
||||
name = str(getattr(element, "name", None) or getattr(element, "qualified_name", "Элемент"))
|
||||
elements.append(
|
||||
FormEditorElement(
|
||||
lineage_id=str(getattr(element, "lineage_id", "") or f"element.{index}"),
|
||||
name=name,
|
||||
qualified_name=str(getattr(element, "qualified_name", name)),
|
||||
caption=str(attributes.get("caption") or attributes.get("synonym") or name),
|
||||
control_kind=_control_kind_for(name, attributes),
|
||||
binding=str(attributes.get("path") or attributes.get("dataPath") or attributes.get("binding") or name),
|
||||
width=str(attributes.get("width") or "stretch"),
|
||||
row=index,
|
||||
)
|
||||
)
|
||||
return elements
|
||||
|
||||
|
||||
def _apply_form_data(draft: FormEditorDraft, form_data: dict[str, list[str]]) -> None:
|
||||
captions = _values(form_data, "element_caption")
|
||||
kinds = _values(form_data, "element_kind")
|
||||
bindings = _values(form_data, "element_binding")
|
||||
widths = _values(form_data, "element_width")
|
||||
for index, element in enumerate(draft.elements):
|
||||
element.caption = _at(captions, index, element.caption)
|
||||
element.control_kind = _safe_control_kind(_at(kinds, index, element.control_kind))
|
||||
element.binding = _at(bindings, index, element.binding)
|
||||
element.width = _safe_width(_at(widths, index, element.width))
|
||||
|
||||
command_captions = _values(form_data, "command_caption")
|
||||
for index, command in enumerate(draft.commands):
|
||||
command.caption = _at(command_captions, index, command.caption)
|
||||
|
||||
new_name = str(_first_value(form_data, "new_element_name") or "").strip()
|
||||
if new_name:
|
||||
draft.elements.append(
|
||||
FormEditorElement(
|
||||
lineage_id=f"draft.{len(draft.elements) + 1}",
|
||||
name=new_name,
|
||||
qualified_name=f"{draft.form_name}.{new_name}",
|
||||
caption=new_name,
|
||||
control_kind=_safe_control_kind(_first_value(form_data, "new_element_kind") or "input"),
|
||||
binding=new_name,
|
||||
row=len(draft.elements),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _control_kind_for(name: str, attributes: dict) -> str:
|
||||
raw = str(attributes.get("control") or attributes.get("control_kind") or attributes.get("type") or "").casefold()
|
||||
if "table" in raw or "табли" in raw:
|
||||
return "table"
|
||||
if "checkbox" in raw or "boolean" in raw or "булево" in raw or name.casefold().startswith(("это", "пометка")):
|
||||
return "checkbox"
|
||||
if "date" in raw or "дата" in raw or "Дата" in name:
|
||||
return "date"
|
||||
if "group" in raw or "группа" in raw:
|
||||
return "group"
|
||||
return "input"
|
||||
|
||||
|
||||
def _safe_control_kind(value: str) -> str:
|
||||
return value if value in {"input", "date", "checkbox", "table", "group", "text"} else "input"
|
||||
|
||||
|
||||
def _safe_width(value: str) -> str:
|
||||
return value if value in {"stretch", "half", "third"} else "stretch"
|
||||
|
||||
|
||||
def _owner_name_from_form(form_name: str) -> str:
|
||||
parts = form_name.split(".")
|
||||
if len(parts) >= 2:
|
||||
return ".".join(parts[:2])
|
||||
return form_name
|
||||
|
||||
|
||||
def _values(form_data: dict[str, list[str]], key: str) -> list[str]:
|
||||
return [str(value) for value in form_data.get(key, [])]
|
||||
|
||||
|
||||
def _first_value(form_data: dict[str, list[str]] | None, key: str) -> str | None:
|
||||
if not form_data:
|
||||
return None
|
||||
values = form_data.get(key)
|
||||
return str(values[0]) if values else None
|
||||
|
||||
|
||||
def _at(values: list[str], index: int, default: str) -> str:
|
||||
value = values[index].strip() if index < len(values) else ""
|
||||
return value or default
|
||||
@@ -1,575 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from html import escape
|
||||
from typing import Iterable
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from sir import NodeKind, SirSnapshot
|
||||
|
||||
|
||||
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 render_html5_operations(jobs: Iterable[object]) -> str:
|
||||
job_list = list(jobs)
|
||||
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-сервером и обновляется htmx polling без React runtime.</p>
|
||||
</div>
|
||||
<div class="hero-metrics">
|
||||
<strong>{len(job_list)}</strong>
|
||||
<span>jobs</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="band">
|
||||
<div class="section-title">
|
||||
<h2>Очередь</h2>
|
||||
<a class="button" href="/html5">Проекты</a>
|
||||
</div>
|
||||
{render_html5_operation_summary(job_list)}
|
||||
<div class="table-wrap">
|
||||
<table data-html5-operations>
|
||||
<thead>
|
||||
<tr><th>Job</th><th>Проект</th><th>Статус</th><th>Stage</th><th>Сообщение</th></tr>
|
||||
</thead>
|
||||
<tbody
|
||||
data-html5-operations-body
|
||||
hx-get="/html5/operations/jobs"
|
||||
hx-trigger="every 3s"
|
||||
hx-swap="innerHTML"
|
||||
>{render_html5_operation_rows(job_list)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</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
|
||||
hx-get="/html5/operations/summary"
|
||||
hx-trigger="every 3s"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
{_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="5" class="muted">Фоновые операции пока не запускались</td></tr>'
|
||||
return rows
|
||||
|
||||
|
||||
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)}">
|
||||
{_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>
|
||||
<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)}
|
||||
</div>
|
||||
{render_html5_project_report(project_id, None)}
|
||||
{render_html5_review(project_id, None)}
|
||||
</aside>
|
||||
</section>
|
||||
<footer class="status" data-html5-status hx-ext="sse" sse-connect="/html5/projects/{quote(project_id)}/events" sse-swap="status">
|
||||
{render_html5_status(project_id, snapshot)}
|
||||
</footer>
|
||||
</main>
|
||||
"""
|
||||
return _page(f"SFERA HTML5 - {project_id}", content)
|
||||
|
||||
|
||||
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"
|
||||
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"
|
||||
hx-trigger="every 15s"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<div class="panel-title">Отчет проекта</div>
|
||||
<dl class="report-grid">{''.join(_metric(label, value) for label, value in metrics)}</dl>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def render_html5_review(project_id: str, findings: list[dict] | None) -> str:
|
||||
if findings is None:
|
||||
return f"""
|
||||
<div
|
||||
class="review-panel"
|
||||
data-html5-review
|
||||
hx-get="/html5/projects/{quote(project_id)}/review"
|
||||
hx-trigger="load"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<div class="panel-title">Review</div>
|
||||
<p class="muted padded">Сервер готовит findings.</p>
|
||||
</div>
|
||||
"""
|
||||
if not findings:
|
||||
body = '<p class="muted padded">Findings не найдены</p>'
|
||||
else:
|
||||
body = "".join(_review_item(finding) for finding in findings[:12])
|
||||
return f"""
|
||||
<div
|
||||
class="review-panel"
|
||||
data-html5-review
|
||||
hx-get="/html5/projects/{quote(project_id)}/review"
|
||||
hx-trigger="every 20s"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<div class="panel-title">Review · {len(findings)}</div>
|
||||
<div class="review-list">{body}</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
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)}">
|
||||
{_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>
|
||||
<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 []
|
||||
poll = ' hx-trigger="every 2s" hx-swap="outerHTML"' if status in {"QUEUED", "RUNNING"} 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)}"
|
||||
{poll}
|
||||
>
|
||||
<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"
|
||||
hx-trigger="every 5s"
|
||||
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 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) -> str:
|
||||
results = html5_symbol_results(snapshot, q)
|
||||
if not results:
|
||||
return '<p class="muted">Нет результатов</p>'
|
||||
return "".join(_symbol_result(node) for node in results)
|
||||
|
||||
|
||||
def render_html5_source(node: object | None) -> str:
|
||||
name = "source" if node is None else getattr(node, "qualified_name", None) or getattr(node, "name", "source")
|
||||
return f'<pre class="code" data-html5-source data-html5-source-name="{escape(str(name))}">{escape(_node_source_text(node))}</pre>'
|
||||
_HTML5_ASSETS_DIR = Path(__file__).resolve().parent / "static" / "html5"
|
||||
|
||||
|
||||
def _page(title: str, body: str) -> str:
|
||||
@@ -579,40 +14,22 @@ def _page(title: str, body: str) -> str:
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{escape(title)}</title>
|
||||
<style>{_css()}</style>
|
||||
<script defer src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<script defer src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
|
||||
<link rel="stylesheet" href="{_asset_url("html5.css")}" />
|
||||
<script defer src="{_asset_url("htmx.min.js")}"></script>
|
||||
<script defer src="{_asset_url("htmx-ext-sse.js")}"></script>
|
||||
<script defer src="{_asset_url("html5-ai-structure.js")}"></script>
|
||||
</head>
|
||||
<body>{body}</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
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>"""
|
||||
def _asset_url(filename: str) -> str:
|
||||
asset_path = _HTML5_ASSETS_DIR / filename
|
||||
try:
|
||||
version = str(int(asset_path.stat().st_mtime))
|
||||
except OSError:
|
||||
version = "dev"
|
||||
return f"/html5/assets/{quote(filename)}?v={version}"
|
||||
|
||||
|
||||
def _project_link(project: object, active_project_id: str) -> str:
|
||||
@@ -622,29 +39,6 @@ def _project_link(project: object, active_project_id: str) -> str:
|
||||
return f'<a class="project-link{active}" href="/html5/projects/{quote(project_id)}/editor">{escape(name)}</a>'
|
||||
|
||||
|
||||
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>
|
||||
</tr>"""
|
||||
|
||||
|
||||
def _topbar(project_id: str, project_nav: str) -> str:
|
||||
return f"""
|
||||
<header class="topbar" data-html5-topbar>
|
||||
@@ -652,164 +46,18 @@ def _topbar(project_id: str, project_nav: str) -> str:
|
||||
<nav class="project-nav">{project_nav}</nav>
|
||||
<a class="button" href="/docs">API</a>
|
||||
<a class="button" href="/html5/operations">Операции</a>
|
||||
<a class="button" href="/html5/projects/{quote(project_id)}/access">Права</a>
|
||||
<a class="button" href="/html5/projects/{quote(project_id)}/ai-structure">AI-структура</a>
|
||||
<a class="button" href="/html5/projects/{quote(project_id)}/setup">HTML5 Setup</a>
|
||||
<a class="button" href="/editor?project={quote(project_id)}">Legacy Next</a>
|
||||
</header>"""
|
||||
|
||||
|
||||
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 _enum_text(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value.value if hasattr(value, "value") else value)
|
||||
|
||||
|
||||
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 _metric(label: str, value: object) -> str:
|
||||
return f"<div><dt>{escape(label)}</dt><dd>{escape(str(value))}</dd></div>"
|
||||
|
||||
|
||||
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>
|
||||
"""
|
||||
|
||||
|
||||
def _review_item(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
|
||||
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>
|
||||
</article>
|
||||
"""
|
||||
|
||||
|
||||
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", ""))
|
||||
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'hx-get="/html5/projects/{quote(project_id)}/source/{quote(lineage_id, safe="")}" '
|
||||
'hx-target="[data-html5-source]" hx-swap="outerHTML">'
|
||||
f'<span>{escape(str(name))}</span><small>{escape(kind_value)}</small></a>'
|
||||
)
|
||||
|
||||
|
||||
def _symbol_result(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)
|
||||
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"
|
||||
return f"""
|
||||
<article class="symbol" data-html5-symbol>
|
||||
<strong>{escape(str(name))}</strong>
|
||||
<span>{escape(kind_value)}</span>
|
||||
<small>{escape(str(location))}</small>
|
||||
</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// Сервер уже отрисовал контекст, дерево, поиск и метрики."
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
return """
|
||||
: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}.code{height:calc(100% - 72px);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)}.symbol{display:grid;gap:3px;padding:10px 12px;border-bottom:1px solid var(--line)}.symbol span,.symbol small{color:var(--muted)}.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{display:grid;gap:3px;padding:10px 12px;border-bottom:1px solid var(--line)}.review-item span,.review-item small{color:var(--muted)}.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{display:flex;gap:8px;flex-wrap:wrap;align-items:end;padding:12px 16px;border-bottom:1px solid var(--line);background:#fbfcfe}.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}.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}
|
||||
@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){.setup-layout{grid-template-columns:1fr}.setup-metrics{grid-template-columns:1fr 1fr}.settings-form{grid-template-columns:1fr}}
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
+2205
-2506
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def metadata_tree(
|
||||
*,
|
||||
project_id: str,
|
||||
object_limit_per_branch: int,
|
||||
project_snapshot: Callable[[str], object],
|
||||
normalized_project: Callable[[str], object | None],
|
||||
normalized_tree: Callable[..., object],
|
||||
snapshot_tree: Callable[..., object],
|
||||
response_model: Callable[..., object],
|
||||
) -> object:
|
||||
snapshot = project_snapshot(project_id)
|
||||
normalized = normalized_project(project_id)
|
||||
root = (
|
||||
normalized_tree(normalized, object_limit_per_branch=max(0, object_limit_per_branch))
|
||||
if normalized is not None
|
||||
else snapshot_tree(snapshot, object_limit_per_branch=max(0, object_limit_per_branch))
|
||||
)
|
||||
return response_model(project_id=project_id, root=root)
|
||||
|
||||
|
||||
def metadata_tree_children(
|
||||
*,
|
||||
project_id: str,
|
||||
node_id: str,
|
||||
offset: int,
|
||||
limit: int,
|
||||
project_snapshot: Callable[[str], object],
|
||||
normalized_project: Callable[[str], object | None],
|
||||
normalized_children_for_node: Callable[..., tuple[list[object], int] | None],
|
||||
snapshot_children_for_node: Callable[..., tuple[list[object], int]],
|
||||
response_model: Callable[..., object],
|
||||
) -> object:
|
||||
snapshot = project_snapshot(project_id)
|
||||
normalized = normalized_project(project_id)
|
||||
normalized_offset = max(0, offset)
|
||||
normalized_limit = min(max(1, limit), 250)
|
||||
normalized_children = (
|
||||
normalized_children_for_node(
|
||||
normalized,
|
||||
node_id=node_id,
|
||||
offset=normalized_offset,
|
||||
limit=normalized_limit,
|
||||
)
|
||||
if normalized is not None
|
||||
else None
|
||||
)
|
||||
if normalized_children is None:
|
||||
children, total = snapshot_children_for_node(
|
||||
snapshot,
|
||||
node_id=node_id,
|
||||
offset=normalized_offset,
|
||||
limit=normalized_limit,
|
||||
)
|
||||
else:
|
||||
children, total = normalized_children
|
||||
return response_model(
|
||||
project_id=project_id,
|
||||
parent_id=node_id,
|
||||
offset=normalized_offset,
|
||||
limit=normalized_limit,
|
||||
total=total,
|
||||
has_more=normalized_offset + len(children) < total,
|
||||
children=children,
|
||||
)
|
||||
|
||||
|
||||
def metadata_tree_search(
|
||||
*,
|
||||
project_id: str,
|
||||
q: str,
|
||||
limit: int,
|
||||
project_snapshot: Callable[[str], object],
|
||||
is_search_node: Callable[[Any], bool],
|
||||
search_rank: Callable[[Any, str], object],
|
||||
child_count_index: Callable[[object, list[str]], object],
|
||||
node_for_search_result: Callable[[object, Any, object], object],
|
||||
response_model: Callable[..., object],
|
||||
) -> object:
|
||||
snapshot = project_snapshot(project_id)
|
||||
normalized_query = q.strip().casefold()
|
||||
normalized_limit = min(max(1, limit), 250)
|
||||
if len(normalized_query) < 2:
|
||||
return response_model(project_id=project_id, q=q, total=0, results=[])
|
||||
matches = [
|
||||
node
|
||||
for node in snapshot.nodes
|
||||
if is_search_node(node)
|
||||
and (
|
||||
normalized_query in node.name.casefold()
|
||||
or normalized_query in node.qualified_name.casefold()
|
||||
)
|
||||
]
|
||||
matches.sort(key=lambda item: search_rank(item, normalized_query))
|
||||
page = matches[:normalized_limit]
|
||||
counts = child_count_index(snapshot, [node.lineage_id for node in page])
|
||||
return response_model(
|
||||
project_id=project_id,
|
||||
q=q,
|
||||
total=len(matches),
|
||||
results=[node_for_search_result(snapshot, node, counts) for node in page],
|
||||
)
|
||||
|
||||
|
||||
def metadata_tree_path(
|
||||
*,
|
||||
project_id: str,
|
||||
node_id: str,
|
||||
project_snapshot: Callable[[str], object],
|
||||
tree_path_for_node: Callable[[object, str], list[str]],
|
||||
tree_path_steps: Callable[[object, list[str]], list[object]],
|
||||
response_model: Callable[..., object],
|
||||
) -> object:
|
||||
snapshot = project_snapshot(project_id)
|
||||
path = tree_path_for_node(snapshot, node_id)
|
||||
if not path:
|
||||
raise HTTPException(status_code=404, detail=f"Metadata tree path not found: {node_id}")
|
||||
return response_model(
|
||||
project_id=project_id,
|
||||
node_id=node_id,
|
||||
path=path,
|
||||
steps=tree_path_steps(snapshot, path),
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MetadataTreeNodeResponse(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
kind: str
|
||||
icon: str
|
||||
qualified_name: str | None = None
|
||||
count: int = 0
|
||||
loaded_count: int = 0
|
||||
has_more: bool = False
|
||||
children: list["MetadataTreeNodeResponse"] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ProjectMetadataTreeResponse(BaseModel):
|
||||
project_id: str
|
||||
root: MetadataTreeNodeResponse
|
||||
|
||||
|
||||
class MetadataTreeChildrenResponse(BaseModel):
|
||||
project_id: str
|
||||
parent_id: str
|
||||
offset: int = 0
|
||||
limit: int = 50
|
||||
total: int = 0
|
||||
has_more: bool = False
|
||||
children: list[MetadataTreeNodeResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MetadataTreeSearchResponse(BaseModel):
|
||||
project_id: str
|
||||
q: str
|
||||
total: int = 0
|
||||
results: list[MetadataTreeNodeResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MetadataTreePathStepResponse(BaseModel):
|
||||
parent_id: str
|
||||
child_id: str
|
||||
offset: int = 0
|
||||
|
||||
|
||||
class MetadataTreePathResponse(BaseModel):
|
||||
project_id: str
|
||||
node_id: str
|
||||
path: list[str] = Field(default_factory=list)
|
||||
steps: list[MetadataTreePathStepResponse] = Field(default_factory=list)
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from one_c_normalizer import MetadataObject
|
||||
|
||||
|
||||
class NormalizedObjectDetail(BaseModel):
|
||||
project_id: str | None = None
|
||||
group_name: str
|
||||
object: MetadataObject
|
||||
|
||||
|
||||
class ModuleRoutineResponse(BaseModel):
|
||||
name: str
|
||||
kind: str
|
||||
line_start: int | None = None
|
||||
line_end: int | None = None
|
||||
export: bool = False
|
||||
calls_count: int = 0
|
||||
queries_count: int = 0
|
||||
writes_count: int = 0
|
||||
calls: list[str] = Field(default_factory=list)
|
||||
queries: list[str] = Field(default_factory=list)
|
||||
writes: list[str] = Field(default_factory=list)
|
||||
impact_level: str = "LOW"
|
||||
impact_reasons: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ModuleSourceResponse(BaseModel):
|
||||
name: str
|
||||
qualified_name: str
|
||||
module_role: str = "MODULE"
|
||||
owner_qualified_name: str | None = None
|
||||
owner_kind: str | None = None
|
||||
object_part: str | None = None
|
||||
form_name: str | None = None
|
||||
form_qualified_name: str | None = None
|
||||
source_path: str
|
||||
source_text: str
|
||||
routines_count: int = 0
|
||||
routines: list[ModuleRoutineResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BslCompletionItemResponse(BaseModel):
|
||||
label: str
|
||||
kind: str = "VALUE"
|
||||
detail: str | None = None
|
||||
insert_text: str | None = None
|
||||
@@ -0,0 +1,190 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from api_server.normalized_object_models import (
|
||||
BslCompletionItemResponse,
|
||||
ModuleRoutineResponse,
|
||||
ModuleSourceResponse,
|
||||
NormalizedObjectDetail,
|
||||
)
|
||||
from api_server.normalized_project_service import normalized_all_groups
|
||||
from one_c_normalizer import NormalizedProject
|
||||
|
||||
|
||||
def normalized_object_detail(normalized: NormalizedProject, qualified_name: str) -> NormalizedObjectDetail | None:
|
||||
for group in normalized.configuration.groups:
|
||||
for item in group.objects:
|
||||
if item.qualified_name == qualified_name:
|
||||
return NormalizedObjectDetail(
|
||||
project_id=normalized.project_id,
|
||||
group_name=group.name,
|
||||
object=item,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def normalized_module_sources_for_object(normalized: NormalizedProject, qualified_name: str) -> list[ModuleSourceResponse]:
|
||||
normalized_query = qualified_name.strip().casefold()
|
||||
if not normalized_query:
|
||||
return []
|
||||
selected_module = None
|
||||
selected_owner = None
|
||||
selected_object = None
|
||||
for group in normalized_all_groups(normalized):
|
||||
for item in group.objects:
|
||||
if item.qualified_name.casefold() == normalized_query or item.name.casefold() == normalized_query:
|
||||
selected_object = item
|
||||
break
|
||||
for module in item.modules:
|
||||
module_keys = {
|
||||
str(module.qualified_name or "").casefold(),
|
||||
str(module.name or "").casefold(),
|
||||
str(module.source_path or "").casefold(),
|
||||
}
|
||||
if normalized_query in module_keys:
|
||||
selected_module = module
|
||||
selected_owner = item
|
||||
break
|
||||
if selected_object is not None or selected_module is not None:
|
||||
break
|
||||
if selected_object is not None or selected_module is not None:
|
||||
break
|
||||
if selected_module is not None:
|
||||
return [_normalized_module_source_response(selected_module, selected_owner)]
|
||||
if selected_object is None:
|
||||
return []
|
||||
return sorted(
|
||||
[_normalized_module_source_response(module, selected_object) for module in selected_object.modules],
|
||||
key=lambda item: (item.module_role, item.name),
|
||||
)
|
||||
|
||||
|
||||
def normalized_bsl_completion_items(
|
||||
normalized: NormalizedProject,
|
||||
receiver: str | None,
|
||||
qualified_name: str | None,
|
||||
) -> list[BslCompletionItemResponse]:
|
||||
receiver_key = (receiver or "").strip().casefold()
|
||||
qualified_key = (qualified_name or "").strip().casefold()
|
||||
items: list[BslCompletionItemResponse] = []
|
||||
for group in normalized_all_groups(normalized):
|
||||
for metadata_object in group.objects:
|
||||
object_names = {
|
||||
metadata_object.name.casefold(),
|
||||
metadata_object.qualified_name.casefold(),
|
||||
}
|
||||
if receiver_key and receiver_key in object_names:
|
||||
items.extend(
|
||||
BslCompletionItemResponse(
|
||||
label=part.name,
|
||||
kind=_completion_kind_for_part(part.kind),
|
||||
detail=f"{metadata_object.qualified_name}: {part.kind}",
|
||||
insert_text=part.name,
|
||||
)
|
||||
for part in [
|
||||
*metadata_object.attributes,
|
||||
*metadata_object.resources,
|
||||
*metadata_object.dimensions,
|
||||
*metadata_object.tabular_sections,
|
||||
*metadata_object.commands,
|
||||
]
|
||||
)
|
||||
for module in metadata_object.modules:
|
||||
module_names = {
|
||||
str(module.name or "").casefold(),
|
||||
str(module.qualified_name or "").casefold(),
|
||||
str(module.source_path or "").casefold(),
|
||||
f"{metadata_object.name}.{module.name}".casefold(),
|
||||
f"{metadata_object.qualified_name}.{module.name}".casefold(),
|
||||
}
|
||||
if receiver_key and receiver_key not in module_names and receiver_key not in object_names:
|
||||
continue
|
||||
if not receiver_key and qualified_key and qualified_key not in module_names and qualified_key not in object_names:
|
||||
continue
|
||||
source_text = str((module.attributes or {}).get("source_text", ""))
|
||||
for routine in normalized_module_routines(source_text):
|
||||
if receiver_key and not routine.export:
|
||||
continue
|
||||
items.append(
|
||||
BslCompletionItemResponse(
|
||||
label=routine.name,
|
||||
kind="FUNCTION" if routine.kind == "FUNCTION" else "PROCEDURE",
|
||||
detail=f"{module.qualified_name or module.name}{' · Export' if routine.export else ''}",
|
||||
insert_text=f"{routine.name}()",
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def _completion_kind_for_part(kind: str) -> str:
|
||||
normalized = kind.upper()
|
||||
if normalized in {"ATTRIBUTE", "RESOURCE", "DIMENSION", "FIELD"}:
|
||||
return "PROPERTY"
|
||||
if normalized in {"COMMAND", "METHOD", "OPERATION"}:
|
||||
return "METHOD"
|
||||
if normalized in {"TABULAR_SECTION", "TABLE"}:
|
||||
return "COLLECTION"
|
||||
return "VALUE"
|
||||
|
||||
|
||||
def _normalized_module_source_response(module, owner) -> ModuleSourceResponse:
|
||||
attributes = module.attributes or {}
|
||||
source_text = str(attributes.get("source_text", ""))
|
||||
routines = normalized_module_routines(source_text)
|
||||
module_role = str(module.module_kind or attributes.get("module_role") or "MODULE")
|
||||
return ModuleSourceResponse(
|
||||
name=module.name,
|
||||
qualified_name=module.qualified_name or module.name,
|
||||
module_role=module_role,
|
||||
owner_qualified_name=str(attributes.get("owner_qualified_name") or getattr(owner, "qualified_name", "") or "") or None,
|
||||
owner_kind=str(attributes.get("owner_kind") or getattr(owner, "object_kind", "") or "") or None,
|
||||
object_part=str(
|
||||
attributes.get("object_part")
|
||||
or module_object_part_for_response(module_role, str(attributes.get("form_name") or ""))
|
||||
),
|
||||
form_name=str(attributes.get("form_name") or "") or None,
|
||||
form_qualified_name=str(attributes.get("form_qualified_name") or "") or None,
|
||||
source_path=module.source_path or "",
|
||||
source_text=source_text,
|
||||
routines_count=len(routines),
|
||||
routines=routines,
|
||||
)
|
||||
|
||||
|
||||
def module_object_part_for_response(module_role: str, form_name: str = "") -> str:
|
||||
return {
|
||||
"OBJECT_MODULE": "object.module",
|
||||
"MANAGER_MODULE": "object.manager",
|
||||
"RECORD_SET_MODULE": "object.record_set",
|
||||
"FORM_MODULE": f"form.{form_name}.module" if form_name else "form.module",
|
||||
"MODULE": "module",
|
||||
}.get(module_role, "module")
|
||||
|
||||
|
||||
def normalized_module_routines(source_text: str) -> list[ModuleRoutineResponse]:
|
||||
if not source_text:
|
||||
return []
|
||||
declarations: list[tuple[int, re.Match[str]]] = []
|
||||
pattern = re.compile(
|
||||
r"^\s*(Процедура|Функция|Procedure|Function)\s+([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\s*\(([^)]*)\)\s*(.*)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
for match in pattern.finditer(source_text):
|
||||
line_start = source_text.count("\n", 0, match.start()) + 1
|
||||
declarations.append((line_start, match))
|
||||
routines: list[ModuleRoutineResponse] = []
|
||||
for index, (line_start, match) in enumerate(declarations):
|
||||
line_end = declarations[index + 1][0] - 1 if index + 1 < len(declarations) else len(source_text.splitlines())
|
||||
kind_label = match.group(1).casefold()
|
||||
tail = match.group(4) or ""
|
||||
routines.append(
|
||||
ModuleRoutineResponse(
|
||||
name=match.group(2),
|
||||
kind="FUNCTION" if kind_label in {"функция", "function"} else "PROCEDURE",
|
||||
line_start=line_start,
|
||||
line_end=line_end,
|
||||
export=bool(re.search(r"\b(Экспорт|Export)\b", tail, re.IGNORECASE)),
|
||||
)
|
||||
)
|
||||
return routines
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class NormalizedGroupSummary(BaseModel):
|
||||
name: str
|
||||
object_kind: str
|
||||
object_count: int
|
||||
|
||||
|
||||
class NormalizedProjectSummary(BaseModel):
|
||||
project_id: str | None = None
|
||||
source_path: str | None = None
|
||||
group_count: int = 0
|
||||
object_count: int = 0
|
||||
attribute_count: int = 0
|
||||
tabular_section_count: int = 0
|
||||
form_count: int = 0
|
||||
command_count: int = 0
|
||||
role_count: int = 0
|
||||
rights_count: int = 0
|
||||
access_profile_count: int = 0
|
||||
access_group_count: int = 0
|
||||
access_user_count: int = 0
|
||||
access_assignment_count: int = 0
|
||||
module_count: int = 0
|
||||
layout_count: int = 0
|
||||
movement_count: int = 0
|
||||
extension_count: int = 0
|
||||
extensions: list[str] = Field(default_factory=list)
|
||||
groups: list[NormalizedGroupSummary] = Field(default_factory=list)
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from api_server.normalized_project_models import NormalizedGroupSummary, NormalizedProjectSummary
|
||||
from one_c_normalizer import NormalizedProject
|
||||
|
||||
|
||||
def normalized_all_groups(normalized: NormalizedProject):
|
||||
groups = list(normalized.configuration.groups)
|
||||
for extension in normalized.configuration.extensions:
|
||||
groups.extend(extension.groups)
|
||||
return groups
|
||||
|
||||
|
||||
def normalized_project_summary(normalized: NormalizedProject) -> NormalizedProjectSummary:
|
||||
all_groups = normalized_all_groups(normalized)
|
||||
objects = [item for group in all_groups for item in group.objects]
|
||||
return NormalizedProjectSummary(
|
||||
project_id=normalized.project_id,
|
||||
source_path=normalized.source_path,
|
||||
group_count=len(all_groups),
|
||||
object_count=len(objects),
|
||||
attribute_count=sum(
|
||||
len(item.attributes)
|
||||
+ sum(normalized_part_descendant_count(section, "ATTRIBUTE") for section in item.tabular_sections)
|
||||
+ sum(normalized_part_descendant_count(form, "ATTRIBUTE") for form in item.forms)
|
||||
for item in objects
|
||||
),
|
||||
tabular_section_count=sum(len(item.tabular_sections) for item in objects),
|
||||
form_count=sum(len(item.forms) for item in objects),
|
||||
command_count=sum(len(item.commands) for item in objects),
|
||||
role_count=sum(1 for item in objects if item.object_kind == "ROLE"),
|
||||
rights_count=sum(len(item.rights) for item in objects),
|
||||
access_profile_count=len(normalized.access.profiles),
|
||||
access_group_count=len(normalized.access.groups),
|
||||
access_user_count=len(normalized.access.users),
|
||||
access_assignment_count=sum(len(item.roles) for item in normalized.access.profiles)
|
||||
+ sum(len(item.roles) for item in normalized.access.groups)
|
||||
+ sum(len(item.roles) for item in normalized.access.users)
|
||||
+ sum(len(item.users) for item in normalized.access.groups),
|
||||
module_count=sum(len(item.modules) for item in objects),
|
||||
layout_count=sum(len(item.layouts) for item in objects),
|
||||
movement_count=sum(len(item.movements) for item in objects),
|
||||
extension_count=len(normalized.configuration.extensions),
|
||||
extensions=[item.name for item in normalized.configuration.extensions],
|
||||
groups=[
|
||||
NormalizedGroupSummary(
|
||||
name=group.name,
|
||||
object_kind=", ".join(group.object_kinds),
|
||||
object_count=len(group.objects),
|
||||
)
|
||||
for group in all_groups
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def normalized_part_descendant_count(part, kind: str | None = None) -> int:
|
||||
children = getattr(part, "children", [])
|
||||
return sum(
|
||||
(1 if kind is None or child.kind == kind else 0) + normalized_part_descendant_count(child, kind)
|
||||
for child in children
|
||||
)
|
||||
@@ -0,0 +1,170 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def is_unc_path(path: str) -> bool:
|
||||
return path.startswith("\\\\")
|
||||
|
||||
|
||||
def copy_smb_tree_to_local(
|
||||
*,
|
||||
source: str,
|
||||
target: Path,
|
||||
username: str,
|
||||
password: str,
|
||||
domain: str | None = None,
|
||||
) -> None:
|
||||
smbclient = _smbclient()
|
||||
server, _share, _relative = parse_unc_path(source)
|
||||
_register_session(smbclient, server=server, username=username, password=password, domain=domain)
|
||||
_copy_smb_directory(smbclient, source.rstrip("\\"), target)
|
||||
|
||||
|
||||
def copy_local_tree_to_smb(
|
||||
*,
|
||||
source: Path,
|
||||
target: str,
|
||||
username: str,
|
||||
password: str,
|
||||
domain: str | None = None,
|
||||
) -> None:
|
||||
smbclient = _smbclient()
|
||||
server, _share, _relative = parse_unc_path(target)
|
||||
_register_session(smbclient, server=server, username=username, password=password, domain=domain)
|
||||
remote_root = target.rstrip("\\")
|
||||
_ensure_smb_directory(smbclient, remote_root)
|
||||
if source.is_file():
|
||||
_copy_local_file_to_smb(smbclient, source, f"{remote_root}\\{source.name}")
|
||||
return
|
||||
for path in sorted(source.rglob("*")):
|
||||
relative = path.relative_to(source)
|
||||
remote_relative = str(relative).replace("/", "\\")
|
||||
remote = f"{remote_root}\\{remote_relative}"
|
||||
if path.is_dir():
|
||||
_ensure_smb_directory(smbclient, remote)
|
||||
elif path.is_file():
|
||||
_ensure_smb_directory(smbclient, remote.rsplit("\\", 1)[0])
|
||||
_copy_local_file_to_smb(smbclient, path, remote)
|
||||
|
||||
|
||||
def parse_unc_path(path: str) -> tuple[str, str, str]:
|
||||
parts = [part for part in path.strip("\\").split("\\") if part]
|
||||
if len(parts) < 2:
|
||||
raise ValueError("UNC путь должен содержать сервер и share: \\\\server\\share.")
|
||||
server, share = parts[0], parts[1]
|
||||
relative = "\\".join(parts[2:])
|
||||
return server, share, relative
|
||||
|
||||
|
||||
def remove_tree(path: Path, *, expected_parent: Path) -> None:
|
||||
resolved = path.resolve()
|
||||
parent = expected_parent.resolve()
|
||||
if resolved == parent or parent not in resolved.parents:
|
||||
raise ValueError(f"Refusing to remove path outside work root: {resolved}")
|
||||
if resolved.exists():
|
||||
shutil.rmtree(resolved)
|
||||
|
||||
|
||||
def _smbclient() -> Any:
|
||||
try:
|
||||
import smbclient
|
||||
except ImportError as error:
|
||||
raise RuntimeError("SMB client dependency is not installed on the API server.") from error
|
||||
return smbclient
|
||||
|
||||
|
||||
def _register_session(smbclient: Any, *, server: str, username: str, password: str, domain: str | None) -> None:
|
||||
effective_domain, effective_username = _normalize_credentials(username, domain)
|
||||
qualified_user = f"{effective_domain}\\{effective_username}" if effective_domain else effective_username
|
||||
try:
|
||||
smbclient.register_session(server, username=qualified_user, password=password)
|
||||
except Exception as error: # pragma: no cover - depends on smb backend details
|
||||
raise RuntimeError(_translate_smb_error(error, server=server, username=qualified_user)) from error
|
||||
|
||||
|
||||
def _copy_smb_directory(smbclient: Any, source: str, target: Path) -> None:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
items = list(smbclient.scandir(source))
|
||||
except Exception as error: # pragma: no cover - depends on smb backend details
|
||||
raise RuntimeError(_translate_smb_error(error, path=source)) from error
|
||||
for item in items:
|
||||
destination = target / item.name
|
||||
child_source = f"{source}\\{item.name}"
|
||||
try:
|
||||
if item.is_dir():
|
||||
_copy_smb_directory(smbclient, child_source, destination)
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
with smbclient.open_file(child_source, mode="rb") as remote_file:
|
||||
with destination.open("wb") as local_file:
|
||||
shutil.copyfileobj(remote_file, local_file, length=1024 * 1024)
|
||||
|
||||
|
||||
def _copy_local_file_to_smb(smbclient: Any, source: Path, target: str) -> None:
|
||||
try:
|
||||
with source.open("rb") as local_file:
|
||||
with smbclient.open_file(target, mode="wb") as remote_file:
|
||||
shutil.copyfileobj(local_file, remote_file, length=1024 * 1024)
|
||||
except Exception as error: # pragma: no cover - depends on smb backend details
|
||||
raise RuntimeError(_translate_smb_error(error, path=target)) from error
|
||||
|
||||
|
||||
def _ensure_smb_directory(smbclient: Any, path: str) -> None:
|
||||
normalized = path.rstrip("\\")
|
||||
try:
|
||||
if smbclient.path.isdir(normalized):
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
parent = _unc_parent_path(normalized)
|
||||
if parent and parent != normalized:
|
||||
_ensure_smb_directory(smbclient, parent)
|
||||
try:
|
||||
smbclient.mkdir(normalized)
|
||||
except OSError as error:
|
||||
if not smbclient.path.isdir(normalized):
|
||||
raise RuntimeError(_translate_smb_error(error, path=normalized)) from error
|
||||
|
||||
|
||||
def _unc_parent_path(path: str) -> str | None:
|
||||
server, share, relative = parse_unc_path(path)
|
||||
if not relative:
|
||||
return None
|
||||
parts = [part for part in relative.split("\\") if part]
|
||||
if len(parts) <= 1:
|
||||
return f"\\\\{server}\\{share}"
|
||||
parent_relative = "\\".join(parts[:-1])
|
||||
return f"\\\\{server}\\{share}\\{parent_relative}"
|
||||
|
||||
|
||||
def _normalize_credentials(username: str, domain: str | None) -> tuple[str | None, str]:
|
||||
raw_username = username.strip()
|
||||
raw_domain = (domain or "").strip() or None
|
||||
if "\\" in raw_username:
|
||||
embedded_domain, embedded_username = raw_username.split("\\", 1)
|
||||
return embedded_domain.strip() or raw_domain, embedded_username.strip()
|
||||
if "@" in raw_username and not raw_domain:
|
||||
embedded_username, embedded_domain = raw_username.split("@", 1)
|
||||
return embedded_domain.strip() or None, embedded_username.strip()
|
||||
return raw_domain, raw_username
|
||||
|
||||
|
||||
def _translate_smb_error(error: Exception, *, server: str | None = None, path: str | None = None, username: str | None = None) -> str:
|
||||
message = str(error).strip()
|
||||
lowered = message.casefold()
|
||||
target = path or server or "сетевой ресурс"
|
||||
if any(token in lowered for token in ["logon failure", "access denied", "authentication", "STATUS_LOGON_FAILURE".casefold(), "STATUS_ACCESS_DENIED".casefold()]):
|
||||
user_part = f" для пользователя {username}" if username else ""
|
||||
return f"Ошибка авторизации SMB{user_part}. Проверьте логин, пароль, домен и права доступа к {target}."
|
||||
if any(token in lowered for token in ["bad network name", "object name not found", "path not found", "no such file", "STATUS_OBJECT_PATH_NOT_FOUND".casefold(), "STATUS_BAD_NETWORK_NAME".casefold()]):
|
||||
return f"Сетевой путь не найден или недоступен: {target}."
|
||||
if any(token in lowered for token in ["connection reset", "connection refused", "timed out", "host is down", "network name deleted", "network path was not found"]):
|
||||
return f"Не удалось подключиться к сетевому ресурсу {target}. Проверьте доступность сервера и сети."
|
||||
if message:
|
||||
return f"Ошибка SMB при обращении к {target}: {message}"
|
||||
return f"Неизвестная ошибка SMB при обращении к {target}."
|
||||
@@ -0,0 +1,309 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from api_server.normalized_object_models import BslCompletionItemResponse, ModuleRoutineResponse, ModuleSourceResponse
|
||||
from api_server.normalized_object_service import module_object_part_for_response
|
||||
from sir import EdgeKind, NodeKind, SirSnapshot
|
||||
|
||||
|
||||
def snapshot_bsl_completion_items(snapshot: SirSnapshot, receiver: str | None) -> list[BslCompletionItemResponse]:
|
||||
receiver_key = (receiver or "").strip().casefold()
|
||||
nodes_by_lineage = {node.lineage_id: node for node in snapshot.nodes}
|
||||
module_lineages: set[str] = set()
|
||||
if receiver_key:
|
||||
for node in snapshot.nodes:
|
||||
if node.kind == NodeKind.MODULE and (
|
||||
node.name.casefold() == receiver_key
|
||||
or node.qualified_name.casefold() == receiver_key
|
||||
or str(node.attributes.get("source_path", "")).casefold() == receiver_key
|
||||
):
|
||||
module_lineages.add(node.lineage_id)
|
||||
else:
|
||||
module_lineages = {node.lineage_id for node in snapshot.nodes if node.kind == NodeKind.MODULE}
|
||||
items: list[BslCompletionItemResponse] = []
|
||||
for module_lineage in module_lineages:
|
||||
module = nodes_by_lineage.get(module_lineage)
|
||||
if module is None:
|
||||
continue
|
||||
for routine in module_routines(snapshot, module_lineage, nodes_by_lineage):
|
||||
if receiver_key and not routine.export:
|
||||
continue
|
||||
items.append(
|
||||
BslCompletionItemResponse(
|
||||
label=routine.name,
|
||||
kind="FUNCTION" if routine.kind == "FUNCTION" else "PROCEDURE",
|
||||
detail=f"{module.qualified_name}{' · Export' if routine.export else ''}",
|
||||
insert_text=f"{routine.name}()",
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def module_sources_for_object(
|
||||
snapshot: SirSnapshot,
|
||||
qualified_name: str,
|
||||
owner_node_kinds: set[NodeKind],
|
||||
) -> list[ModuleSourceResponse]:
|
||||
nodes_by_lineage = {node.lineage_id: node for node in snapshot.nodes}
|
||||
selected_routine = next(
|
||||
(
|
||||
node
|
||||
for node in snapshot.nodes
|
||||
if node.kind in {NodeKind.PROCEDURE, NodeKind.FUNCTION}
|
||||
and (node.qualified_name == qualified_name or node.name == qualified_name)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if selected_routine is not None:
|
||||
module_edge = next(
|
||||
(
|
||||
edge
|
||||
for edge in snapshot.edges
|
||||
if edge.kind == EdgeKind.DECLARES and edge.target_lineage == selected_routine.lineage_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if module_edge is not None:
|
||||
module = nodes_by_lineage.get(module_edge.source_lineage)
|
||||
if module is not None:
|
||||
return module_source_response(snapshot, module, nodes_by_lineage)
|
||||
|
||||
selected_command = next(
|
||||
(
|
||||
node
|
||||
for node in snapshot.nodes
|
||||
if node.kind in {NodeKind.COMMAND, NodeKind.FORM, NodeKind.FORM_ELEMENT}
|
||||
and (node.qualified_name == qualified_name or node.name == qualified_name)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if selected_command is not None:
|
||||
handler_edge = next(
|
||||
(
|
||||
edge
|
||||
for edge in snapshot.edges
|
||||
if edge.kind == EdgeKind.HANDLES and edge.source_lineage == selected_command.lineage_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if handler_edge is not None:
|
||||
routine = nodes_by_lineage.get(handler_edge.target_lineage)
|
||||
module_edge = next(
|
||||
(
|
||||
edge
|
||||
for edge in snapshot.edges
|
||||
if edge.kind == EdgeKind.DECLARES and routine is not None and edge.target_lineage == routine.lineage_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if module_edge is not None:
|
||||
module = nodes_by_lineage.get(module_edge.source_lineage)
|
||||
if module is not None:
|
||||
return module_source_response(snapshot, module, nodes_by_lineage)
|
||||
|
||||
selected_module = next(
|
||||
(
|
||||
node
|
||||
for node in snapshot.nodes
|
||||
if node.kind == NodeKind.MODULE
|
||||
and (
|
||||
node.qualified_name == qualified_name
|
||||
or node.name == qualified_name
|
||||
or node.source_ref.source_path == qualified_name
|
||||
or module_metadata_qualified_name(snapshot, node, nodes_by_lineage) == qualified_name
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if selected_module is not None:
|
||||
return module_source_response(snapshot, selected_module, nodes_by_lineage)
|
||||
owner = next(
|
||||
(
|
||||
node
|
||||
for node in snapshot.nodes
|
||||
if node.qualified_name == qualified_name and node.kind in owner_node_kinds
|
||||
),
|
||||
None,
|
||||
)
|
||||
if owner is None:
|
||||
return []
|
||||
module_edges = [
|
||||
edge
|
||||
for edge in snapshot.edges
|
||||
if edge.kind == EdgeKind.CONTAINS
|
||||
and edge.source_lineage == owner.lineage_id
|
||||
and edge.attributes.get("link_type") == "METADATA_MODULE"
|
||||
]
|
||||
modules: list[ModuleSourceResponse] = []
|
||||
for edge in module_edges:
|
||||
module = nodes_by_lineage.get(edge.target_lineage)
|
||||
if module is None or module.kind != NodeKind.MODULE:
|
||||
continue
|
||||
routines = module_routines(snapshot, module.lineage_id, nodes_by_lineage)
|
||||
module_role = str(edge.attributes.get("module_role") or module.attributes.get("module_role") or "MODULE")
|
||||
modules.append(
|
||||
ModuleSourceResponse(
|
||||
name=module.name,
|
||||
qualified_name=module.qualified_name,
|
||||
module_role=module_role,
|
||||
owner_qualified_name=str(module.attributes.get("owner_qualified_name") or owner.qualified_name or "") or None,
|
||||
owner_kind=str(module.attributes.get("owner_kind") or owner.kind.value or "") or None,
|
||||
object_part=str(
|
||||
edge.attributes.get("object_part")
|
||||
or module.attributes.get("object_part")
|
||||
or module_object_part_for_response(module_role, str(module.attributes.get("form_name") or ""))
|
||||
),
|
||||
form_name=str(edge.attributes.get("form_name") or module.attributes.get("form_name") or "") or None,
|
||||
form_qualified_name=str(module.attributes.get("form_qualified_name") or "") or None,
|
||||
source_path=module.source_ref.source_path,
|
||||
source_text=str(module.attributes.get("source_text", "")),
|
||||
routines_count=len(routines),
|
||||
routines=routines,
|
||||
)
|
||||
)
|
||||
return sorted(modules, key=lambda item: (item.module_role, item.name))
|
||||
|
||||
|
||||
def module_source_response(
|
||||
snapshot: SirSnapshot,
|
||||
module,
|
||||
nodes_by_lineage: dict[str, object],
|
||||
) -> list[ModuleSourceResponse]:
|
||||
owner_edge = next(
|
||||
(
|
||||
edge
|
||||
for edge in snapshot.edges
|
||||
if edge.kind == EdgeKind.CONTAINS
|
||||
and edge.target_lineage == module.lineage_id
|
||||
and edge.attributes.get("link_type") == "METADATA_MODULE"
|
||||
),
|
||||
None,
|
||||
)
|
||||
routines = module_routines(snapshot, module.lineage_id, nodes_by_lineage)
|
||||
owner = nodes_by_lineage.get(owner_edge.source_lineage) if owner_edge else None
|
||||
module_role = str((owner_edge.attributes.get("module_role") if owner_edge else None) or module.attributes.get("module_role") or "MODULE")
|
||||
return [
|
||||
ModuleSourceResponse(
|
||||
name=module.name,
|
||||
qualified_name=module.qualified_name,
|
||||
module_role=module_role,
|
||||
owner_qualified_name=str(module.attributes.get("owner_qualified_name") or getattr(owner, "qualified_name", "") or "") or None,
|
||||
owner_kind=str(module.attributes.get("owner_kind") or getattr(getattr(owner, "kind", None), "value", "") or "") or None,
|
||||
object_part=str(
|
||||
(owner_edge.attributes.get("object_part") if owner_edge else None)
|
||||
or module.attributes.get("object_part")
|
||||
or module_object_part_for_response(module_role, str(module.attributes.get("form_name") or ""))
|
||||
),
|
||||
form_name=str((owner_edge.attributes.get("form_name") if owner_edge else None) or module.attributes.get("form_name") or "") or None,
|
||||
form_qualified_name=str(module.attributes.get("form_qualified_name") or "") or None,
|
||||
source_path=module.source_ref.source_path,
|
||||
source_text=str(module.attributes.get("source_text", "")),
|
||||
routines_count=len(routines),
|
||||
routines=routines,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def module_metadata_qualified_name(
|
||||
snapshot: SirSnapshot,
|
||||
module,
|
||||
nodes_by_lineage: dict[str, object],
|
||||
) -> str | None:
|
||||
owner_edge = next(
|
||||
(
|
||||
edge
|
||||
for edge in snapshot.edges
|
||||
if edge.kind == EdgeKind.CONTAINS
|
||||
and edge.target_lineage == module.lineage_id
|
||||
and edge.attributes.get("link_type") == "METADATA_MODULE"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if owner_edge is None:
|
||||
return None
|
||||
owner = nodes_by_lineage.get(owner_edge.source_lineage)
|
||||
if owner is None:
|
||||
return None
|
||||
role = str(owner_edge.attributes.get("module_role", "MODULE"))
|
||||
form_name = str(owner_edge.attributes.get("form_name", ""))
|
||||
suffix = {
|
||||
"OBJECT_MODULE": "МодульОбъекта",
|
||||
"MANAGER_MODULE": "МодульМенеджера",
|
||||
"RECORD_SET_MODULE": "МодульНабораЗаписей",
|
||||
"FORM_MODULE": f"Форма.{form_name}.Модуль" if form_name else "МодульФормы",
|
||||
"MODULE": "Модуль",
|
||||
}.get(role, module.name)
|
||||
return f"{owner.qualified_name}.{suffix}"
|
||||
|
||||
|
||||
def module_routines(
|
||||
snapshot: SirSnapshot,
|
||||
module_lineage: str,
|
||||
nodes_by_lineage: dict[str, object],
|
||||
) -> list[ModuleRoutineResponse]:
|
||||
routines: list[ModuleRoutineResponse] = []
|
||||
for edge in snapshot.edges:
|
||||
if edge.kind != EdgeKind.DECLARES or edge.source_lineage != module_lineage:
|
||||
continue
|
||||
routine = nodes_by_lineage.get(edge.target_lineage)
|
||||
if routine is None or getattr(routine, "kind", None) not in {NodeKind.PROCEDURE, NodeKind.FUNCTION}:
|
||||
continue
|
||||
calls = _routine_relation_values(snapshot, nodes_by_lineage, routine.lineage_id, EdgeKind.CALLS)
|
||||
queries = _routine_relation_values(snapshot, nodes_by_lineage, routine.lineage_id, EdgeKind.OWNS_QUERY)
|
||||
writes = _routine_relation_values(snapshot, nodes_by_lineage, routine.lineage_id, EdgeKind.WRITES)
|
||||
impact_level, impact_reasons = _routine_impact_markers(calls, queries, writes)
|
||||
routines.append(
|
||||
ModuleRoutineResponse(
|
||||
name=routine.name,
|
||||
kind=routine.kind.value,
|
||||
line_start=routine.source_ref.line_start,
|
||||
line_end=routine.source_ref.line_end,
|
||||
export=bool(routine.attributes.get("export", False)),
|
||||
calls_count=len(calls),
|
||||
queries_count=len(queries),
|
||||
writes_count=len(writes),
|
||||
calls=calls,
|
||||
queries=queries,
|
||||
writes=writes,
|
||||
impact_level=impact_level,
|
||||
impact_reasons=impact_reasons,
|
||||
)
|
||||
)
|
||||
return sorted(routines, key=lambda item: item.line_start or 0)
|
||||
|
||||
|
||||
def _routine_impact_markers(calls: list[str], queries: list[str], writes: list[str]) -> tuple[str, list[str]]:
|
||||
reasons: list[str] = []
|
||||
if writes:
|
||||
reasons.append("writes data")
|
||||
if queries:
|
||||
reasons.append("reads query tables")
|
||||
if len(calls) >= 3:
|
||||
reasons.append("fan-out calls")
|
||||
if writes and (queries or len(calls) >= 2):
|
||||
level = "HIGH"
|
||||
elif writes or queries or len(calls) >= 3:
|
||||
level = "MEDIUM"
|
||||
else:
|
||||
level = "LOW"
|
||||
return level, reasons
|
||||
|
||||
|
||||
def _routine_relation_values(
|
||||
snapshot: SirSnapshot,
|
||||
nodes_by_lineage: dict[str, object],
|
||||
routine_lineage: str,
|
||||
relation: EdgeKind,
|
||||
) -> list[str]:
|
||||
values: list[str] = []
|
||||
for edge in snapshot.edges:
|
||||
if edge.kind != relation or edge.source_lineage != routine_lineage:
|
||||
continue
|
||||
target = nodes_by_lineage.get(edge.target_lineage)
|
||||
if target is None:
|
||||
continue
|
||||
if relation == EdgeKind.OWNS_QUERY:
|
||||
query_text = str(target.attributes.get("query_text", "")).strip()
|
||||
values.append(query_text or target.name)
|
||||
else:
|
||||
values.append(target.qualified_name or target.name)
|
||||
return values
|
||||
@@ -0,0 +1,125 @@
|
||||
(function () {
|
||||
const jobs = new WeakMap();
|
||||
|
||||
function formatClock(seconds) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const rest = seconds % 60;
|
||||
return String(minutes).padStart(2, "0") + ":" + String(rest).padStart(2, "0");
|
||||
}
|
||||
|
||||
function formatHuman(seconds) {
|
||||
if (seconds < 60) {
|
||||
return seconds + " сек";
|
||||
}
|
||||
return Math.floor(seconds / 60) + " мин " + String(seconds % 60).padStart(2, "0");
|
||||
}
|
||||
|
||||
function stageFor(seconds) {
|
||||
if (seconds < 10) {
|
||||
return "Проверка путей и учетных данных";
|
||||
}
|
||||
if (seconds < 45) {
|
||||
return "Копирование входных файлов";
|
||||
}
|
||||
if (seconds < 120) {
|
||||
return "Разбор структуры 1С";
|
||||
}
|
||||
if (seconds < 240) {
|
||||
return "Построение индексов для ИИ";
|
||||
}
|
||||
return "Запись Codex-пакета";
|
||||
}
|
||||
|
||||
function etaFor(seconds) {
|
||||
if (seconds < 10) {
|
||||
return "1-5 мин";
|
||||
}
|
||||
if (seconds < 45) {
|
||||
return "до 5 мин";
|
||||
}
|
||||
if (seconds < 120) {
|
||||
return "2-6 мин";
|
||||
}
|
||||
if (seconds < 300) {
|
||||
return "еще несколько минут";
|
||||
}
|
||||
return "зависит от размера SMB-папки";
|
||||
}
|
||||
|
||||
function update(progress, startedAt) {
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - startedAt) / 1000));
|
||||
const elapsed = progress.querySelector("[data-ai-structure-elapsed]");
|
||||
const elapsedLabel = progress.querySelector("[data-ai-structure-elapsed-label]");
|
||||
const eta = progress.querySelector("[data-ai-structure-eta]");
|
||||
const stage = progress.querySelector("[data-ai-structure-stage]");
|
||||
const bar = progress.querySelector("[data-ai-structure-bar]");
|
||||
if (elapsed) {
|
||||
elapsed.textContent = formatClock(seconds);
|
||||
}
|
||||
if (elapsedLabel) {
|
||||
elapsedLabel.textContent = formatHuman(seconds);
|
||||
}
|
||||
if (eta) {
|
||||
eta.textContent = etaFor(seconds);
|
||||
}
|
||||
if (stage) {
|
||||
stage.textContent = stageFor(seconds);
|
||||
}
|
||||
if (bar) {
|
||||
bar.style.width = Math.min(92, 8 + seconds * 0.35) + "%";
|
||||
}
|
||||
}
|
||||
|
||||
function start(form) {
|
||||
const progress = document.querySelector("[data-ai-structure-progress]");
|
||||
if (!progress) {
|
||||
return;
|
||||
}
|
||||
const previous = jobs.get(form);
|
||||
if (previous) {
|
||||
window.clearInterval(previous.timer);
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
progress.hidden = false;
|
||||
progress.setAttribute("data-ai-structure-progress-state", "running");
|
||||
update(progress, startedAt);
|
||||
const timer = window.setInterval(function () {
|
||||
update(progress, startedAt);
|
||||
}, 1000);
|
||||
jobs.set(form, { progress: progress, timer: timer });
|
||||
}
|
||||
|
||||
function stop(form) {
|
||||
const job = jobs.get(form);
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
window.clearInterval(job.timer);
|
||||
job.progress.setAttribute("data-ai-structure-progress-state", "done");
|
||||
window.setTimeout(function () {
|
||||
job.progress.hidden = true;
|
||||
}, 500);
|
||||
jobs.delete(form);
|
||||
}
|
||||
|
||||
document.addEventListener("htmx:beforeRequest", function (event) {
|
||||
const form = event.target && event.target.closest ? event.target.closest(".ai-structure-form") : null;
|
||||
if (form) {
|
||||
start(form);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("htmx:afterRequest", function (event) {
|
||||
const form = event.target && event.target.closest ? event.target.closest(".ai-structure-form") : null;
|
||||
if (form) {
|
||||
stop(form);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("htmx:sendError", function (event) {
|
||||
const form = event.target && event.target.closest ? event.target.closest(".ai-structure-form") : null;
|
||||
if (form) {
|
||||
stop(form);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,31 @@
|
||||
:root{color-scheme:light;--bg:#f7f8fb;--card:#fff;--text:#17202a;--muted:#687385;--line:#dce2ea;--brand:#2457d6;--ok:#168457;--warn:#a16207}
|
||||
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.45 system-ui,-apple-system,Segoe UI,sans-serif}a{color:inherit}
|
||||
.shell{min-height:100vh;padding:32px}.hero{display:flex;justify-content:space-between;gap:24px;align-items:end;margin:0 auto 24px;max-width:1180px}.hero h1{margin:0;font-size:36px;letter-spacing:0}.lead{max-width:640px;color:var(--muted);font-size:16px}.eyebrow{margin:0 0 8px;color:var(--brand);font-size:12px;font-weight:800;text-transform:uppercase}.hero-metrics{min-width:160px;border:1px solid var(--line);background:var(--card);padding:18px}.hero-metrics strong{display:block;font-size:34px}.hero-metrics span,.muted{color:var(--muted)}.toolbar-links{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.band,.panel,.editor{border:1px solid var(--line);background:var(--card)}.band{max-width:1180px;margin:auto;padding:18px}.section-title,.topbar,.editor-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.button,button{display:inline-flex;align-items:center;justify-content:center;height:32px;border:1px solid var(--line);background:#fff;padding:0 12px;text-decoration:none;font-weight:700;cursor:pointer}table{width:100%;border-collapse:collapse}th,td{border-top:1px solid var(--line);padding:12px;text-align:left}td small{display:block;color:var(--muted)}td .button,td form{margin-right:6px}.delete-project{display:inline-flex;gap:4px;vertical-align:middle}.delete-project input{height:32px;width:120px;border:1px solid var(--line);padding:0 6px}
|
||||
.workspace{min-height:100vh;padding-bottom:34px}.topbar{position:sticky;top:0;z-index:2;height:54px;border-bottom:1px solid var(--line);background:rgba(255,255,255,.94);padding:0 14px}.brand{font-weight:900;text-decoration:none;color:var(--brand)}.project-nav{display:flex;gap:6px;overflow:auto;flex:1}.project-link{white-space:nowrap;text-decoration:none;border:1px solid var(--line);padding:6px 10px;background:#fff}.project-link.active{background:var(--brand);border-color:var(--brand);color:#fff}
|
||||
.layout{display:grid;grid-template-columns:280px minmax(0,1fr)320px;height:calc(100vh - 88px)}.panel{overflow:auto}.panel-title{padding:12px;border-bottom:1px solid var(--line);font-size:12px;font-weight:900;text-transform:uppercase;color:var(--muted)}.tree-item{display:block;padding:10px 12px;border-bottom:1px solid var(--line);text-decoration:none}.tree-item span{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tree-item small{color:var(--muted)}
|
||||
.editor{min-width:0;overflow:hidden;border-top:0;border-bottom:0}.editor-head{height:72px;border-bottom:1px solid var(--line);padding:0 14px}.editor-head h1{margin:0;font-size:18px}.search{display:flex;gap:6px}.search input{height:32px;width:260px;border:1px solid var(--line);padding:0 10px}.source-panel{height:calc(100% - 72px);display:grid;grid-template-rows:auto auto auto minmax(0,1fr);overflow:hidden}.source-head{display:flex;justify-content:space-between;gap:12px;align-items:center;min-height:54px;padding:10px 14px;border-bottom:1px solid var(--line);background:#fff}.source-head strong,.source-head small{display:block}.source-head small{color:var(--muted)}.source-head dl{display:flex;gap:12px;margin:0}.source-head div div{padding:0}.source-head dt{font-size:11px;color:var(--muted)}.source-head dd{margin:0;font-weight:800}.source-summary,.object-cache{margin:0;padding:8px 14px;border-bottom:1px solid var(--line);background:#fffdf8;color:var(--muted);font-size:12px;font-weight:800;line-height:1.45}.object-cache{background:#f8fbff}.code{height:100%;margin:0;overflow:auto;padding:16px;background:#fbfaf7;font:13px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap}
|
||||
.metrics{display:grid;grid-template-columns:1fr 1fr;margin:0}.metrics div{padding:12px;border-bottom:1px solid var(--line)}.metrics dt{color:var(--muted);font-size:12px}.metrics dd{margin:2px 0 0;font-size:22px;font-weight:800}.compact{list-style:none;margin:0;padding:0}.compact li{display:flex;justify-content:space-between;padding:8px 12px;border-bottom:1px solid var(--line)}.object-actions{display:flex;gap:6px;flex-wrap:wrap;padding:10px 12px;border-bottom:1px solid var(--line);background:#fbfcfe}.object-actions .button{height:28px;padding:0 9px;font-size:12px}.object-actions .button[data-html5-object-action-active="true"],.object-actions .button[aria-current="page"]{background:var(--brand);border-color:var(--brand);color:#fff}.object-breadcrumb{display:flex;gap:6px;flex-wrap:wrap;padding:9px 12px;border-bottom:1px solid var(--line);background:#fff;font-size:12px;font-weight:800;color:var(--muted)}.object-breadcrumb span:not(:last-child)::after{content:"/";margin-left:6px;color:#98a2b3}.object-breadcrumb span:last-child{color:var(--ink)}.object-summary,.symbol-summary,.review-summary,.project-summary,.object-report-summary,.authoring-summary{margin:0;padding:10px 12px;border-bottom:1px solid var(--line);background:#f8fbff;color:var(--muted);font-size:12px;font-weight:800;line-height:1.45}.symbol{display:grid;gap:3px;padding:10px 12px;border-bottom:1px solid var(--line);cursor:pointer}.symbol:hover{background:#f8fbff}.symbol span,.symbol small{color:var(--muted)}.symbol-focus,.symbol-reference,.object-focus,.object-context-item{display:grid;gap:3px;padding:10px 12px;border-bottom:1px solid var(--line)}.symbol-focus small,.symbol-reference span,.symbol-reference small,.object-focus span,.object-context-item small{color:var(--muted)}.inline-actions{display:flex;gap:8px;flex-wrap:wrap;font-size:12px;font-weight:800}.inline-actions a{color:var(--brand);text-decoration:none}.report-grid{display:grid;grid-template-columns:1fr 1fr;margin:0}.report-grid div{padding:10px 12px;border-bottom:1px solid var(--line)}.report-grid dt{color:var(--muted);font-size:12px}.report-grid dd{margin:2px 0 0;font-size:20px;font-weight:900}.review-item,.authoring-change{display:grid;gap:3px;padding:10px 12px;border-bottom:1px solid var(--line)}.authoring-change[hx-get]{cursor:pointer}.authoring-change[hx-get]:hover{background:#f8fbff}.review-item span,.review-item small,.authoring-change span,.authoring-change small{color:var(--muted)}.diff-item{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px;padding:8px 12px;border-bottom:1px solid var(--line)}.diff-item span{color:var(--muted);font-weight:800}.diff-item code{white-space:pre-wrap;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}.status{position:fixed;bottom:0;left:0;right:0;display:flex;gap:18px;overflow:auto;height:34px;align-items:center;border-top:1px solid var(--line);background:#fff;padding:0 12px;color:var(--muted);font-size:12px}.empty-state{max-width:720px;margin:80px auto;background:#fff;border:1px solid var(--line);padding:28px}
|
||||
.setup-layout{display:grid;grid-template-columns:340px minmax(0,1fr);gap:16px;max-width:1180px;margin:18px auto;padding:0 16px}.setup-workspace{background:#f3f6fb}.setup-card{padding:16px;border-bottom:1px solid var(--line)}.setup-card h1{margin:0;font-size:24px}.setup-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}.setup-main{min-height:620px}.settings-form{display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:8px;align-items:end;padding:12px 16px;border-bottom:1px solid var(--line);background:#fff}.settings-form label{display:grid;gap:4px;font-size:12px;font-weight:800;color:var(--muted);text-transform:uppercase}.settings-form input{height:32px;border:1px solid var(--line);padding:0 8px}.saved{float:right;color:var(--ok);font-weight:900}.setup-actions-panel,.ops-filter{display:flex;gap:8px;flex-wrap:wrap;align-items:end;padding:12px 16px;border-bottom:1px solid var(--line);background:#fbfcfe}.ops-filter input{height:32px;min-width:180px;border:1px solid var(--line);padding:0 8px}.inline-form{display:flex;gap:8px;align-items:end}.inline-form label{display:grid;gap:4px;font-size:12px;font-weight:800;color:var(--muted);text-transform:uppercase}.inline-form select{height:32px;min-width:240px;border:1px solid var(--line);background:#fff;padding:0 8px}.rollback-form,.authoring-preview-form{display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:12px;border-bottom:1px solid var(--line);background:#fbfcfe}.rollback-form input,.authoring-preview-form input,.authoring-preview-form textarea{min-width:0;border:1px solid var(--line);padding:0 8px}.rollback-form input,.authoring-preview-form input{height:32px}.authoring-preview-form textarea{grid-column:1/-1;min-height:88px;padding:8px;resize:vertical;font:12px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace}.rollback-form button,.authoring-preview-form button{grid-column:1/-1}.setup-summary{display:grid;gap:0}.compact-lead{margin:0;padding:0 16px 16px}.setup-metrics,.ops-summary{display:grid;grid-template-columns:repeat(4,1fr);margin:0;border-top:1px solid var(--line)}.ops-summary{grid-template-columns:repeat(5,1fr);margin:12px 0}.setup-metrics div,.ops-summary div{padding:16px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);background:#fff}.setup-metrics div:last-child,.ops-summary div:last-child{border-right:0}.setup-metrics dt,.ops-summary dt{font-size:12px;color:var(--muted)}.setup-metrics dd,.ops-summary dd{margin:4px 0 0;font-size:28px;font-weight:900}.status-pill{border:1px solid var(--line);padding:6px 10px;background:#eef6f1;color:var(--ok);font-weight:800}.flush{border-top:1px solid var(--line)}.padded{padding:12px 16px;margin:0}.setup-detail,.history-item,.source-card,.check-item{display:grid;gap:3px;padding:12px 16px;border-bottom:1px solid var(--line)}.setup-detail span,.setup-detail small,.history-item span,.history-item small,.source-card span,.source-card small,.check-item span,.check-item small,.check-head span,.check-head small{color:var(--muted)}.source-list,.history-list,.check-list{display:grid}.check-head{display:flex;gap:10px;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);background:#fff}.job-log{margin:0;padding:0;list-style:none}.job-log li{padding:8px 16px;border-bottom:1px solid var(--line);color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}
|
||||
.form-editor-layout{grid-template-columns:280px minmax(0,1.2fr) minmax(320px,.8fr)}.form-editor-actions{display:flex;gap:8px;flex-wrap:wrap}.tree-item[data-html5-form-selected="true"]{background:#f8fbff;border-left-color:var(--brand)}.form-designer{height:calc(100% - 72px);display:grid;grid-template-rows:auto minmax(0,1fr) auto;overflow:hidden;background:#f7f9fc}.form-designer-head,.form-designer-foot{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px;border-bottom:1px solid var(--line);background:#fff}.form-designer-head strong,.form-designer-head small{display:block}.form-designer-head small{color:var(--muted)}.form-designer-head span{border:1px solid var(--line);background:#eef6f1;color:var(--ok);padding:5px 8px;font-size:12px;font-weight:900}.form-designer-foot{border-top:1px solid var(--line);border-bottom:0}.form-canvas{min-height:0;overflow:auto;padding:18px}.form-window{max-width:720px;min-height:420px;margin:0 auto;border:1px solid var(--line);background:#fff;box-shadow:0 8px 24px rgba(15,23,42,.08)}.form-window-title{height:38px;display:flex;align-items:center;border-bottom:1px solid var(--line);padding:0 12px;font-weight:900;background:#fbfcfe}.form-command-bar{display:flex;gap:6px;flex-wrap:wrap;padding:10px 12px;border-bottom:1px solid var(--line)}.form-command-bar button{height:28px;font-size:12px}.form-fields{display:grid;gap:10px;padding:14px}.form-field{display:grid;grid-template-columns:160px minmax(0,1fr);gap:8px;align-items:center}.form-field span{font-size:12px;font-weight:800;color:var(--muted)}.form-field input{height:30px;border:1px solid var(--line);padding:0 8px;background:#fbfaf7}
|
||||
.form-designer-body{min-height:0;display:grid;grid-template-columns:minmax(0,1fr) 340px;overflow:hidden}.form-property-panel{min-height:0;overflow:auto;border-left:1px solid var(--line);background:#fff}.property-row{display:grid;grid-template-columns:1fr 132px;gap:8px;padding:12px;border-bottom:1px solid var(--line)}.property-row label,.form-editor-row label{display:grid;gap:4px;font-size:11px;font-weight:900;color:var(--muted);text-transform:uppercase}.property-row input,.property-row select,.form-editor-row input,.form-editor-row select,.form-add-row input,.form-add-row select{height:30px;min-width:0;border:1px solid var(--line);background:#fff;padding:0 8px;color:var(--text);font:13px/1.2 system-ui,-apple-system,Segoe UI,sans-serif;text-transform:none}.form-window{border-color:#b9c1cd;background:#fdfdfd;box-shadow:0 12px 28px rgba(33,43,54,.12)}.form-window-title{justify-content:space-between;height:34px;background:linear-gradient(#f8f9fb,#e9edf3);border-bottom-color:#cbd3df;color:#1f2937}.form-window-title small{font-size:11px;color:#687385;font-weight:800}.form-command-bar{min-height:42px;background:#f4f6f9;border-bottom-color:#ccd4df}.form-command-bar button{height:27px;border-color:#aeb8c6;background:linear-gradient(#fff,#edf1f6);box-shadow:inset 0 1px 0 #fff;color:#1f2937}.form-command-bar button:hover{background:#fff}.form-fields{grid-template-columns:repeat(12,minmax(0,1fr));align-content:start;gap:8px 12px;padding:14px 16px 22px;background:#fbfbfc}.form-field{grid-column:1/-1;grid-template-columns:150px minmax(0,1fr);min-height:32px;padding:4px 6px;border:1px solid transparent}.form-field:hover{border-color:#b9c1cd;background:#fff}.form-field[data-html5-form-width="half"]{grid-column:span 6}.form-field[data-html5-form-width="third"]{grid-column:span 4}.form-field>label{font-size:12px;font-weight:800;color:#4b5563;align-self:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.form-input-control,.form-text-control{width:100%;border:1px solid #aeb8c6;background:#fff;padding:0 7px;color:#17202a;box-shadow:inset 0 1px 2px rgba(15,23,42,.06)}.form-input-control{height:28px}.form-text-control{min-height:64px;resize:none}.form-check-control{display:flex;align-items:center;gap:8px;color:#374151}.form-check-control input{width:16px;height:16px}.form-table-control{border:1px solid #aeb8c6;background:#fff;min-height:108px;overflow:hidden}.form-table-control div{display:grid;grid-template-columns:2fr 1fr 1fr}.form-table-control span{min-height:27px;border-right:1px solid #d7dde6;border-bottom:1px solid #d7dde6;padding:5px 7px;color:#374151;font-size:12px}.form-table-control div:first-child span{background:#eef2f7;font-weight:900}.form-group-control{min-height:44px;border:1px dashed #aeb8c6;background:#f6f8fb;padding:10px;color:#687385;font-weight:800}.form-editor-row{display:grid;gap:7px;padding:10px 12px;border-bottom:1px solid var(--line)}label.form-editor-row{grid-template-columns:88px minmax(0,1fr);align-items:center;text-transform:none}.form-editor-row small{color:var(--muted);font-size:11px}.form-editor-row[data-html5-form-element-editor]{grid-template-columns:1fr 118px 1fr 104px}.form-add-row{display:grid;grid-template-columns:minmax(0,1fr) 118px;gap:8px;padding:12px;border-bottom:1px solid var(--line);background:#fbfcfe}.button.primary{background:var(--brand);border-color:var(--brand);color:#fff;margin:12px}.form-designer-foot small{color:var(--muted);font-weight:800}.form-window[data-html5-form-layout="compact"] .form-fields{gap:4px 8px}.form-window[data-html5-form-layout="compact"] .form-field{min-height:28px}.form-window[data-html5-form-layout="columns"] .form-field{grid-column:span 6}.form-window[data-html5-form-layout="columns"] .form-field[data-html5-form-control="table"],.form-window[data-html5-form-layout="columns"] .form-field[data-html5-form-control="group"]{grid-column:1/-1}
|
||||
.form-empty-structure{grid-column:1/-1;border:1px dashed #aeb8c6;background:#fff;padding:18px;color:#687385}.form-empty-structure strong,.form-empty-structure span{display:block}.form-empty-structure strong{color:#1f2937}
|
||||
.access-workspace{background:#f4f7fb}.access-layout{display:grid;grid-template-columns:300px minmax(0,1fr)340px;height:calc(100vh - 88px)}.access-main{border-top:0;border-bottom:0;overflow:auto}.access-nav,.access-side{overflow:auto}.tree-item[data-html5-access-profile-selected="true"]{background:#f8fbff;border-left:3px solid var(--brand);padding-left:9px}.access-empty{margin:16px;border:1px dashed #aeb8c6;background:#fff;padding:18px;color:#687385}.access-empty strong,.access-empty span{display:block}.access-empty strong{color:#1f2937}.access-profile,.access-plan,.access-result{border-bottom:1px solid var(--line);background:#fff}.access-summary{display:flex;gap:8px;flex-wrap:wrap;padding:10px 14px;border-bottom:1px solid var(--line);background:#f8fbff;color:#687385;font-size:12px;font-weight:800}.access-role-grid,.access-operations,.access-list{display:grid}.access-role-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.access-card{display:grid;gap:3px;min-width:0;padding:10px 12px;border-bottom:1px solid var(--line)}.access-role-grid .access-card:nth-child(odd){border-right:1px solid var(--line)}.access-card strong,.access-card small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.access-card small{color:var(--muted)}.access-plan-head{display:flex;gap:10px;align-items:center;justify-content:space-between;padding:10px 12px;border-bottom:1px solid var(--line);background:#fbfcfe}.access-plan-head form{margin:0}.access-actions{padding:12px}.access-warnings{margin:0;padding:0;list-style:none}.access-warnings li{padding:8px 12px;border-bottom:1px solid var(--line);color:var(--warn);font-weight:800}.access-json{margin:0;max-height:220px;overflow:auto;padding:12px;background:#fbfaf7;border-top:1px solid var(--line);font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap}.access-main .primary{background:var(--brand);border-color:var(--brand);color:#fff}
|
||||
.access-builder{border-bottom:1px solid var(--line);background:#fff}.access-builder-form{display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:12px;border-bottom:1px solid var(--line);background:#fbfcfe}.access-builder-form label{display:grid;gap:5px;font-size:11px;font-weight:900;color:var(--muted);text-transform:uppercase}.access-builder-form input,.access-builder-form textarea{min-width:0;border:1px solid var(--line);background:#fff;padding:0 8px;color:var(--text);font:13px/1.3 system-ui,-apple-system,Segoe UI,sans-serif;text-transform:none}.access-builder-form input{height:32px}.access-builder-form textarea{min-height:68px;padding:8px;resize:vertical}.access-builder-actions{grid-column:1/-1;display:flex;gap:8px;justify-content:flex-end}.access-builder-result{border-top:1px solid var(--line);background:#fff}
|
||||
.access-card[hx-get]{cursor:pointer}.access-card[hx-get]:hover{background:#f8fbff}.access-user-detail{border-bottom:1px solid var(--line);background:#fff}
|
||||
.ai-structure-form{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;align-items:end;padding:12px 16px;border-bottom:1px solid var(--line);background:#fbfcfe;overflow-x:hidden}.ai-structure-form label{display:grid;gap:5px;font-size:11px;font-weight:900;color:var(--muted);text-transform:uppercase;min-width:0}.ai-structure-form input{height:32px;min-width:0;width:100%;border:1px solid var(--line);background:#fff;padding:0 8px;color:var(--text);font:13px/1.3 system-ui,-apple-system,Segoe UI,sans-serif;text-transform:none}.ai-structure-field{min-width:0}.ai-structure-field-wide{grid-column:span 2}.ai-structure-field-compact{grid-column:span 1}.ai-structure-form .checkbox-row{display:flex;align-items:center;gap:7px;height:32px}.ai-structure-form .checkbox-row input{width:16px;height:16px;flex:0 0 auto}.ai-structure-submit{width:100%;margin:0;min-width:0}.ai-structure-result{background:#fff}
|
||||
.ai-structure-hint{border-bottom:1px solid var(--line);background:#fff}.ai-structure-hint-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));background:#fff}.ai-structure-hint-grid .access-card:nth-child(3n+1),.ai-structure-hint-grid .access-card:nth-child(3n+2){border-right:1px solid var(--line)}.ai-structure-hint-grid code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}
|
||||
.ai-structure-tree-grid{grid-template-columns:repeat(2,minmax(0,1fr));border-top:1px solid var(--line)}.ai-structure-tree-grid .access-card:nth-child(3n+1),.ai-structure-tree-grid .access-card:nth-child(3n+2){border-right:0}.ai-structure-tree-grid .access-card:nth-child(odd){border-right:1px solid var(--line)}.ai-structure-tree-card strong{margin-bottom:6px}.ai-structure-tree-list{margin:0;padding:0;list-style:none}.ai-structure-tree-list li{position:relative;padding:4px 0 4px 16px;color:var(--muted);font-size:12px;line-height:1.4}.ai-structure-tree-list li::before{content:"";position:absolute;left:4px;top:11px;width:6px;height:1px;background:#9aa4b2}
|
||||
.ai-structure-progress{border-bottom:1px solid var(--line);background:#fff}.ai-progress-head{display:flex;align-items:center;gap:10px;padding:10px 12px;border-bottom:1px solid var(--line);background:#fbfcfe}.ai-progress-head strong{flex:1}.ai-progress-head small{font-weight:900;color:var(--muted);font-variant-numeric:tabular-nums}.ai-progress-spinner{width:18px;height:18px;border:3px solid #c9d6ec;border-top-color:var(--brand);border-radius:50%;animation:aiProgressSpin 900ms linear infinite}.ai-progress-bar{height:6px;background:#eef2f7;overflow:hidden}.ai-progress-bar span{display:block;width:8%;height:100%;background:var(--brand);transition:width 700ms ease}.ai-progress-metrics{display:grid;grid-template-columns:repeat(3,1fr);margin:0}.ai-progress-metrics div{padding:10px 12px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.ai-progress-metrics div:last-child{border-right:0}.ai-progress-metrics dt{font-size:11px;font-weight:900;text-transform:uppercase;color:var(--muted)}.ai-progress-metrics dd{margin:3px 0 0;font-weight:900}.ai-structure-progress[hidden]{display:none}.ai-structure-progress.htmx-request,.ai-structure-progress[data-ai-structure-progress-state="running"]{display:block}@keyframes aiProgressSpin{to{transform:rotate(360deg)}}
|
||||
@media(max-width:980px){.layout{grid-template-columns:1fr;height:auto}.tree,.inspector{max-height:320px}.editor{min-height:520px}.hero{display:block}.shell{padding:16px}}
|
||||
@media(max-width:980px){.access-layout{grid-template-columns:1fr;height:auto}.access-nav,.access-side{max-height:360px}.access-role-grid{grid-template-columns:1fr}.access-role-grid .access-card:nth-child(odd){border-right:0}}
|
||||
@media(max-width:980px){.access-builder-form{grid-template-columns:1fr}.access-builder-actions{justify-content:stretch}.access-builder-actions button{flex:1}}
|
||||
@media(max-width:1180px){.ai-structure-form{grid-template-columns:repeat(2,minmax(0,1fr))}.ai-structure-field-wide{grid-column:1/-1}}
|
||||
@media(max-width:1180px){.ai-structure-hint-grid{grid-template-columns:1fr 1fr}.ai-structure-hint-grid .access-card:nth-child(3n+1),.ai-structure-hint-grid .access-card:nth-child(3n+2){border-right:0}.ai-structure-hint-grid .access-card:nth-child(odd){border-right:1px solid var(--line)}}
|
||||
@media(max-width:1180px){.ai-structure-tree-grid{grid-template-columns:1fr 1fr}.ai-structure-tree-grid .access-card:nth-child(odd){border-right:1px solid var(--line)}}
|
||||
@media(max-width:700px){.ai-structure-form{grid-template-columns:1fr}.ai-structure-field-wide,.ai-structure-field-compact,.ai-structure-submit{grid-column:1/-1}}
|
||||
@media(max-width:700px){.ai-structure-hint-grid{grid-template-columns:1fr}.ai-structure-hint-grid .access-card:nth-child(odd){border-right:0}}
|
||||
@media(max-width:700px){.ai-structure-tree-grid{grid-template-columns:1fr}.ai-structure-tree-grid .access-card:nth-child(odd){border-right:0}}
|
||||
@media(max-width:980px){.ai-progress-metrics{grid-template-columns:1fr}}
|
||||
@media(max-width:980px){.form-designer-body{grid-template-columns:1fr}.form-property-panel{border-left:0;border-top:1px solid var(--line)}.form-field[data-html5-form-width="half"],.form-field[data-html5-form-width="third"]{grid-column:1/-1}.form-editor-row[data-html5-form-element-editor]{grid-template-columns:1fr}.property-row{grid-template-columns:1fr}}
|
||||
@media(max-width:980px){.setup-layout{grid-template-columns:1fr}.setup-metrics{grid-template-columns:1fr 1fr}.settings-form{grid-template-columns:1fr}}
|
||||
@@ -0,0 +1,290 @@
|
||||
/*
|
||||
Server Sent Events Extension
|
||||
============================
|
||||
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
|
||||
|
||||
*/
|
||||
|
||||
(function() {
|
||||
/** @type {import("../htmx").HtmxInternalApi} */
|
||||
var api
|
||||
|
||||
htmx.defineExtension('sse', {
|
||||
|
||||
/**
|
||||
* Init saves the provided reference to the internal HTMX API.
|
||||
*
|
||||
* @param {import("../htmx").HtmxInternalApi} api
|
||||
* @returns void
|
||||
*/
|
||||
init: function(apiRef) {
|
||||
// store a reference to the internal API.
|
||||
api = apiRef
|
||||
|
||||
// set a function in the public API for creating new EventSource objects
|
||||
if (htmx.createEventSource == undefined) {
|
||||
htmx.createEventSource = createEventSource
|
||||
}
|
||||
},
|
||||
|
||||
getSelectors: function() {
|
||||
return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
|
||||
},
|
||||
|
||||
/**
|
||||
* onEvent handles all events passed to this extension.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {Event} evt
|
||||
* @returns void
|
||||
*/
|
||||
onEvent: function(name, evt) {
|
||||
var parent = evt.target || evt.detail.elt
|
||||
switch (name) {
|
||||
case 'htmx:beforeCleanupElement':
|
||||
var internalData = api.getInternalData(parent)
|
||||
// Try to remove remove an EventSource when elements are removed
|
||||
var source = internalData.sseEventSource
|
||||
if (source) {
|
||||
api.triggerEvent(parent, 'htmx:sseClose', {
|
||||
source,
|
||||
type: 'nodeReplaced',
|
||||
})
|
||||
internalData.sseEventSource.close()
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
// Try to create EventSources when elements are processed
|
||||
case 'htmx:afterProcessNode':
|
||||
ensureEventSourceOnElement(parent)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/// ////////////////////////////////////////////
|
||||
// HELPER FUNCTIONS
|
||||
/// ////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* createEventSource is the default method for creating new EventSource objects.
|
||||
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns EventSource
|
||||
*/
|
||||
function createEventSource(url) {
|
||||
return new EventSource(url, { withCredentials: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* registerSSE looks for attributes that can contain sse events, right
|
||||
* now hx-trigger and sse-swap and adds listeners based on these attributes too
|
||||
* the closest event source
|
||||
*
|
||||
* @param {HTMLElement} elt
|
||||
*/
|
||||
function registerSSE(elt) {
|
||||
// Add message handlers for every `sse-swap` attribute
|
||||
if (api.getAttributeValue(elt, 'sse-swap')) {
|
||||
// Find closest existing event source
|
||||
var sourceElement = api.getClosestMatch(elt, hasEventSource)
|
||||
if (sourceElement == null) {
|
||||
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
||||
return null // no eventsource in parentage, orphaned element
|
||||
}
|
||||
|
||||
// Set internalData and source
|
||||
var internalData = api.getInternalData(sourceElement)
|
||||
var source = internalData.sseEventSource
|
||||
|
||||
var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
|
||||
var sseEventNames = sseSwapAttr.split(',')
|
||||
|
||||
for (var i = 0; i < sseEventNames.length; i++) {
|
||||
const sseEventName = sseEventNames[i].trim()
|
||||
const listener = function(event) {
|
||||
// If the source is missing then close SSE
|
||||
if (maybeCloseSSESource(sourceElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
// If the body no longer contains the element, remove the listener
|
||||
if (!api.bodyContains(elt)) {
|
||||
source.removeEventListener(sseEventName, listener)
|
||||
return
|
||||
}
|
||||
|
||||
// swap the response into the DOM and trigger a notification
|
||||
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
|
||||
return
|
||||
}
|
||||
swap(elt, event.data)
|
||||
api.triggerEvent(elt, 'htmx:sseMessage', event)
|
||||
}
|
||||
|
||||
// Register the new listener
|
||||
api.getInternalData(elt).sseEventListener = listener
|
||||
source.addEventListener(sseEventName, listener)
|
||||
}
|
||||
}
|
||||
|
||||
// Add message handlers for every `hx-trigger="sse:*"` attribute
|
||||
if (api.getAttributeValue(elt, 'hx-trigger')) {
|
||||
// Find closest existing event source
|
||||
var sourceElement = api.getClosestMatch(elt, hasEventSource)
|
||||
if (sourceElement == null) {
|
||||
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
||||
return null // no eventsource in parentage, orphaned element
|
||||
}
|
||||
|
||||
// Set internalData and source
|
||||
var internalData = api.getInternalData(sourceElement)
|
||||
var source = internalData.sseEventSource
|
||||
|
||||
var triggerSpecs = api.getTriggerSpecs(elt)
|
||||
triggerSpecs.forEach(function(ts) {
|
||||
if (ts.trigger.slice(0, 4) !== 'sse:') {
|
||||
return
|
||||
}
|
||||
|
||||
var listener = function (event) {
|
||||
if (maybeCloseSSESource(sourceElement)) {
|
||||
return
|
||||
}
|
||||
if (!api.bodyContains(elt)) {
|
||||
source.removeEventListener(ts.trigger.slice(4), listener)
|
||||
}
|
||||
// Trigger events to be handled by the rest of htmx
|
||||
htmx.trigger(elt, ts.trigger, event)
|
||||
htmx.trigger(elt, 'htmx:sseMessage', event)
|
||||
}
|
||||
|
||||
// Register the new listener
|
||||
api.getInternalData(elt).sseEventListener = listener
|
||||
source.addEventListener(ts.trigger.slice(4), listener)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
|
||||
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
|
||||
* is created and stored in the element's internalData.
|
||||
* @param {HTMLElement} elt
|
||||
* @param {number} retryCount
|
||||
* @returns {EventSource | null}
|
||||
*/
|
||||
function ensureEventSourceOnElement(elt, retryCount) {
|
||||
if (elt == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
// handle extension source creation attribute
|
||||
if (api.getAttributeValue(elt, 'sse-connect')) {
|
||||
var sseURL = api.getAttributeValue(elt, 'sse-connect')
|
||||
if (sseURL == null) {
|
||||
return
|
||||
}
|
||||
|
||||
ensureEventSource(elt, sseURL, retryCount)
|
||||
}
|
||||
|
||||
registerSSE(elt)
|
||||
}
|
||||
|
||||
function ensureEventSource(elt, url, retryCount) {
|
||||
var source = htmx.createEventSource(url)
|
||||
|
||||
source.onerror = function(err) {
|
||||
// Log an error event
|
||||
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
|
||||
|
||||
// If parent no longer exists in the document, then clean up this EventSource
|
||||
if (maybeCloseSSESource(elt)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, try to reconnect the EventSource
|
||||
if (source.readyState === EventSource.CLOSED) {
|
||||
retryCount = retryCount || 0
|
||||
retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
|
||||
var timeout = retryCount * 500
|
||||
window.setTimeout(function() {
|
||||
ensureEventSourceOnElement(elt, retryCount)
|
||||
}, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
source.onopen = function(evt) {
|
||||
api.triggerEvent(elt, 'htmx:sseOpen', { source })
|
||||
|
||||
if (retryCount && retryCount > 0) {
|
||||
const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
|
||||
for (let i = 0; i < childrenToFix.length; i++) {
|
||||
registerSSE(childrenToFix[i])
|
||||
}
|
||||
// We want to increase the reconnection delay for consecutive failed attempts only
|
||||
retryCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
api.getInternalData(elt).sseEventSource = source
|
||||
|
||||
|
||||
var closeAttribute = api.getAttributeValue(elt, "sse-close");
|
||||
if (closeAttribute) {
|
||||
// close eventsource when this message is received
|
||||
source.addEventListener(closeAttribute, function() {
|
||||
api.triggerEvent(elt, 'htmx:sseClose', {
|
||||
source,
|
||||
type: 'message',
|
||||
})
|
||||
source.close()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* maybeCloseSSESource confirms that the parent element still exists.
|
||||
* If not, then any associated SSE source is closed and the function returns true.
|
||||
*
|
||||
* @param {HTMLElement} elt
|
||||
* @returns boolean
|
||||
*/
|
||||
function maybeCloseSSESource(elt) {
|
||||
if (!api.bodyContains(elt)) {
|
||||
var source = api.getInternalData(elt).sseEventSource
|
||||
if (source != undefined) {
|
||||
api.triggerEvent(elt, 'htmx:sseClose', {
|
||||
source,
|
||||
type: 'nodeMissing',
|
||||
})
|
||||
source.close()
|
||||
// source = null
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} elt
|
||||
* @param {string} content
|
||||
*/
|
||||
function swap(elt, content) {
|
||||
api.withExtensions(elt, function(extension) {
|
||||
content = extension.transformResponse(content, null, elt)
|
||||
})
|
||||
|
||||
var swapSpec = api.getSwapSpecification(elt)
|
||||
var target = api.getTarget(elt)
|
||||
api.swap(target, content, swapSpec)
|
||||
}
|
||||
|
||||
|
||||
function hasEventSource(node) {
|
||||
return api.getInternalData(node).sseEventSource != null
|
||||
}
|
||||
})()
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def current_timestamp() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
@@ -134,7 +138,19 @@ async def runtime_import(request: RuntimeImportRequest) -> RuntimeImportResponse
|
||||
],
|
||||
dump_plan=dump_plan,
|
||||
)
|
||||
raise HTTPException(status_code=501, detail="Designer execution runner is not implemented yet")
|
||||
if source_kind in {"CF_FILE", "CFE_FILE"}:
|
||||
if not request.path:
|
||||
raise HTTPException(status_code=400, detail="path is required for CF/CFE import")
|
||||
dump_root, execution_logs = _convert_local_cf_or_cfe_to_metadata_dump(request, platform_status)
|
||||
return RuntimeImportResponse(
|
||||
status="normalized",
|
||||
mode=mode.value,
|
||||
platform_found=True,
|
||||
normalized_project=normalize_one_c_project(dump_root, project_id=request.project_id),
|
||||
diagnostics=[*platform_status.diagnostics, *execution_logs],
|
||||
dump_plan=dump_plan,
|
||||
)
|
||||
raise HTTPException(status_code=501, detail=f"Designer execution runner is not implemented yet for {source_kind}")
|
||||
|
||||
|
||||
def _mode() -> RuntimeMode:
|
||||
@@ -217,6 +233,105 @@ def _designer_dump_plan(request: RuntimeImportRequest) -> list[str]:
|
||||
return plan
|
||||
|
||||
|
||||
def _convert_local_cf_or_cfe_to_metadata_dump(
|
||||
request: RuntimeImportRequest,
|
||||
platform_status: RuntimePlatformResponse,
|
||||
) -> tuple[Path, list[str]]:
|
||||
source_kind = request.source_kind.upper()
|
||||
payload_path = Path(request.path or "")
|
||||
if not payload_path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Path not found: {request.path}")
|
||||
if not platform_status.designer_path:
|
||||
raise HTTPException(status_code=503, detail="1C Designer CLI path is not configured")
|
||||
export_root = Path(tempfile.mkdtemp(prefix=f"sfera-runtime-{request.project_id or 'project'}-"))
|
||||
builder_infobase = export_root / "builder-infobase"
|
||||
logs: list[str] = []
|
||||
|
||||
_run_designer_command(
|
||||
platform_status.designer_path,
|
||||
["CREATEINFOBASE", f"File={builder_infobase};"],
|
||||
export_root / "create-builder-infobase.log",
|
||||
"1C CREATEINFOBASE for local CF/CFE conversion",
|
||||
)
|
||||
builder_args = ["/F", str(builder_infobase)]
|
||||
artifacts_root = export_root / "artifacts"
|
||||
artifacts_root.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(payload_path, artifacts_root / payload_path.name)
|
||||
|
||||
if source_kind == "CF_FILE":
|
||||
_run_designer_command(
|
||||
platform_status.designer_path,
|
||||
[*builder_args, "/LoadCfg", str(payload_path)],
|
||||
export_root / "designer-loadcfg-local-cf.log",
|
||||
"1C LoadCfg local CF",
|
||||
)
|
||||
metadata_root = export_root / "configuration"
|
||||
metadata_root.mkdir(parents=True, exist_ok=True)
|
||||
_run_designer_command(
|
||||
platform_status.designer_path,
|
||||
[*builder_args, "/DumpConfigToFiles", str(metadata_root), "-Format", "Hierarchical"],
|
||||
export_root / "designer-dumpconfigtofiles-local-cf.log",
|
||||
"1C DumpConfigToFiles from local CF",
|
||||
)
|
||||
shutil.copyfile(payload_path, metadata_root / payload_path.name)
|
||||
logs.append("Local .cf converted to metadata export for server-side parsing.")
|
||||
return export_root, logs
|
||||
|
||||
if source_kind == "CFE_FILE":
|
||||
extension_name = str(request.metadata.get("one_c_extension") or payload_path.stem).strip()
|
||||
if not extension_name:
|
||||
raise HTTPException(status_code=400, detail="Extension name is required for local CFE conversion.")
|
||||
_run_designer_command(
|
||||
platform_status.designer_path,
|
||||
[*builder_args, "/LoadCfg", str(payload_path), "-Extension", extension_name, "/UpdateDBCfg"],
|
||||
export_root / "designer-loadcfg-local-cfe.log",
|
||||
"1C LoadCfg local CFE",
|
||||
)
|
||||
metadata_root = export_root / "extension"
|
||||
metadata_root.mkdir(parents=True, exist_ok=True)
|
||||
_run_designer_command(
|
||||
platform_status.designer_path,
|
||||
[*builder_args, "/DumpConfigToFiles", str(metadata_root), "-Format", "Hierarchical", "-Extension", extension_name],
|
||||
export_root / "designer-dumpconfigtofiles-local-cfe.log",
|
||||
"1C DumpConfigToFiles from local CFE",
|
||||
)
|
||||
shutil.copyfile(payload_path, metadata_root / payload_path.name)
|
||||
logs.append("Local .cfe converted to metadata export for server-side parsing.")
|
||||
return export_root, logs
|
||||
|
||||
raise HTTPException(status_code=400, detail=f"Unsupported local 1C source: {source_kind}")
|
||||
|
||||
|
||||
def _designer_process_command(designer_path: str, arguments: list[str]) -> list[str]:
|
||||
path = Path(designer_path)
|
||||
if path.suffix.casefold() == ".py":
|
||||
return [sys.executable, designer_path, *arguments]
|
||||
return [designer_path, *arguments]
|
||||
|
||||
|
||||
def _run_designer_command(designer_path: str, arguments: list[str], log_path: Path, action_title: str, timeout_seconds: int = 180) -> None:
|
||||
command = _designer_process_command(designer_path, arguments)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
output = completed.stdout or ""
|
||||
log_path.write_text(output, encoding="utf-8")
|
||||
if completed.returncode != 0:
|
||||
tail = output[-4000:] if output else ""
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"{action_title} failed with code {completed.returncode}. {tail}".strip(),
|
||||
)
|
||||
|
||||
|
||||
def _redact_connection_string(value: str) -> str:
|
||||
sensitive_keys = {"pwd", "password", "пароль"}
|
||||
chunks: list[str] = []
|
||||
@@ -245,6 +360,11 @@ def _request_fingerprint(request: RuntimeImportRequest) -> str:
|
||||
|
||||
def _mock_project(project_id: str | None) -> NormalizedProject:
|
||||
from one_c_normalizer import (
|
||||
AccessGroup,
|
||||
AccessModel,
|
||||
AccessProfile,
|
||||
AccessRoleAssignment,
|
||||
AccessUser,
|
||||
Command,
|
||||
ConfigurationRoot,
|
||||
Extension,
|
||||
@@ -258,6 +378,31 @@ def _mock_project(project_id: str | None) -> NormalizedProject:
|
||||
return NormalizedProject(
|
||||
project_id=project_id,
|
||||
source_path="mock://runtime-adapter",
|
||||
access=AccessModel(
|
||||
profiles=[
|
||||
AccessProfile(
|
||||
name="МенеджерПродаж",
|
||||
qualified_name="ПрофильГруппыДоступа.МенеджерПродаж",
|
||||
roles=[AccessRoleAssignment(role="Менеджер", role_qualified_name="Роль.Менеджер")],
|
||||
)
|
||||
],
|
||||
groups=[
|
||||
AccessGroup(
|
||||
name="ОтделПродаж",
|
||||
qualified_name="ГруппаДоступа.ОтделПродаж",
|
||||
profile="МенеджерПродаж",
|
||||
users=["demo.user"],
|
||||
)
|
||||
],
|
||||
users=[
|
||||
AccessUser(
|
||||
name="demo.user",
|
||||
qualified_name="Пользователь.demo.user",
|
||||
full_name="Demo User",
|
||||
groups=["ОтделПродаж"],
|
||||
)
|
||||
],
|
||||
),
|
||||
configuration=ConfigurationRoot(
|
||||
groups=[
|
||||
MetadataGroup(
|
||||
|
||||
@@ -111,3 +111,47 @@ def test_runtime_platform_reports_capabilities(monkeypatch, tmp_path):
|
||||
payload = response.json()
|
||||
assert payload["platform_found"] is True
|
||||
assert "cf_dump_plan" in payload["capabilities"]
|
||||
|
||||
|
||||
def test_runtime_adapter_local_1c_executes_cf_dump(monkeypatch, tmp_path):
|
||||
designer = tmp_path / "fake_designer.py"
|
||||
designer.write_text(
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
args = sys.argv[1:]
|
||||
if args and args[0] == "CREATEINFOBASE":
|
||||
target = next(item for item in args[1:] if item.startswith("File="))[5:].rstrip(";")
|
||||
Path(target).mkdir(parents=True, exist_ok=True)
|
||||
raise SystemExit(0)
|
||||
if "/DumpConfigToFiles" in args:
|
||||
target = Path(args[args.index("/DumpConfigToFiles") + 1])
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
(target / "metadata.xml").write_text(
|
||||
"<Configuration><Catalog name='Контрагенты' qualifiedName='Справочник.Контрагенты' /></Configuration>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
raise SystemExit(0)
|
||||
raise SystemExit(0)
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
cf_file = tmp_path / "demo.cf"
|
||||
cf_file.write_text("binary-placeholder", encoding="utf-8")
|
||||
monkeypatch.setenv("RUNTIME_ADAPTER_MODE", "local_1c")
|
||||
monkeypatch.setenv("ONEC_DESIGNER_PATH", str(designer))
|
||||
monkeypatch.setenv("ONEC_ENABLE_DESIGNER_EXECUTION", "true")
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/runtime/import",
|
||||
json={"source_kind": "CF_FILE", "project_id": "cf-exec", "path": str(cf_file)},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["status"] == "normalized"
|
||||
assert payload["platform_found"] is True
|
||||
assert payload["normalized_project"]["project_id"] == "cf-exec"
|
||||
assert "Local .cf converted to metadata export" in "\n".join(payload["diagnostics"])
|
||||
|
||||
@@ -505,6 +505,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.28"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/54/a85eb421fbdd5007bc5af39d0f4ed9fa609e0fedbfdc2adcf0b34526870e/python_multipart-0.0.28.tar.gz", hash = "sha256:8550da197eac0f7ab748961fc9509b999fa2662ea25cef857f05249f6893c0f8", size = 45314, upload-time = "2026-05-10T11:05:16.596Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/a2/43bbc5860b5034e2af4ef99a0e04d726ff329c43e192ef3abaa8d7ecfce5/python_multipart-0.0.28-py3-none-any.whl", hash = "sha256:10faac07eb966c3f48dc415f9dee46c04cb10d58d30a35677db8027c825ed9b6", size = 29438, upload-time = "2026-05-10T11:05:15.052Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytz"
|
||||
version = "2026.2"
|
||||
@@ -521,6 +530,7 @@ source = { editable = "services/api-server" }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "neo4j" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "sfera-collaboration" },
|
||||
{ name = "sfera-impact-engine" },
|
||||
{ name = "sfera-incremental-indexer" },
|
||||
@@ -550,6 +560,7 @@ dependencies = [
|
||||
requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.115" },
|
||||
{ name = "neo4j", specifier = ">=5.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.20" },
|
||||
{ name = "sfera-collaboration", editable = "packages/collaboration" },
|
||||
{ name = "sfera-impact-engine", editable = "packages/impact-engine" },
|
||||
{ name = "sfera-incremental-indexer", editable = "packages/incremental-indexer" },
|
||||
|
||||
Reference in New Issue
Block a user