452 lines
16 KiB
Python
452 lines
16 KiB
Python
"""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",
|
|
]
|