Complete name-first 1C adapter saved-state support
This commit is contained in:
@@ -20,6 +20,10 @@ infobase.
|
||||
- `xml_metadata.py`: small XML metadata extractor used as validation oracle.
|
||||
- `structured_metadata.py`: evidence-based projection from Config payloads to
|
||||
normalized metadata records.
|
||||
- `common_command.py`: adapter-independent reverse index from
|
||||
`CommonCommand.Group` to command-group membership.
|
||||
- `scheduled_job.py`: adapter-independent scheduled-job schedule decoder,
|
||||
named-field validator, and verified tree rebuilder.
|
||||
|
||||
## Current Guarantees
|
||||
|
||||
@@ -42,6 +46,10 @@ The parser can currently:
|
||||
evidence paths.
|
||||
- attach child metadata items to concrete section record paths when a declared
|
||||
child-record container is present.
|
||||
- resolve command-group membership from CommonCommand payloads without
|
||||
requiring callers to know GUIDs or storage paths.
|
||||
- decode scheduled-job schedule payloads and build guarded named-field edits,
|
||||
including weekday/month collection resize without exposing tree paths.
|
||||
|
||||
## Non-Goals At This Layer
|
||||
|
||||
|
||||
@@ -9,7 +9,13 @@ from .payload import (
|
||||
payload_to_text,
|
||||
try_decompress,
|
||||
)
|
||||
from .dbnames import DBNamesRecord, parse_dbnames_bytes, parse_dbnames_file
|
||||
from .dbnames import (
|
||||
DBNamesRecord,
|
||||
parse_dbnames_bytes,
|
||||
parse_dbnames_file,
|
||||
parse_dbnames_version_bytes,
|
||||
parse_dbnames_version_file,
|
||||
)
|
||||
from .extensions import (
|
||||
ExtensionZippedInfo,
|
||||
ManifestEntry,
|
||||
@@ -20,6 +26,23 @@ from .config_object import MetadataObjectIdentity, find_identity, parse_config_o
|
||||
from .storage import StorageRoute, group_records_by_guid, storage_route, storage_routes
|
||||
from .config_sections import SectionSummary, summarize_section, summarize_sections
|
||||
from .xml_metadata import XmlMetadataItem, extract_xml_metadata_items, group_xml_items
|
||||
from .support_rules import (
|
||||
SupplierSupport,
|
||||
SupportRule,
|
||||
parse_parent_configurations_bytes,
|
||||
parse_parent_configurations_file,
|
||||
)
|
||||
from .common_command import (
|
||||
common_command_group_guid,
|
||||
index_common_command_groups,
|
||||
parse_common_command_tree,
|
||||
)
|
||||
from .scheduled_job import (
|
||||
decode_schedule,
|
||||
rebuild_schedule_tree,
|
||||
schedule_layout,
|
||||
schedule_write_edits,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BraceNode",
|
||||
@@ -32,6 +55,8 @@ __all__ = [
|
||||
"DBNamesRecord",
|
||||
"parse_dbnames_bytes",
|
||||
"parse_dbnames_file",
|
||||
"parse_dbnames_version_bytes",
|
||||
"parse_dbnames_version_file",
|
||||
"ExtensionZippedInfo",
|
||||
"ManifestEntry",
|
||||
"parse_extension_manifest_bytes",
|
||||
@@ -49,4 +74,15 @@ __all__ = [
|
||||
"XmlMetadataItem",
|
||||
"extract_xml_metadata_items",
|
||||
"group_xml_items",
|
||||
"SupplierSupport",
|
||||
"SupportRule",
|
||||
"parse_parent_configurations_bytes",
|
||||
"parse_parent_configurations_file",
|
||||
"common_command_group_guid",
|
||||
"index_common_command_groups",
|
||||
"parse_common_command_tree",
|
||||
"decode_schedule",
|
||||
"rebuild_schedule_tree",
|
||||
"schedule_layout",
|
||||
"schedule_write_edits",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Pure reverse index for 1C common-command group membership."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Iterable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from .payload import decode_payload_lossless, parse_brace_text
|
||||
|
||||
|
||||
_GUID_RE = re.compile(
|
||||
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
||||
)
|
||||
|
||||
|
||||
def _tree_item_at_path(tree: Any, path: tuple[int, ...]) -> Any | None:
|
||||
node = tree
|
||||
for index in path:
|
||||
if not isinstance(node, dict) or not isinstance(node.get("items"), list):
|
||||
return None
|
||||
items = node["items"]
|
||||
if index < 0 or index >= len(items):
|
||||
return None
|
||||
node = items[index]
|
||||
return node
|
||||
|
||||
|
||||
def _tree_scalar_at_path(tree: Any, path: tuple[int, ...]) -> str:
|
||||
node = _tree_item_at_path(tree, path)
|
||||
if isinstance(node, dict) and node.get("type") in {"atom", "string"}:
|
||||
return str(node.get("value") or "")
|
||||
return ""
|
||||
|
||||
|
||||
def common_command_group_guid(tree: Any) -> str | None:
|
||||
"""Return the group GUID stored in an observed CommonCommand Config tree."""
|
||||
body = _tree_item_at_path(tree, (1, 1, 2))
|
||||
group_guid = _tree_scalar_at_path(body, (7, 1)).strip().lower()
|
||||
return group_guid if _GUID_RE.fullmatch(group_guid) else None
|
||||
|
||||
|
||||
def parse_common_command_tree(data: bytes) -> Any | None:
|
||||
"""Decode a CommonCommand payload without raising on unsupported data."""
|
||||
try:
|
||||
decoded = decode_payload_lossless(data)
|
||||
text = decoded.get("text")
|
||||
if not text or "{" not in text:
|
||||
return None
|
||||
return parse_brace_text(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def index_common_command_groups(
|
||||
commands: Iterable[Mapping[str, Any]],
|
||||
payloads: Mapping[str, bytes],
|
||||
) -> dict[str, Any]:
|
||||
"""Build ``CommandGroup GUID -> CommonCommand rows`` from Config payloads."""
|
||||
command_rows = [dict(item) for item in commands]
|
||||
normalized_payloads = {str(key).strip().lower(): value for key, value in payloads.items()}
|
||||
groups: dict[str, list[dict[str, Any]]] = {}
|
||||
source_missing = 0
|
||||
undecodable = 0
|
||||
unassigned = 0
|
||||
|
||||
for item in command_rows:
|
||||
guid = str(item.get("guid") or "").strip().lower()
|
||||
data = normalized_payloads.get(guid)
|
||||
if not data:
|
||||
source_missing += 1
|
||||
continue
|
||||
tree = parse_common_command_tree(data)
|
||||
if tree is None:
|
||||
undecodable += 1
|
||||
continue
|
||||
group_guid = common_command_group_guid(tree)
|
||||
if group_guid is None:
|
||||
unassigned += 1
|
||||
continue
|
||||
groups.setdefault(group_guid, []).append(item)
|
||||
|
||||
return {
|
||||
"groups": groups,
|
||||
"scanned": len(command_rows),
|
||||
"source_missing": source_missing,
|
||||
"undecodable": undecodable,
|
||||
"unassigned": unassigned,
|
||||
}
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from .payload import parse_brace_text, payload_to_text, scalar
|
||||
@@ -77,3 +78,44 @@ def parse_dbnames_bytes(data: bytes, *, source: str = "DBNames") -> dict[str, An
|
||||
|
||||
def parse_dbnames_file(path: Path) -> dict[str, Any]:
|
||||
return parse_dbnames_bytes(path.read_bytes(), source=path.name)
|
||||
|
||||
|
||||
_GUID_RE = re.compile(
|
||||
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
||||
)
|
||||
|
||||
|
||||
def parse_dbnames_version_bytes(data: bytes, *, source: str = "DBNamesVersion") -> dict[str, Any]:
|
||||
"""Parse the version marker stored separately from DBNames records."""
|
||||
decoded = payload_to_text(data)
|
||||
text = decoded.get("text")
|
||||
if text is None:
|
||||
raise ValueError(f"{source}: cannot decode DBNamesVersion text")
|
||||
parsed = _unwrap_bom_sequence(parse_brace_text(text))
|
||||
if not (isinstance(parsed, dict) and parsed.get("type") == "list"):
|
||||
raise ValueError(f"{source}: expected root list")
|
||||
items = parsed.get("items") or []
|
||||
if len(items) != 2:
|
||||
raise ValueError(f"{source}: expected 2 root items, got {len(items)}")
|
||||
if not all(isinstance(item, dict) and item.get("type") == "atom" for item in items):
|
||||
actual_types = [item.get("type") if isinstance(item, dict) else type(item).__name__ for item in items]
|
||||
raise ValueError(f"{source}: expected scalar marker and version, got {actual_types}")
|
||||
marker_text = scalar(items[0]).strip()
|
||||
try:
|
||||
marker = int(marker_text)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{source}: marker must be an integer, got {marker_text!r}") from exc
|
||||
version = scalar(items[1]).strip().lower()
|
||||
if not _GUID_RE.fullmatch(version):
|
||||
raise ValueError(f"{source}: version must be a GUID string, got {version!r}")
|
||||
return {
|
||||
"source": source,
|
||||
"compression": decoded["compression"],
|
||||
"encoding": decoded["encoding"],
|
||||
"marker": marker,
|
||||
"version": version,
|
||||
}
|
||||
|
||||
|
||||
def parse_dbnames_version_file(path: Path) -> dict[str, Any]:
|
||||
return parse_dbnames_version_bytes(path.read_bytes(), source=path.name)
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
"""Pure decoder and safe tree editor for 1C scheduled-job schedules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
WRITABLE_SCALAR_FIELDS = {
|
||||
"begin_date",
|
||||
"end_date",
|
||||
"begin_time",
|
||||
"end_time",
|
||||
"completion_time",
|
||||
"completion_interval",
|
||||
"repeat_period_in_day",
|
||||
"repeat_pause",
|
||||
"week_day_in_month",
|
||||
"day_in_month",
|
||||
"weeks_period",
|
||||
"days_repeat_period",
|
||||
}
|
||||
WRITABLE_LIST_FIELDS = {"week_days", "months"}
|
||||
INTEGER_RANGES = {
|
||||
"completion_interval": (0, 2_147_483_647),
|
||||
"repeat_period_in_day": (0, 2_147_483_647),
|
||||
"repeat_pause": (0, 2_147_483_647),
|
||||
"week_day_in_month": (0, 5),
|
||||
"day_in_month": (0, 31),
|
||||
"weeks_period": (0, 2_147_483_647),
|
||||
"days_repeat_period": (0, 2_147_483_647),
|
||||
}
|
||||
|
||||
|
||||
def _scalar(node: Any) -> str:
|
||||
if not isinstance(node, dict):
|
||||
return str(node or "")
|
||||
if node.get("type") in {"atom", "string"}:
|
||||
return str(node.get("value") or "")
|
||||
return ""
|
||||
|
||||
|
||||
def schedule_datetime(raw: str) -> tuple[str | None, str | None]:
|
||||
if not re.fullmatch(r"\d{14}", raw):
|
||||
return None, None
|
||||
return (
|
||||
f"{raw[0:4]}-{raw[4:6]}-{raw[6:8]}",
|
||||
f"{raw[8:10]}:{raw[10:12]}:{raw[12:14]}",
|
||||
)
|
||||
|
||||
|
||||
def schedule_layout(tree: Any) -> dict[str, Any]:
|
||||
items = tree.get("items") if isinstance(tree, dict) and isinstance(tree.get("items"), list) else []
|
||||
raw = [_scalar(item) for item in items]
|
||||
if len(raw) < 13:
|
||||
return {
|
||||
"status": "invalid_schedule_payload",
|
||||
"diagnostics": {"message": "The SQL schedule payload is shorter than the supported format."},
|
||||
}
|
||||
week_day_count = int(raw[8]) if re.fullmatch(r"-?\d+", raw[8]) else None
|
||||
cursor = 9
|
||||
if week_day_count is None or week_day_count < 0 or cursor + week_day_count > len(raw):
|
||||
return {
|
||||
"status": "invalid_schedule_payload",
|
||||
"diagnostics": {"message": "Invalid weekday collection in the SQL schedule payload."},
|
||||
}
|
||||
week_days_start = cursor
|
||||
cursor += week_day_count
|
||||
if cursor + 3 > len(raw):
|
||||
return {
|
||||
"status": "invalid_schedule_payload",
|
||||
"diagnostics": {"message": "The SQL schedule payload has no month collection header."},
|
||||
}
|
||||
week_day_in_month_index = cursor
|
||||
day_in_month_index = cursor + 1
|
||||
month_count_index = cursor + 2
|
||||
month_count = int(raw[month_count_index]) if re.fullmatch(r"-?\d+", raw[month_count_index]) else None
|
||||
cursor += 3
|
||||
if month_count is None or month_count < 0 or cursor + month_count > len(raw):
|
||||
return {
|
||||
"status": "invalid_schedule_payload",
|
||||
"diagnostics": {"message": "Invalid month collection in the SQL schedule payload."},
|
||||
}
|
||||
months_start = cursor
|
||||
cursor += month_count
|
||||
if cursor + 2 > len(raw):
|
||||
return {
|
||||
"status": "invalid_schedule_payload",
|
||||
"diagnostics": {"message": "The SQL schedule payload has no repeat-period tail."},
|
||||
}
|
||||
return {
|
||||
"status": "ok",
|
||||
"raw": raw,
|
||||
"indexes": {
|
||||
"begin_date": 0,
|
||||
"end_date": 1,
|
||||
"begin_time": 2,
|
||||
"end_time": 3,
|
||||
"completion_time": 4,
|
||||
"completion_interval": 5,
|
||||
"repeat_period_in_day": 6,
|
||||
"repeat_pause": 7,
|
||||
"week_day_count": 8,
|
||||
"week_days": list(range(week_days_start, week_days_start + week_day_count)),
|
||||
"week_day_in_month": week_day_in_month_index,
|
||||
"day_in_month": day_in_month_index,
|
||||
"month_count": month_count_index,
|
||||
"months": list(range(months_start, months_start + month_count)),
|
||||
"weeks_period": cursor,
|
||||
"days_repeat_period": cursor + 1,
|
||||
},
|
||||
"cursor": cursor + 2,
|
||||
}
|
||||
|
||||
|
||||
def decode_schedule(tree: Any, *, include_storage: bool = False) -> dict[str, Any]:
|
||||
layout = schedule_layout(tree)
|
||||
if layout.get("status") != "ok":
|
||||
return layout
|
||||
raw = layout["raw"]
|
||||
indexes = layout["indexes"]
|
||||
|
||||
def integer(field: str) -> int | None:
|
||||
value = raw[indexes[field]]
|
||||
return int(value) if re.fullmatch(r"-?\d+", value) else None
|
||||
|
||||
begin_date, _ = schedule_datetime(raw[indexes["begin_date"]])
|
||||
end_date, _ = schedule_datetime(raw[indexes["end_date"]])
|
||||
_, begin_time = schedule_datetime(raw[indexes["begin_time"]])
|
||||
_, end_time = schedule_datetime(raw[indexes["end_time"]])
|
||||
_, completion_time = schedule_datetime(raw[indexes["completion_time"]])
|
||||
result = {
|
||||
"status": "ok",
|
||||
"begin_date": begin_date,
|
||||
"end_date": end_date,
|
||||
"begin_time": begin_time,
|
||||
"end_time": end_time,
|
||||
"completion_time": completion_time,
|
||||
"completion_interval": integer("completion_interval"),
|
||||
"repeat_period_in_day": integer("repeat_period_in_day"),
|
||||
"repeat_pause": integer("repeat_pause"),
|
||||
"week_days": [int(raw[index]) for index in indexes["week_days"] if raw[index].isdigit()],
|
||||
"week_day_in_month": integer("week_day_in_month"),
|
||||
"day_in_month": integer("day_in_month"),
|
||||
"months": [int(raw[index]) for index in indexes["months"] if raw[index].isdigit()],
|
||||
"weeks_period": integer("weeks_period"),
|
||||
"days_repeat_period": integer("days_repeat_period"),
|
||||
"evidence": "live_sql_config_schedule_decoder",
|
||||
}
|
||||
if include_storage:
|
||||
result["storage"] = {
|
||||
"format": "scheduled_job_config_suffix_0",
|
||||
"raw_values": raw,
|
||||
"trailing_values": raw[int(layout["cursor"]) :],
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def rebuild_schedule_tree(tree: Any, requested: dict[str, Any]) -> dict[str, Any]:
|
||||
layout = schedule_layout(tree)
|
||||
if layout.get("status") != "ok":
|
||||
return layout
|
||||
raw = list(layout["raw"])
|
||||
indexes = layout["indexes"]
|
||||
current = decode_schedule(tree)
|
||||
|
||||
for field in WRITABLE_SCALAR_FIELDS:
|
||||
if field not in requested:
|
||||
continue
|
||||
value = requested[field]
|
||||
index = int(indexes[field])
|
||||
if field in {"begin_date", "end_date"}:
|
||||
raw[index] = str(value).replace("-", "") + (
|
||||
raw[index][8:] if re.fullmatch(r"\d{14}", raw[index]) else "000000"
|
||||
)
|
||||
elif field in {"begin_time", "end_time", "completion_time"}:
|
||||
raw[index] = (
|
||||
raw[index][:8] if re.fullmatch(r"\d{14}", raw[index]) else "00010101"
|
||||
) + str(value).replace(":", "")
|
||||
else:
|
||||
raw[index] = str(value)
|
||||
|
||||
week_days = list(requested.get("week_days", current.get("week_days") or []))
|
||||
months = list(requested.get("months", current.get("months") or []))
|
||||
rebuilt_raw = [
|
||||
*raw[:8],
|
||||
str(len(week_days)),
|
||||
*(str(value) for value in week_days),
|
||||
raw[int(indexes["week_day_in_month"])],
|
||||
raw[int(indexes["day_in_month"])],
|
||||
str(len(months)),
|
||||
*(str(value) for value in months),
|
||||
raw[int(indexes["weeks_period"])],
|
||||
raw[int(indexes["days_repeat_period"])],
|
||||
*raw[int(layout["cursor"]) :],
|
||||
]
|
||||
rebuilt_tree = copy.deepcopy(tree)
|
||||
rebuilt_tree["items"] = [{"type": "atom", "value": value} for value in rebuilt_raw]
|
||||
verification = decode_schedule(rebuilt_tree)
|
||||
if verification.get("status") != "ok":
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "schedule_rebuild_verification_failed",
|
||||
"diagnostics": {"message": "The rebuilt scheduled-job tree did not pass the schedule decoder."},
|
||||
}
|
||||
for field, expected in requested.items():
|
||||
if verification.get(field) != expected:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "schedule_rebuild_verification_failed",
|
||||
"field": field,
|
||||
"diagnostics": {
|
||||
"message": "A named schedule field changed during tree rebuild verification.",
|
||||
"expected": expected,
|
||||
"actual": verification.get(field),
|
||||
},
|
||||
}
|
||||
return {
|
||||
"status": "ok",
|
||||
"tree": rebuilt_tree,
|
||||
"schedule": verification,
|
||||
"old_counts": {
|
||||
"week_days": len(indexes["week_days"]),
|
||||
"months": len(indexes["months"]),
|
||||
},
|
||||
"new_counts": {
|
||||
"week_days": len(week_days),
|
||||
"months": len(months),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def schedule_write_edits(tree: Any, requested: Any) -> dict[str, Any]:
|
||||
if not isinstance(requested, dict) or not requested:
|
||||
return {
|
||||
"status": "invalid_argument",
|
||||
"error": "schedule_required",
|
||||
"diagnostics": {"message": "schedule must be a non-empty JSON object."},
|
||||
}
|
||||
unknown = sorted(set(requested) - WRITABLE_SCALAR_FIELDS - WRITABLE_LIST_FIELDS)
|
||||
if unknown:
|
||||
return {
|
||||
"status": "invalid_argument",
|
||||
"error": "unsupported_schedule_fields",
|
||||
"diagnostics": {
|
||||
"message": "The request contains unsupported scheduled-job fields.",
|
||||
"fields": unknown,
|
||||
"allowed_fields": sorted(WRITABLE_SCALAR_FIELDS | WRITABLE_LIST_FIELDS),
|
||||
},
|
||||
}
|
||||
layout = schedule_layout(tree)
|
||||
if layout.get("status") != "ok":
|
||||
return layout
|
||||
current = decode_schedule(tree)
|
||||
raw = layout["raw"]
|
||||
indexes = layout["indexes"]
|
||||
normalized: dict[str, Any] = {}
|
||||
edits: list[dict[str, Any]] = []
|
||||
resized_collections: list[str] = []
|
||||
|
||||
for field, value in requested.items():
|
||||
if field in {"begin_date", "end_date"}:
|
||||
if value is None:
|
||||
compact = "00010101"
|
||||
elif isinstance(value, str) and re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
|
||||
try:
|
||||
datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return {
|
||||
"status": "invalid_argument",
|
||||
"error": "invalid_schedule_value",
|
||||
"field": field,
|
||||
"diagnostics": {"message": f"{field} must be a real ISO date YYYY-MM-DD."},
|
||||
}
|
||||
compact = value.replace("-", "")
|
||||
else:
|
||||
return {
|
||||
"status": "invalid_argument",
|
||||
"error": "invalid_schedule_value",
|
||||
"field": field,
|
||||
"diagnostics": {"message": f"{field} must be an ISO date YYYY-MM-DD or null."},
|
||||
}
|
||||
index = int(indexes[field])
|
||||
encoded = compact + (raw[index][8:] if re.fullmatch(r"\d{14}", raw[index]) else "000000")
|
||||
normalized[field] = "0001-01-01" if value is None else value
|
||||
if encoded != raw[index]:
|
||||
edits.append(
|
||||
{
|
||||
"path": str(index),
|
||||
"value": encoded,
|
||||
"node_type": "atom",
|
||||
"expected_old": raw[index],
|
||||
"field": field,
|
||||
}
|
||||
)
|
||||
continue
|
||||
if field in {"begin_time", "end_time", "completion_time"}:
|
||||
if value is None:
|
||||
compact = "000000"
|
||||
elif isinstance(value, str) and re.fullmatch(r"\d{2}:\d{2}:\d{2}", value):
|
||||
try:
|
||||
datetime.strptime(value, "%H:%M:%S")
|
||||
except ValueError:
|
||||
return {
|
||||
"status": "invalid_argument",
|
||||
"error": "invalid_schedule_value",
|
||||
"field": field,
|
||||
"diagnostics": {"message": f"{field} must be a real time HH:MM:SS."},
|
||||
}
|
||||
compact = value.replace(":", "")
|
||||
else:
|
||||
return {
|
||||
"status": "invalid_argument",
|
||||
"error": "invalid_schedule_value",
|
||||
"field": field,
|
||||
"diagnostics": {"message": f"{field} must be a time HH:MM:SS or null."},
|
||||
}
|
||||
index = int(indexes[field])
|
||||
encoded = (
|
||||
raw[index][:8] if re.fullmatch(r"\d{14}", raw[index]) else "00010101"
|
||||
) + compact
|
||||
normalized[field] = "00:00:00" if value is None else value
|
||||
if encoded != raw[index]:
|
||||
edits.append(
|
||||
{
|
||||
"path": str(index),
|
||||
"value": encoded,
|
||||
"node_type": "atom",
|
||||
"expected_old": raw[index],
|
||||
"field": field,
|
||||
}
|
||||
)
|
||||
continue
|
||||
if field in WRITABLE_LIST_FIELDS:
|
||||
maximum = 7 if field == "week_days" else 12
|
||||
if (
|
||||
not isinstance(value, list)
|
||||
or any(
|
||||
isinstance(item, bool)
|
||||
or not isinstance(item, int)
|
||||
or item < 1
|
||||
or item > maximum
|
||||
for item in value
|
||||
)
|
||||
or len(set(value)) != len(value)
|
||||
):
|
||||
return {
|
||||
"status": "invalid_argument",
|
||||
"error": "invalid_schedule_value",
|
||||
"field": field,
|
||||
"diagnostics": {
|
||||
"message": f"{field} must be a JSON array of unique integers from 1 to {maximum}."
|
||||
},
|
||||
}
|
||||
field_indexes = list(indexes[field])
|
||||
if len(value) != len(field_indexes):
|
||||
resized_collections.append(field)
|
||||
normalized[field] = list(value)
|
||||
continue
|
||||
normalized[field] = list(value)
|
||||
for index, item in zip(field_indexes, value):
|
||||
encoded = str(item)
|
||||
if encoded != raw[index]:
|
||||
edits.append(
|
||||
{
|
||||
"path": str(index),
|
||||
"value": encoded,
|
||||
"node_type": "atom",
|
||||
"expected_old": raw[index],
|
||||
"field": field,
|
||||
}
|
||||
)
|
||||
continue
|
||||
minimum, maximum = INTEGER_RANGES[field]
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < minimum or value > maximum:
|
||||
return {
|
||||
"status": "invalid_argument",
|
||||
"error": "invalid_schedule_value",
|
||||
"field": field,
|
||||
"diagnostics": {
|
||||
"message": f"{field} must be a JSON integer from {minimum} to {maximum}."
|
||||
},
|
||||
}
|
||||
index = int(indexes[field])
|
||||
encoded = str(value)
|
||||
normalized[field] = value
|
||||
if encoded != raw[index]:
|
||||
edits.append(
|
||||
{
|
||||
"path": str(index),
|
||||
"value": encoded,
|
||||
"node_type": "atom",
|
||||
"expected_old": raw[index],
|
||||
"field": field,
|
||||
}
|
||||
)
|
||||
|
||||
if resized_collections:
|
||||
rebuilt = rebuild_schedule_tree(tree, normalized)
|
||||
if rebuilt.get("status") != "ok":
|
||||
return rebuilt
|
||||
edits = [
|
||||
{
|
||||
"replace_root": rebuilt["tree"],
|
||||
"fields": sorted(normalized),
|
||||
"resized_collections": sorted(resized_collections),
|
||||
"old_counts": rebuilt["old_counts"],
|
||||
"new_counts": rebuilt["new_counts"],
|
||||
}
|
||||
]
|
||||
return {
|
||||
"status": "ok",
|
||||
"current": current,
|
||||
"requested": normalized,
|
||||
"edits": edits,
|
||||
"counts": {
|
||||
"requested_fields": len(normalized),
|
||||
"edits": len(edits),
|
||||
"resized_collections": len(resized_collections),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Compatibility aliases retained by adapter_1c_server and existing clients.
|
||||
config_schedule_datetime = schedule_datetime
|
||||
scheduled_job_schedule_layout = schedule_layout
|
||||
scheduled_job_sql_schedule = decode_schedule
|
||||
scheduled_job_schedule_rebuild_tree = rebuild_schedule_tree
|
||||
scheduled_job_schedule_write_edits = schedule_write_edits
|
||||
SCHEDULED_JOB_WRITABLE_SCALAR_FIELDS = WRITABLE_SCALAR_FIELDS
|
||||
SCHEDULED_JOB_WRITABLE_LIST_FIELDS = WRITABLE_LIST_FIELDS
|
||||
SCHEDULED_JOB_INTEGER_RANGES = INTEGER_RANGES
|
||||
|
||||
|
||||
__all__ = [
|
||||
"INTEGER_RANGES",
|
||||
"WRITABLE_LIST_FIELDS",
|
||||
"WRITABLE_SCALAR_FIELDS",
|
||||
"config_schedule_datetime",
|
||||
"decode_schedule",
|
||||
"rebuild_schedule_tree",
|
||||
"schedule_datetime",
|
||||
"schedule_layout",
|
||||
"schedule_write_edits",
|
||||
"scheduled_job_schedule_layout",
|
||||
"scheduled_job_schedule_rebuild_tree",
|
||||
"scheduled_job_schedule_write_edits",
|
||||
"scheduled_job_sql_schedule",
|
||||
]
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Decoder for exported 1C ``ParentConfigurations.bin`` support rules.
|
||||
|
||||
The SQL representation of these data is platform-private and may differ from
|
||||
the exported representation. Callers must therefore pass bytes from a
|
||||
positively identified source; this module deliberately does not discover a
|
||||
source or infer that missing data means "not on support".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from .payload import parse_brace_text, payload_to_text, scalar
|
||||
|
||||
|
||||
GUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I)
|
||||
OBJECT_RULES = {
|
||||
0: "not_editable",
|
||||
1: "editable_support_preserved",
|
||||
2: "not_supported",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SupportRule:
|
||||
object_guid: str
|
||||
rule_code: int
|
||||
rule: str
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SupplierSupport:
|
||||
configuration_guid: str
|
||||
general_mode_code: int
|
||||
general_mode: str
|
||||
version: str
|
||||
producer: str
|
||||
name: str
|
||||
declared_object_count: int
|
||||
rules: tuple[SupportRule, ...]
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
result = asdict(self)
|
||||
result["rules"] = [rule.to_dict() for rule in self.rules]
|
||||
return result
|
||||
|
||||
|
||||
def _root_items(data: bytes, source: str) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
decoded = payload_to_text(data)
|
||||
text = decoded.get("text")
|
||||
if text is None:
|
||||
raise ValueError(f"{source}: cannot decode support rules text")
|
||||
root = parse_brace_text(text)
|
||||
if not (isinstance(root, dict) and root.get("type") == "list"):
|
||||
raise ValueError(f"{source}: expected root list")
|
||||
return list(root.get("items") or []), decoded
|
||||
|
||||
|
||||
def _integer(items: list[dict[str, Any]], index: int, source: str, field: str) -> int:
|
||||
try:
|
||||
return int(scalar(items[index]))
|
||||
except (IndexError, TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{source}: invalid {field} at item {index}") from exc
|
||||
|
||||
|
||||
def parse_parent_configurations_bytes(
|
||||
data: bytes,
|
||||
*,
|
||||
source: str = "ParentConfigurations.bin",
|
||||
) -> dict[str, Any]:
|
||||
"""Decode a positively identified exported support-rules payload."""
|
||||
|
||||
items, decoded = _root_items(data, source)
|
||||
if len(items) < 3:
|
||||
raise ValueError(f"{source}: support rules header is incomplete")
|
||||
format_marker = _integer(items, 0, source, "format marker")
|
||||
if format_marker != 6:
|
||||
raise ValueError(f"{source}: expected format marker 6, got {format_marker}")
|
||||
supplier_count = _integer(items, 2, source, "supplier count")
|
||||
if supplier_count < 0:
|
||||
raise ValueError(f"{source}: supplier count must not be negative")
|
||||
|
||||
suppliers: list[SupplierSupport] = []
|
||||
position = 3
|
||||
for supplier_index in range(supplier_count):
|
||||
if position + 6 >= len(items):
|
||||
raise ValueError(f"{source}: supplier {supplier_index} header is incomplete")
|
||||
configuration_guid = scalar(items[position]).lower()
|
||||
if not GUID_RE.fullmatch(configuration_guid):
|
||||
raise ValueError(f"{source}: supplier {supplier_index} configuration GUID is invalid")
|
||||
general_code = _integer(items, position + 1, source, "general support mode")
|
||||
object_count = _integer(items, position + 6, source, "object count")
|
||||
if object_count < 0:
|
||||
raise ValueError(f"{source}: supplier {supplier_index} object count must not be negative")
|
||||
object_position = position + 7
|
||||
rules: list[SupportRule] = []
|
||||
for object_index in range(object_count):
|
||||
current = object_position + object_index * 4
|
||||
if current + 3 >= len(items):
|
||||
raise ValueError(f"{source}: supplier {supplier_index} object {object_index} is incomplete")
|
||||
rule_code = _integer(items, current, source, "object support rule")
|
||||
object_guid = scalar(items[current + 2]).lower()
|
||||
if rule_code not in OBJECT_RULES:
|
||||
raise ValueError(f"{source}: unsupported object rule code {rule_code}")
|
||||
if not GUID_RE.fullmatch(object_guid):
|
||||
raise ValueError(f"{source}: supplier {supplier_index} object {object_index} GUID is invalid")
|
||||
effective_code = 0 if general_code != 0 else rule_code
|
||||
rules.append(SupportRule(object_guid, effective_code, OBJECT_RULES[effective_code]))
|
||||
suppliers.append(
|
||||
SupplierSupport(
|
||||
configuration_guid=configuration_guid,
|
||||
general_mode_code=general_code,
|
||||
general_mode="editable" if general_code == 0 else "locked",
|
||||
version=scalar(items[position + 3]),
|
||||
producer=scalar(items[position + 4]),
|
||||
name=scalar(items[position + 5]),
|
||||
declared_object_count=object_count,
|
||||
rules=tuple(rules),
|
||||
)
|
||||
)
|
||||
position = object_position + object_count * 4 + 2
|
||||
|
||||
return {
|
||||
"source": source,
|
||||
"compression": decoded["compression"],
|
||||
"encoding": decoded["encoding"],
|
||||
"format_marker": format_marker,
|
||||
"supplier_count": supplier_count,
|
||||
"suppliers": suppliers,
|
||||
}
|
||||
|
||||
|
||||
def parse_parent_configurations_file(path: Path) -> dict[str, Any]:
|
||||
return parse_parent_configurations_bytes(path.read_bytes(), source=path.name)
|
||||
Reference in New Issue
Block a user