Complete name-first 1C adapter saved-state support
This commit is contained in:
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user