1
1
mirror of https://github.com/KenanZhu/AutoLibrary.git synced 2026-06-18 07:23:03 +08:00

Compare commits

...

32 Commits

Author SHA1 Message Date
KenanZhu 5800437ba2 fix(gui): 编排窗口代码生成统一使用 END IF 结束块 2026-05-18 20:43:48 +08:00
KenanZhu 23467c1d3d feat(autoscript): 支持 // 行内注释与完整注释行解析 2026-05-18 20:13:46 +08:00
KenanZhu b8c0a29c59 fix(gui): 调整定时任务对话框布局边距与间距 2026-05-18 17:59:04 +08:00
KenanZhu 87787ad3dc style(gui): 编辑器高亮配色更改为 VSCode C 风格并为布尔字面量独立配色 2026-05-18 17:59:00 +08:00
KenanZhu e800f6ece1 refactor(gui): 统一 setupUi 命名并调整按钮布局 2026-05-18 16:01:22 +08:00
KenanZhu 600a304ab8 style(gui): 规范编排对话框属性命名并消除冗余代码 2026-05-18 16:01:16 +08:00
KenanZhu c038c8005d refactor(autoscript): 公开 splitTopLevel 并导出常量,消除冗余委托与重复变量 2026-05-18 16:01:10 +08:00
KenanZhu 6cf182c8c8 refactor(gui): 编排窗口迁移至新包并移除旧的预览/编排对话框 2026-05-18 11:15:35 +08:00
KenanZhu 33c0f4414c fix(autoscript): 为异常添加行号信息并补充类型兼容性检查 2026-05-17 02:58:47 +08:00
KenanZhu 2843300cf9 refactor(autoscript): 使用观察者模式解耦解析与预检查/编排流程 2026-05-17 01:48:25 +08:00
KenanZhu 9bdc9a3de9 refactor(autoscript): 使用 ASTokenizer 和 NodeVisitor 重构解析与执行流程 2026-05-17 01:33:22 +08:00
KenanZhu 500ddd41c5 refactor(autoscript): 替换 dsl 包为 autoscript 引擎模块 2026-05-12 11:49:43 +08:00
KenanZhu 14c6db3384 refactor(config): 引入 ConfigPath 值对象消除 ConfigType/ConfigKey 的消费者 API 冗余 2026-05-10 16:14:36 +08:00
KenanZhu bbd97970a6 refactor(modules): 将 AutoScriptEngine 移至 dsl/,ConfigUtils 移至 managers/config/,修复单一职责和依赖倒置问题 2026-05-10 15:33:10 +08:00
github-actions[bot] 22d3c3462c chore(release): merge release/v1.3.0 to main [auto release commit] 2026-05-09 06:08:33 +00:00
github-actions[bot] dc287f3aa5 chore(release): v1.3.0 [auto release commit] 2026-05-09 06:05:24 +00:00
Kenan Zhu 7886379875 feat(*): 支持编辑定时任务,支持AutoScript的重复性定时任务预处理指令 (#7)
feat: 支持编辑定时任务,支持AutoScript的重复性定时任务预处理指令
2026-05-09 13:20:37 +08:00
KenanZhu 967ede4b04 fix(ALTimerTaskManageWidget): 修复右键菜单删除任务时 parent() 类型错误 2026-05-09 12:59:23 +08:00
KenanZhu 27250dba2f feat(ALTimerTask*): 实现定时任务编辑功能,统一代码规范并重命名重复任务历史字段 2026-05-09 10:07:25 +08:00
KenanZhu 46b3447d1e feat(autoscript): 将预处理脚本重构为 AutoScript DSL,新增可视化编排与预览对话框 2026-05-08 20:46:54 +08:00
Gogs 4d0d7a952c feat(preproc): 新增适用于重复性定时任务的预处理脚本以及可视化编排对话框 2026-05-08 15:23:24 +08:00
KenanZhu e11f696b76 style(*): 添加缺失的版权信息,并同一版权年份为文件创建时间的年份 2026-05-06 01:01:52 +08:00
KenanZhu ffae43d5bd fix(ConfigUtils): 添加未导入的 os 模块 2026-03-24 21:49:52 +08:00
Gogs baa4f23136 refactor(config): 新增 ConfigUtils 工具类并优化配置管理逻辑
- 新增 ConfigUtils 工具类,提供配置路径获取等工具方法
- 将 ConfigManager.getValidateAutomationConfigPaths() 重构为 ConfigUtils.getAutomationConfigPaths()
- 优化 MsgBase 中 LogManager 的导入方式,使用模块导入替代函数导入
- 规范化 TimerUtils.py 中 calculate_next_repeat_time() 的文档字符串格式
2026-03-23 13:31:06 +08:00
KenanZhu 1c88d3db7b chore(requirement): 移除 opencv-python 和 pywin32 冗余依赖 2026-03-22 22:56:43 +08:00
github-actions[bot] 3880f90916 chore(release): merge release/v1.2.1 to main [auto release commit] 2026-03-22 14:17:40 +00:00
github-actions[bot] d3d146b1b3 chore(release): v1.2.1 [auto release commit] 2026-03-22 14:14:27 +00:00
KenanZhu 0f74a3b0ec chore(requirement): 将 installed-browsers 替换为 pybrowsers 依赖 2026-03-22 22:05:52 +08:00
KenanZhu 9305c559cd refactor(WebBrowserDetector): 切换浏览器检测库为 browsers 并添加检测结果去重 2026-03-22 22:04:31 +08:00
KenanZhu f56945f29e fix(AppInitializer): 优化驱动目录初始化日志逻辑,仅在目录不存在时输出日志 2026-03-22 21:43:23 +08:00
KenanZhu 37132de4fc fix(ALTimerTaskManageWidget): 修复重复性定时任务删除时因 history 字段不存在导致 len(int) 异常 2026-03-22 21:34:08 +08:00
github-actions[bot] ac5385bcfe chore(release): merge release/v1.2.0 to main [auto release commit] 2026-03-21 10:58:40 +00:00
52 changed files with 4861 additions and 214 deletions
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
+599
View File
@@ -0,0 +1,599 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
import re
from datetime import (
datetime,
timedelta,
date,
time
)
from .ASObject import (
ASObject,
_META_VARS,
_inferType
)
from .ASOperator import ASOperator
from .ASTokenizer import (
ASTokenizer,
NodeVisitor,
Script,
IfNode,
SetNode,
OpNode,
PassNode,
UnrecogNode
)
__all__ = ["execute", "addTargetVar", "splitTopLevel"]
# Engine state
# User-registered target variables (bound to target_data paths)
_TARGET_VARS = {}
# Free-form script variables (not bound to target_data)
_SCRIPT_VARS = {}
# Name -> ASObject lookup map built from _META_VARS, _TARGET_VARS, and display names
_FIELD_MAP = {}
def _errPos(
line: int,
message: str
) -> str:
"""
Format an error message with a script line number.
Args:
line (int): The script line number where the error occurred.
message (str): The error description.
Returns:
str: A formatted error string like "AutoScript syntax error(line X): message".
"""
return f"AutoScript 语法错误(第{line}行): {message}"
# Pre-compiled regex patterns for value resolution
_RE_TIME = re.compile(r"^TIME\((\d{1,2}):(\d{2})\)$", re.IGNORECASE)
_RE_DATE = re.compile(r"^DATE\((\d{4})-(\d{2})-(\d{2})\)$", re.IGNORECASE)
_RE_CUR_DATE_OFFSET = re.compile(r"^CURRENT_DATE\s*\+\s*(\d+)$", re.IGNORECASE)
_RE_CUR_TIME_OFFSET = re.compile(r"^CURRENT_TIME\s*\+\s*(\d+)$", re.IGNORECASE)
def splitTopLevel(
text: str,
delimiter: str
) -> list:
"""
Split a condition expression by a delimiter (.AND. / .OR.), respecting parentheses.
Only splits at the top nesting level; delimiters inside parentheses are ignored.
Args:
text (str): The condition expression to split.
delimiter (str): The delimiter string, e.g. ".OR." or ".AND.".
Returns:
list: A list of sub-expression strings (stripped of leading/trailing whitespace).
"""
parts = []
depth = 0
buf = ""
i = 0
text_upper = text.upper()
delim_upper = delimiter.upper()
dlen = len(delim_upper)
while i < len(text):
if text[i] == "(":
depth += 1
buf += text[i]
elif text[i] == ")":
depth -= 1
buf += text[i]
elif depth == 0 and text_upper[i:i + dlen] == delim_upper:
parts.append(buf)
buf = ""
i += dlen
continue
else:
buf += text[i]
i += 1
if buf.strip():
parts.append(buf)
return parts
def _buildFieldMap():
"""
Rebuild the _FIELD_MAP lookup from _META_VARS and _TARGET_VARS.
Each variable is registered under both its canonical name (uppercased)
and its display_name (if present), so that scripts can refer to either.
"""
_FIELD_MAP.clear()
for ch_name, obj in _META_VARS.items():
_FIELD_MAP[obj.name.upper()] = obj
_FIELD_MAP[ch_name.upper().strip()] = obj
for obj in _TARGET_VARS.values():
_FIELD_MAP[obj.name.upper()] = obj
if obj.display_name:
_FIELD_MAP[obj.display_name.upper().strip()] = obj
def _resolveFieldObj(
field_name: str
):
"""
Resolve a field name to its ASObject by looking up _FIELD_MAP then _SCRIPT_VARS.
Unlike getting a raw value, this returns the ASObject instance itself,
preserving type information for operations and comparisons.
Args:
field_name (str): The field name (case-insensitive).
Returns:
ASObject or None: The resolved ASObject, or None if not found.
"""
upper_name = field_name.upper().strip()
obj = _FIELD_MAP.get(upper_name)
if obj:
return obj
obj = _SCRIPT_VARS.get(upper_name)
if obj:
return obj
return None
def _resolveValue(
value_str: str,
target_data: dict
):
"""
Parse and resolve a value string from a script into a Python object.
Supports the following literal forms:
- TIME(hh:mm)
- DATE(yyyy-mm-dd)
- .TRUE. / .FALSE.
- Single/double quoted strings (with escaped single quotes)
- CURRENT_DATE + N / CURRENT_TIME + N (relative offsets)
- Numeric literals (int / float)
- Field references (resolved via _resolveFieldObj)
Args:
value_str (str): The raw value string from the script.
target_data (dict): The application data dict.
Returns:
The resolved Python value.
"""
s = value_str.strip()
m = _RE_TIME.match(s)
if m:
return time(int(m.group(1)), int(m.group(2)))
m = _RE_DATE.match(s)
if m:
return date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
up = s.upper()
if up == ".TRUE.":
return True
if up == ".FALSE.":
return False
if s.startswith("'") and s.endswith("'"):
return s[1:-1].replace("''", "'")
if s.startswith('"') and s.endswith('"'):
return s[1:-1]
m = _RE_CUR_DATE_OFFSET.match(s)
if m:
days = int(m.group(1))
return datetime.now().date() + timedelta(days=days)
m = _RE_CUR_TIME_OFFSET.match(s)
if m:
hours = int(m.group(1))
return (datetime.now() + timedelta(hours=hours)).time()
try:
return int(s)
except ValueError:
pass
try:
return float(s)
except ValueError:
pass
obj = _resolveFieldObj(s)
if obj:
return obj.getValue(target_data)
return ""
def _resolveAsObject(
expr: str,
target_data: dict
) -> ASObject:
"""
Resolve a value expression to an ASObject.
- If the expression is a registered field name, returns its ASObject directly.
- If the expression is a literal (number, string, DATE(), TIME(), bool),
creates a temporary ASObject with the inferred type.
This is the key function that ensures all internal operations work
with typed ASObject instances rather than raw Python values.
Args:
expr (str): The raw expression string from the script.
target_data (dict): The application data dict.
Returns:
ASObject: A registered or temporary ASObject representing the expression value.
"""
s = expr.strip()
obj = _resolveFieldObj(s)
if obj is not None:
return obj
value = _resolveValue(s, target_data)
inferred = _inferType(value, s)
return ASObject._makeTemp(value, inferred)
def _evaluateCondition(
condition_str: str,
target_data: dict,
line: int = 0
) -> bool:
"""
Evaluate a condition expression and return a boolean result.
Supports:
- Boolean literals: .TRUE., .FALSE.
- .AND. / .OR. operators (lowest precedence)
- Parenthesised sub-expressions
- Comparison operators: .EQ., .NEQ., .BGT., .BLT., .BGE., .BLE.
All operands are resolved as ASObject instances (via _resolveAsObject)
and comparisons are delegated to ASOperator.compare().
Args:
condition_str (str): The raw condition expression from the script.
target_data (dict): The application data dict.
Returns:
bool: The evaluation result.
Raises:
ValueError: If the expression contains unrecognised tokens or type-mismatched comparisons.
"""
s = condition_str.strip()
if not s:
return False
or_parts = splitTopLevel(s, ".OR.")
if len(or_parts) > 1:
return any(
_evaluateCondition(p.strip(), target_data, line)
for p in or_parts
)
and_parts = splitTopLevel(s, ".AND.")
if len(and_parts) > 1:
return all(
_evaluateCondition(p.strip(), target_data, line)
for p in and_parts
)
s = s.strip()
if s.startswith("(") and s.endswith(")"):
return _evaluateCondition(s[1:-1], target_data, line)
up = s.upper()
if up == ".TRUE.":
return True
if up == ".FALSE.":
return False
for op in ASOperator._COMPARE:
idx = up.find(op.upper())
if idx < 0:
continue
left_raw = s[:idx].strip()
right_raw = s[idx + len(op):].strip()
try:
left_obj = _resolveAsObject(left_raw, target_data)
right_obj = _resolveAsObject(right_raw, target_data)
return ASOperator.compare(left_obj, right_obj, op, target_data)
except ValueError as e:
raise ValueError(_errPos(line, str(e)))
raise ValueError(
_errPos(line, f"无法识别的条件表达式 '{condition_str}'")
)
def _executeSet(
line_text: str,
target_data: dict,
line: int = 0
):
"""
Execute a SET statement to assign a value to a field or script variable.
Parses the line as "SET field_name = value_expr", resolves the value,
and assigns it. If the target field does not exist, a new script variable
is created with an inferred type.
Args:
line (str): The raw SET line from the script.
target_data (dict): The application data dict.
Raises:
ValueError: If the value string contains unexpected extra tokens.
"""
rest = line_text[3:].strip()
eq_idx = rest.find("=")
if eq_idx < 0:
return
field_name = rest[:eq_idx].strip()
value_str = rest[eq_idx + 1:].strip()
if not field_name:
return
resolved = _resolveValue(value_str, target_data)
stripped = value_str.strip()
if resolved == "" and stripped not in ("''", '""') and len(stripped.split()) > 1:
raise ValueError(_errPos(line, f"SET 值中存在多余内容 '{stripped}'"))
upper_name = field_name.upper().strip()
obj = _FIELD_MAP.get(upper_name)
if not obj:
obj = _SCRIPT_VARS.get(upper_name)
if obj:
try:
obj.setValue(resolved, target_data)
except ValueError as e:
raise ValueError(_errPos(line, str(e)))
return
inferred_type = _inferType(resolved, stripped)
new_var = ASObject(
upper_name,
inferred_type,
read_only=False,
is_config=False,
default_value=resolved
)
_SCRIPT_VARS[upper_name] = new_var
def _executeOperation(
line_text: str,
target_data: dict,
line: int = 0
):
"""
Execute a field operation statement: "FIELD .ADD. N" or "FIELD .SUB. N".
Resolves the left side as a registered ASObject and the right side
as a temporary numeric ASObject, then delegates to ASOperator.apply().
Args:
line (str): The raw operation line from the script (e.g. "RESERVE_DATE .ADD. 1").
target_data (dict): The application data dict.
Raises:
ValueError: If the field is unknown, the operand is invalid,
or the type does not support the operation.
"""
parts = line_text.split()
if len(parts) < 3:
return
if len(parts) > 3:
raise ValueError(
_errPos(line, f"操作语句中存在多余内容 '{' '.join(parts[3:])}'")
)
field_name = parts[0].upper().strip()
op = parts[1].upper().strip()
raw_value = parts[2].strip()
target = _resolveFieldObj(field_name)
if target is None:
raise ValueError(_errPos(line, f"未知字段 '{field_name}'"))
try:
operand = _resolveAsObject(raw_value, target_data)
ASOperator.apply(target, operand, op, target_data)
except ValueError as e:
raise ValueError(_errPos(line, str(e)))
def addTargetVar(
name: str,
var_type: str,
key_path: list,
display_name: str = None
):
"""
Register a new target variable bound to a path in the application data dict.
Once registered, the variable can be read, written, and operated on in scripts
using its canonical name or display_name.
Args:
name (str): The canonical variable name (e.g. "RESERVE_DATE").
var_type (str): The type ("Int", "Float", "Boolean", "Date", "Time", "String").
key_path (list): The nested path into target_data, e.g. ["reserve_info", "date"].
display_name (str): An optional Chinese alias for use in script conditions.
Example:
>>> addTargetVar("MY_FIELD", "String", ["custom", "field"], display_name="自定义字段")
"""
upper_name = name.upper().strip()
obj = ASObject(
upper_name,
var_type,
is_config=True,
key_path=key_path,
display_name=display_name
)
_TARGET_VARS[upper_name] = obj
class _EngineExecutor(NodeVisitor):
"""
AST visitor that executes AutoScript against target_data.
Walks the AST and dispatches SET / ADD / SUB operations
via visitScript / visitIf / visitSet / visitOp / visitPass / visitUnrecog.
"""
def __init__(
self,
target_data: dict
):
super().__init__()
self._target_data = target_data
self._cur_line = 0
@property
def _line(self) -> int:
"""Return current line number for _errPos calls."""
return self._cur_line
def _incLine(
self
):
self._cur_line += 1
def visitScript(
self,
_node: Script
):
for child in _node.body:
child.accept(self)
def visitIf(
self,
_node: IfNode
):
self._incLine()
if not _node.closed:
raise ValueError(_errPos(self._line, "IF 与 ENDIF / END IF 不匹配"))
matched = _evaluateCondition(_node.condition, self._target_data, self._line)
if matched:
for child in _node.body:
child.accept(self)
else:
executed = False
for elif_node in _node.elif_branches:
self._incLine()
if _evaluateCondition(elif_node.condition, self._target_data, self._line):
for child in elif_node.body:
child.accept(self)
executed = True
break
if not executed and _node.else_body:
self._incLine()
for child in _node.else_body:
child.accept(self)
def visitSet(
self,
_node: SetNode
):
self._incLine()
full_line = f"SET {_node.target} = {_node.value}"
_executeSet(full_line, self._target_data, self._line)
def visitOp(
self,
_node: OpNode
):
self._incLine()
op_upper = _node.op_type.upper()
full_line = f"{_node.target} .{op_upper}. {_node.value}"
_executeOperation(full_line, self._target_data, self._line)
def visitPass(
self,
_node: PassNode
):
self._incLine()
def visitUnrecog(
self,
_node: UnrecogNode
):
self._incLine()
upper = _node.raw_line.upper().strip()
if upper.startswith("IF"):
paren_open = upper.find("(")
if paren_open < 0:
raise ValueError(_errPos(self._line, "IF 缺少左括号"))
depth = 1
for ci in range(paren_open + 1, len(upper)):
if upper[ci] == "(":
depth += 1
elif upper[ci] == ")":
depth -= 1
if depth == 0:
remaining = upper[ci + 1:].strip()
if remaining and remaining != "THEN":
raise ValueError(_errPos(self._line, f"IF 条件后存在多余内容 '{remaining}'"))
break
if depth > 0:
raise ValueError(_errPos(self._line, "IF 缺少右括号"))
elif upper.startswith("ELSE IF"):
paren_open = upper.find("(")
if paren_open < 0:
raise ValueError(_errPos(self._line, "ELSE IF 缺少左括号"))
_executeOperation(_node.raw_line, self._target_data, self._line)
def execute(
script_text: str,
target_data: dict
):
"""
Execute an AutoScript on the given target data.
Parses the script into an AST via ASTokenizer.parse(),
then walks the tree with a visitor to evaluate conditions
and dispatch SET / ADD / SUB operations.
Args:
script_text (str): The AutoScript source code.
target_data (dict): The application data dict to read from / write to.
Raises:
ValueError: On syntax errors, unbalanced IF/END IF, unknown fields, etc.
"""
_buildFieldMap()
if not script_text or not script_text.strip():
return
ast = ASTokenizer.parse(script_text)
if not ast.body:
return
executor = _EngineExecutor(target_data)
ast.accept(executor)
+259
View File
@@ -0,0 +1,259 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
import re
from datetime import (
datetime,
date,
time
)
__all__ = [
"ASObject",
"_META_VARS",
"_inferType"
]
# Default values for each supported type when no value is present
_TYPE_DEFAULTS = {
"Int": 0,
"Float": 0.0,
"Boolean": False,
"Date": None,
"Time": None,
"String": ""
}
class ASObject:
"""
Represents a variable object used throughout the AutoScript engine.
An ASObject can be a meta variable (read-only, e.g. CURRENT_DATE),
a target variable (bound to a config data dict via key_path),
or a script variable (free-form, stored internally).
Args:
name (str): The canonical name of the variable (case-insensitive).
var_type (str): One of "Int", "Float", "Boolean", "Date", "Time", "String".
read_only (bool): Whether the variable is read-only (default: False).
is_config (bool): Whether the variable maps to a target_data path (default: False).
key_path (list): The nested key path into target_data, e.g. ["reserve_info", "date"].
default_value: The fallback value when no target_data value is found.
display_name (str): An alias for use in script conditions and assignments.
Example:
>>> obj = ASObject("MY_DATE", "Date", is_config=True,
... key_path=["reserve_info", "date"],
... display_name="预约日期")
>>> obj.getValue({"reserve_info": {"date": "2026-05-01"}})
datetime.date(2026, 5, 1)
"""
_TEMP_COUNTER = 0
def __init__(
self,
name: str,
var_type: str, *,
read_only: bool = False,
is_config: bool = False,
key_path: list = None,
default_value=None,
display_name: str = None
):
self.name = name
self.var_type = var_type
self.read_only = read_only
self.is_config = is_config
self.key_path = key_path or []
self._value = default_value
self.display_name = display_name
@classmethod
def _makeTemp(
cls,
value,
inferred_type: str
):
"""
Create a temporary unnamed ASObject from a literal value.
Temporary objects are used for inline script literals (e.g. 42,
'hello', DATE(2026-01-01)) so they can participate in typed
operations alongside registered variables.
Args:
value: The resolved Python value.
inferred_type (str): The AutoScript type name.
Returns:
ASObject: A temporary, non-config, read-write ASObject.
"""
cls._TEMP_COUNTER += 1
return cls(
f"__TMP_{cls._TEMP_COUNTER}",
inferred_type,
read_only=False,
is_config=False,
default_value=value
)
def _typeEmpty(
self
):
"""
Return the type-appropriate empty / default value.
"""
return _TYPE_DEFAULTS.get(self.var_type, "")
def getValue(
self,
target_data: dict = None
):
"""
Retrieve the current value of this variable.
For read-only variables (CURRENT_DATE, CURRENT_TIME), returns the
live datetime. For config variables, traverses the key_path into
target_data and parses Date/Time strings. Otherwise returns the
internal _value.
Args:
target_data (dict): The application data dict (required for config vars).
Returns:
The resolved value, or a type-appropriate default if missing.
"""
if self.read_only:
if self.name == "CURRENT_DATE":
return datetime.now().date()
if self.name == "CURRENT_TIME":
return datetime.now().time()
return self._value
if self.is_config and target_data is not None and self.key_path:
d = target_data
for key in self.key_path[:-1]:
d = d.get(key, {})
if not isinstance(d, dict):
return self._typeEmpty()
raw = d.get(self.key_path[-1])
if raw is None:
return self._typeEmpty()
if self.var_type == "Date" and isinstance(raw, str):
try:
return datetime.strptime(raw, "%Y-%m-%d").date()
except ValueError:
return self._typeEmpty()
if self.var_type == "Time" and isinstance(raw, str):
try:
return datetime.strptime(raw, "%H:%M").time()
except ValueError:
return self._typeEmpty()
return raw
return self._value
def setValue(
self,
value,
target_data: dict = None
):
"""
Assign a new value to this variable, with type coercion.
Performs coercion for Boolean (string -> bool), Int, and Float types.
For config variables, dates/times are converted back to strings before
writing into target_data.
Args:
value: The value to assign.
target_data (dict): The application data dict (required for config vars).
Raises:
ValueError: If the variable is read-only or value cannot be coerced.
"""
if self.read_only:
raise ValueError(f"不能修改只读变量 '{self.name}'")
if self.var_type == "Boolean" and not isinstance(value, bool):
value = (str(value).upper() == "TRUE")
if self.var_type == "Int" and not isinstance(value, int):
try:
value = int(value)
except (ValueError, TypeError):
raise ValueError(f"无法将值 '{value}' 转换为 Int 类型")
if self.var_type == "Float" and not isinstance(value, float):
try:
value = float(value)
except (ValueError, TypeError):
raise ValueError(f"无法将值 '{value}' 转换为 Float 类型")
if self.is_config:
if self.var_type == "Date" and isinstance(value, date):
value = value.strftime("%Y-%m-%d")
if self.var_type == "Time" and isinstance(value, time):
value = value.strftime("%H:%M")
if self.is_config and target_data is not None and self.key_path:
d = target_data
for key in self.key_path[:-1]:
d = d.setdefault(key, {})
d[self.key_path[-1]] = value
else:
self._value = value
# Built-in read-only meta variables available to all scripts
_META_VARS = {
"CURRENT_DATE": ASObject("CURRENT_DATE", "Date", read_only=True, display_name="当前日期"),
"CURRENT_TIME": ASObject("CURRENT_TIME", "Time", read_only=True, display_name="当前时间"),
}
def _inferType(
value,
raw_expr: str = None
) -> str:
"""
Infer the ASObject type string from a Python value or raw expression.
When the Python type is ambiguous (e.g. int can be Int or a component
of Date), the raw_expr is used as a hint.
Args:
value: The resolved Python value.
raw_expr (str): The original expression string from the script (optional).
Returns:
str: One of "Boolean", "Int", "Float", "Date", "Time", "String".
"""
if isinstance(value, bool):
return "Boolean"
if isinstance(value, int):
return "Int"
if isinstance(value, float):
return "Float"
if isinstance(value, date):
return "Date"
if isinstance(value, time):
return "Time"
if raw_expr:
if re.match(r"^DATE\(\d{4}-\d{2}-\d{2}\)$", raw_expr, re.IGNORECASE):
return "Date"
if re.match(r"^TIME\(\d{1,2}:\d{2}\)$", raw_expr, re.IGNORECASE):
return "Time"
return "String"
+78
View File
@@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
class ParsingObserver:
"""
Base observer for AutoScript parsing events.
Subclass and override the relevant methods to react to
tokenization / parsing events produced by ASTokenizer.
This is the core abstraction that lets pre-check and
orchestration modules subscribe to the same parsing pipeline.
"""
def onParseStart(
self,
script_text: str
):
"""
Called when tokenization of a new script begins.
Args:
script_text (str): The full script text being parsed.
"""
pass
def onTokenParsed(
self,
kind: str | None,
data,
line_num: int,
raw_line: str
):
"""
Called after each script line has been classified as a token.
Args:
kind (str | None): Token kind (K_IF, K_ELSE_IF, K_ELSE,
K_ENDIF, K_SET, K_ADD, K_SUB) or
None if unrecognised.
data: Token payload — condition string for IF/ELSE IF,
(target, value) tuple for SET/ADD/SUB,
None for ELSE/ENDIF/unrecognised.
line_num (int): 1-based line number.
raw_line (str): The stripped raw line text.
"""
pass
def onParseComplete(
self,
statements: list
):
"""
Called when flat tokenization is complete.
Args:
statements (list[Stmt]): The list of parsed Stmt objects.
"""
pass
def onASTReady(
self,
ast
):
"""
Called when full AST construction is complete (after parse()).
Args:
ast (Script): The root Script AST node.
"""
pass
+191
View File
@@ -0,0 +1,191 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
from datetime import (
datetime,
timedelta,
date,
time
)
from .ASObject import ASObject
__all__ = ["ASOperator", "ARITH_TYPES", "COMPARISON_OPERATORS"]
class ASOperator:
"""
Centralised type-safe operations for AutoScript engine types.
All arithmetic (ADD / SUB) and comparison operators are routed through
this class, which dispatches to the correct Python-level logic based on
the ASObject's var_type. This keeps type-specific branching in one
place instead of scattering it across the engine.
Args:
op (str): One of ".ADD.", ".SUB.", ".EQ.", ".NEQ.", ".BGT.",
".BLT.", ".BGE.", ".BLE.".
Example:
>>> obj = ASObject("X", "Int", default_value=10)
>>> ASOperator.apply(obj, ASObject._makeTemp(5, "Int"), ".ADD.", None)
>>> obj.getValue()
15
>>> ASOperator.compare(
... obj,
... ASObject._makeTemp(15, "Int"),
... ".EQ.", None
... )
True
"""
_COMPARE = {
".EQ." : lambda a, b: a == b,
".NEQ.": lambda a, b: a != b,
".BGT.": lambda a, b: a > b,
".BLT.": lambda a, b: a < b,
".BGE.": lambda a, b: a >= b,
".BLE.": lambda a, b: a <= b,
}
_ARITH_TYPES = {"Date", "Time", "Int", "Float"}
# Comparison-compatible type groups
_COMPATIBLE_GROUPS = [
{"String"},
{"Boolean"},
{"Int", "Float"},
{"Date"},
{"Time"},
]
@classmethod
def apply(
cls,
target: ASObject,
operand: ASObject,
op: str,
target_data: dict
):
"""
Apply ADD or SUB to a target ASObject, modifying it in place.
Args:
target (ASObject): The variable to modify.
operand (ASObject): The operand (numeric value for Date/Time/Int/Float).
op (str): ".ADD." or ".SUB.".
target_data (dict): Application data dict (passed through to getValue/setValue).
Raises:
ValueError: If the type does not support the operation or values are invalid.
"""
tp = target.var_type
op_tp = operand.var_type
if tp not in cls._ARITH_TYPES:
raise ValueError(f"'{tp}' 类型字段不支持操作运算")
if op_tp not in ("Int", "Float"):
raise ValueError(f"操作数类型 '{op_tp}' 不能用于运算,需要数值类型 (Int / Float)")
if tp in ("Date", "Time") and op_tp != "Int":
raise ValueError(f"'{tp}' 类型的加减法操作数必须为 Int 类型,不允许 Float")
target_val = target.getValue(target_data)
if target_val is None:
raise ValueError(f"'{target.name}' 的值为空,无法进行运算")
op_name = "ADD" if op == ".ADD." else "SUB" if op == ".SUB." else None
if op_name is None:
raise ValueError(f"不支持的操作 '{op}'")
sign = 1 if op == ".ADD." else -1
cls._arithBinary(target, target_val, operand, target_data, sign, op_name)
@classmethod
def _arithBinary(
cls,
target: ASObject,
target_val,
operand: ASObject,
target_data: dict,
sign: int,
op_name: str = ""
):
"""Apply arithmetic per type."""
tp = target.var_type
raw_op = operand._value
if tp == "Date":
if not isinstance(target_val, date):
raise ValueError(f"'{target.name}' 的值 '{target_val}' 不是有效日期")
new_val = target_val + timedelta(days=int(raw_op)) * sign
elif tp == "Time":
if not isinstance(target_val, time):
raise ValueError(f"'{target.name}' 的值 '{target_val}' 不是有效时间")
delta = timedelta(hours=int(raw_op)) * sign
dt = datetime.combine(datetime.today(), target_val) + delta
new_val = dt.time()
elif tp == "Int":
new_val = int(target_val) + int(raw_op)*sign
elif tp == "Float":
new_val = float(target_val) + float(raw_op)*sign
else:
raise ValueError(f"'{tp}' 类型不支持 {op_name} 操作")
target.setValue(new_val, target_data)
@classmethod
def compare(
cls,
left: ASObject,
right: ASObject,
op: str,
target_data: dict
) -> bool:
"""
Compare two ASObjects using the given comparison operator.
Args:
left (ASObject): Left-hand side.
right (ASObject): Right-hand side.
op (str): One of ".EQ.", ".NEQ.", ".BGT.", ".BLT.", ".BGE.", ".BLE.".
target_data (dict): Application data dict.
Returns:
bool: The comparison result.
Raises:
ValueError: If the types are incompatible for comparison.
"""
cmp_func = cls._COMPARE.get(op)
if cmp_func is None:
raise ValueError(f"未知的比较操作 '{op}'")
left_tp = left.var_type
right_tp = right.var_type
if left_tp != right_tp:
same_group = any(
left_tp in g and right_tp in g
for g in cls._COMPATIBLE_GROUPS
)
if not same_group:
raise ValueError(
f"类型不兼容: 无法将 '{left.name}' ({left_tp}) "
f"'{right.name}' ({right_tp}) 进行比较"
)
left_val = left.getValue(target_data)
right_val = right.getValue(target_data)
try:
return cmp_func(left_val, right_val)
except TypeError:
raise ValueError(
f"无法比较 '{left.name}' ({left.var_type}) "
f"'{right.name}' ({right.var_type})"
)
# Public constants
# may be used by the GUI orchestration dialog.
ARITH_TYPES = ASOperator._ARITH_TYPES
COMPARISON_OPERATORS = set(ASOperator._COMPARE.keys())
+575
View File
@@ -0,0 +1,575 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
import re
__all__ = [
"ASTokenizer",
"Stmt",
"Script",
"IfNode",
"ElifNode",
"SetNode",
"OpNode",
"PassNode",
"UnrecogNode",
"NodeVisitor",
"LineStrategy"
]
# Token kind constants
K_IF = "IF"
K_ELSE_IF = "ELSE IF"
K_ELSE = "ELSE"
K_ENDIF = "ENDIF"
K_SET = "SET"
K_ADD = "ADD"
K_SUB = "SUB"
K_PASS = "PASS"
# Op-type constants
OP_SET = "set"
OP_ADD = "add"
OP_SUB = "sub"
# Compiled line patterns
_RE_IF = re.compile(r"^IF\((.+)\)(?:\s+THEN\s*)?$", re.IGNORECASE)
_RE_ELSE_IF = re.compile(r"^ELSE\s+IF\((.+)\)(?:\s+THEN\s*)?$", re.IGNORECASE)
_RE_ELSE = re.compile(r"^ELSE\s*$", re.IGNORECASE)
_RE_ENDIF = re.compile(r"^(ENDIF|END IF)$", re.IGNORECASE)
_RE_SET = re.compile(r"^SET\s+(\w+)\s*=\s*(.+)$", re.IGNORECASE)
_RE_ADD = re.compile(r"^(\w+)\s+\.ADD\.\s+(\d+)$", re.IGNORECASE)
_RE_SUB = re.compile(r"^(\w+)\s+\.SUB\.\s+(\d+)$", re.IGNORECASE)
_RE_PASS = re.compile(r"^\s*PASS\s*$", re.IGNORECASE)
class Script:
"""
Root AST node for an entire AutoScript.
Contains an ordered list of top-level statement nodes.
"""
def __init__(
self,
body: list = None
):
self.body = body or []
def accept(
self,
visitor
):
return visitor.visitScript(self)
class IfNode:
"""
IF conditional block with optional ELSE IF / ELSE branches.
Attributes:
condition (str): Raw condition expression.
body (list): Statements executed when condition is true.
elif_branches (list[ElifNode]): ELSE IF branches in order.
else_body (list): Statements executed for the ELSE branch.
closed (bool): Whether this IF has a matching ENDIF token.
"""
def __init__(
self,
condition: str = "",
body: list = None,
elif_branches: list = None,
else_body: list = None,
closed: bool = True
):
self.condition = condition
self.body = body or []
self.elif_branches = elif_branches or []
self.else_body = else_body or []
self.closed = closed
def accept(
self,
visitor
):
return visitor.visitIf(self)
class ElifNode:
"""
ELSE IF branch within an IfNode.
"""
def __init__(
self,
condition: str = "",
body: list = None
):
self.condition = condition
self.body = body or []
class SetNode:
"""
SET assignment statement.
"""
def __init__(
self,
target: str = "",
value: str = ""
):
self.target = target
self.value = value
def accept(
self,
visitor
):
return visitor.visitSet(self)
class OpNode:
"""
.ADD. / .SUB. operation statement.
"""
def __init__(
self,
op_type: str = "",
target: str = "",
value: str = ""
):
self.op_type = op_type
self.target = target
self.value = value
def accept(
self,
visitor
):
return visitor.visitOp(self)
class PassNode:
"""
PASS no-op statement.
"""
def accept(
self,
visitor
):
return visitor.visitPass(self)
class UnrecogNode:
"""
Unrecognised line preserved for downstream error reporting.
"""
def __init__(
self,
raw_line: str = ""
):
self.raw_line = raw_line
def accept(
self,
visitor
):
return visitor.visitUnrecog(self)
class NodeVisitor:
"""
Base visitor for the AutoScript AST.
Subclass and override visit* methods to implement
custom traversal logic. Default walks tree depth-first.
"""
def visitScript(
self,
_node: Script
):
for child in _node.body:
child.accept(self)
def visitIf(
self,
_node: IfNode
):
for child in _node.body:
child.accept(self)
for elif_node in _node.elif_branches:
for child in elif_node.body:
child.accept(self)
for child in _node.else_body:
child.accept(self)
def visitSet(
self,
_node: SetNode
):
pass
def visitOp(
self,
_node: OpNode
):
pass
def visitPass(
self,
_node: PassNode
):
pass
def visitUnrecog(
self,
_node: UnrecogNode
):
pass
class LineStrategy:
"""
Encapsulates a regex pattern and its data-extraction handler.
Used by the tokenizer to classify a single line.
"""
def __init__(
self,
pattern,
handler
):
self.pattern = pattern
self.handler = handler
def match(
self,
line: str
):
m = self.pattern.match(line)
if m:
return self.handler(m)
return None
# Strategy instances — one per recognised AutoScript syntax form
_LINE_STRATEGIES = [
LineStrategy(_RE_IF, lambda m: (K_IF, m.group(1))),
LineStrategy(_RE_ELSE_IF, lambda m: (K_ELSE_IF, m.group(1))),
LineStrategy(_RE_ELSE, lambda m: (K_ELSE, None)),
LineStrategy(_RE_ENDIF, lambda m: (K_ENDIF, None)),
LineStrategy(_RE_SET, lambda m: (K_SET, (m.group(1).strip(), m.group(2).strip()))),
LineStrategy(_RE_ADD, lambda m: (K_ADD, (m.group(1).strip(), m.group(2).strip()))),
LineStrategy(_RE_SUB, lambda m: (K_SUB, (m.group(1).strip(), m.group(2).strip()))),
LineStrategy(_RE_PASS, lambda m: (K_PASS, None)),
]
class Stmt:
"""
Flat statement container, backward-compatible with the original
tokenize() output and the orchestration dialog's _classifyLine.
"""
def __init__(
self,
kind: str | None = None,
condition: str | None = None,
target: str | None = None,
value: str | None = None,
op_type: str | None = None,
raw_line: str = ""
):
self.kind = kind
self.condition = condition
self.target = target
self.value = value
self.op_type = op_type
self.raw_line = raw_line
class ASTokenizer:
"""
Tokenizer / parser for the AutoScript DSL.
Main class-level entry points (engine-facing):
- classifyLine(line) — single-line classifier.
- tokenize(script) — flat Stmt list.
- parse(script) — structured AST (Script root).
Observer-enabled API (used by pre-check & orchestration):
>>> obs = ScriptPrecheckObserver()
>>> stmts = ASTokenizer.tokenizeWithObservers(script, [obs])
"""
@classmethod
def _notifyObservers(
cls,
observers: list,
method: str,
*args
):
for obs in observers:
getattr(obs, method)(*args)
@classmethod
def _matchLine(
cls,
stripped: str
):
for strategy in _LINE_STRATEGIES:
result = strategy.match(stripped)
if result:
return result
return (None, None)
@classmethod
def _buildStmt(
cls,
stripped: str,
kind: str | None,
data
) -> Stmt:
stmt = Stmt(kind=kind, raw_line=stripped)
if kind == K_IF or kind == K_ELSE_IF:
stmt.condition = data
elif kind == K_SET:
stmt.target, stmt.value = data
stmt.op_type = OP_SET
elif kind == K_ADD:
stmt.target, stmt.value = data
stmt.op_type = OP_ADD
elif kind == K_SUB:
stmt.target, stmt.value = data
stmt.op_type = OP_SUB
return stmt
@classmethod
def _stripComment(
cls,
line: str
) -> str:
in_single = False
for i, ch in enumerate(line):
if ch == "'":
in_single = not in_single
elif ch == "/" and i + 1 < len(line) and line[i + 1] == "/" and not in_single:
return line[:i].rstrip()
return line
@classmethod
def _tokenizeImpl(
cls,
script: str
) -> list:
statements = []
for raw_line in script.split("\n"):
code = cls._stripComment(raw_line.strip())
if not code:
continue
kind, data = cls._matchLine(code)
statements.append(cls._buildStmt(code, kind, data))
return statements
@classmethod
def _parseTokens(
cls,
tokens: list
) -> Script:
body = []
i = 0
while i < len(tokens):
tok = tokens[i]
kind = tok.kind
if kind == K_IF:
node, consumed = cls._parseIfBlock(tokens, i)
body.append(node)
i += consumed
elif kind in (K_ELSE_IF, K_ELSE, K_ENDIF):
i += 1
elif kind == K_SET:
body.append(SetNode(target=tok.target, value=tok.value))
i += 1
elif kind in (K_ADD, K_SUB):
body.append(OpNode(
op_type=tok.op_type,
target=tok.target,
value=tok.value
))
i += 1
elif kind == K_PASS:
body.append(PassNode())
i += 1
else:
body.append(UnrecogNode(raw_line=tok.raw_line))
i += 1
return Script(body=body)
@classmethod
def classifyLine(
cls,
stripped: str
):
kind, data = cls._matchLine(stripped)
if kind is None or kind == K_PASS:
return None
return (kind, data)
@classmethod
def tokenize(
cls,
script: str
) -> list:
return cls._tokenizeImpl(script)
@classmethod
def parse(
cls,
script: str
) -> Script:
return cls._parseTokens(cls._tokenizeImpl(script))
@classmethod
def tokenizeWithObservers(
cls,
script: str,
observers: list
) -> list:
"""
Tokenize and notify observers for each classified line.
Fires onParseStart, onTokenParsed, and onParseComplete
events to each observer. This is the single tokenization
pipeline shared by pre-check and orchestration modules.
"""
cls._notifyObservers(observers, "onParseStart", script)
statements = []
for i, raw_line in enumerate(script.split("\n"), 1):
code = cls._stripComment(raw_line.strip())
if not code:
continue
kind, data = cls._matchLine(code)
cls._notifyObservers(observers, "onTokenParsed", kind, data, i, code)
statements.append(cls._buildStmt(code, kind, data))
cls._notifyObservers(observers, "onParseComplete", statements)
return statements
@classmethod
def parseWithObservers(
cls,
script: str,
observers: list
) -> Script:
"""
Parse and notify observers throughout the pipeline.
Calls tokenizeWithObservers (which fires per-token events),
then builds the AST and fires onASTReady.
"""
tokens = cls.tokenizeWithObservers(script, observers)
ast = cls._parseTokens(tokens)
cls._notifyObservers(observers, "onASTReady", ast)
return ast
@classmethod
def _parseIfBlock(
cls,
tokens: list,
start: int
):
first = tokens[start]
node = IfNode(condition=first.condition or "")
body = []
elif_branches = []
else_body = []
current_target = body
i = start + 1
while i < len(tokens):
tok = tokens[i]
kind = tok.kind
if kind == K_IF:
sub_node, consumed = cls._parseIfBlock(tokens, i)
current_target.append(sub_node)
i += consumed
elif kind == K_ELSE_IF:
elif_branches.append(ElifNode(condition=tok.condition or ""))
current_target = elif_branches[-1].body
i += 1
elif kind == K_ELSE:
else_body = []
current_target = else_body
i += 1
elif kind == K_ENDIF:
node.body = body
node.elif_branches = elif_branches
node.else_body = else_body
return (node, i - start + 1)
elif kind == K_SET:
current_target.append(SetNode(target=tok.target, value=tok.value))
i += 1
elif kind in (K_ADD, K_SUB):
current_target.append(OpNode(
op_type=tok.op_type,
target=tok.target,
value=tok.value
))
i += 1
elif kind == K_PASS:
current_target.append(PassNode())
i += 1
else:
current_target.append(UnrecogNode(raw_line=tok.raw_line))
i += 1
node.body = body
node.elif_branches = elif_branches
node.else_body = else_body
node.closed = False
return (node, i - start)
+93
View File
@@ -0,0 +1,93 @@
"""
AutoScript module for the AutoLibrary project.
A lightweight scripting DSL for preprocessing user reservation data.
"""
from autoscript.ASTokenizer import (
ASTokenizer,
Stmt,
ElifNode,
Script,
IfNode,
SetNode,
OpNode,
)
from autoscript.ASEngine import (
execute,
addTargetVar,
splitTopLevel,
)
from autoscript.ASObject import _META_VARS as META_VARS
from autoscript.ASObserver import ParsingObserver
__all__ = [
"execute",
"addTargetVar",
"splitTopLevel",
"registerDefaultTargetVars",
"buildMockTargetData",
"META_VARS",
"ALL_VARIABLES",
"ASTokenizer",
"Stmt",
"Script",
"IfNode",
"SetNode",
"OpNode",
"ElifNode",
"ParsingObserver",
]
# Key paths into target_data dict for each target variable.
# (name, type, key_path, display_name)
_TARGET_VAR_DEFS = [
("USERNAME", "String",["username"], "用户名"),
("USER_ENABLE", "Boolean",["enabled"], "用户启用"),
("RESERVE_DATE", "Date", ["reserve_info", "date"], "预约日期"),
("RESERVE_BEGIN_TIME", "Time", ["reserve_info", "begin_time", "time"], "预约开始时间"),
("RESERVE_END_TIME", "Time", ["reserve_info", "end_time", "time"], "预约结束时间"),
]
# All variables (display_name -> (name, type)), derived from target vars + meta vars.
ALL_VARIABLES = {
display_name: (name, var_type)
for name, var_type, _, display_name in _TARGET_VAR_DEFS
} | {
obj.display_name: (obj.name, obj.var_type)
for obj in META_VARS.values()
}
_MOCK_TYPE_VALUES = {
"String": "__mock__",
"Boolean": True,
"Date": "2099-01-01",
"Time": "00:00",
"Int": 0,
"Float": 0.0,
}
def buildMockTargetData(
) -> dict:
"""
Build a target_data dict filled with type-appropriate mock values
for all registered target variables.
"""
data = {}
for _, var_type, key_path, _ in _TARGET_VAR_DEFS:
d = data
for key in key_path[:-1]:
d = d.setdefault(key, {})
d[key_path[-1]] = _MOCK_TYPE_VALUES.get(var_type, "")
return data
def registerDefaultTargetVars(
) -> None:
"""
Register all built-in target variables with the engine.
This must be called before any script execution.
Calling multiple times is idempotent (re-registers same keys).
"""
for name, var_type, key_path, display_name in _TARGET_VAR_DEFS:
addTargetVar(name, var_type, key_path, display_name)
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
+3 -3
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -11,7 +11,7 @@ import logging
import queue
import datetime
from managers.log.LogManager import getLogger
import managers.log.LogManager as LogManager
class MsgBase:
@@ -54,7 +54,7 @@ class MsgBase:
self._input_queue = input_queue
self._output_queue = output_queue
try:
self._logger = getLogger(self._class_name)
self._logger = LogManager.getLogger(self._class_name)
except RuntimeError:
self._logger = None
+2 -3
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -56,9 +56,8 @@ def initializeWebDriverManager(
app_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
driver_dir = os.path.join(app_dir, "drivers")
logger.info("初始化驱动目录 %s", driver_dir)
if not QDir(driver_dir).exists():
logger.error("创建驱动目录 %s 失败", driver_dir)
logger.info("初始化驱动目录 %s", driver_dir)
if not QDir().mkpath(driver_dir):
logger.error("创建驱动目录 %s 失败", driver_dir)
return False
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
+382
View File
@@ -0,0 +1,382 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
from PySide6.QtCore import Qt, Slot
from PySide6.QtGui import (
QColor,
QFont,
QSyntaxHighlighter,
QTextCharFormat,
)
from PySide6.QtWidgets import (
QApplication,
QDialog,
QDialogButtonBox,
QGridLayout,
QHBoxLayout,
QLabel,
QPlainTextEdit,
QPushButton,
QStyle,
QTabWidget,
QVBoxLayout,
QWidget,
)
from autoscript import ALL_VARIABLES
class ALScriptHighlighter(QSyntaxHighlighter):
def __init__(
self,
parent = None
):
super().__init__(parent)
self._rules = []
keywordFmt = QTextCharFormat()
keywordFmt.setForeground(QColor("#569CD6"))
keywordFmt.setFontWeight(QFont.Weight.Bold)
for kw in ["IF", "ELSE IF", "ELSE", "ENDIF", "END IF",
"SET", "PASS", "THEN"]:
pattern = r"\b" + kw.replace(" ", r"\s+") + r"\b"
self._rules.append((pattern, keywordFmt))
opFmt = QTextCharFormat()
opFmt.setForeground(QColor("#C586C0"))
opFmt.setFontWeight(QFont.Weight.Normal)
for op in [r"\.EQ\.", r"\.NEQ\.", r"\.BGT\.", r"\.BLT\.",
r"\.BGE\.", r"\.BLE\.", r"\.ADD\.", r"\.SUB\.",
r"\.AND\.", r"\.OR\."]:
self._rules.append((op, opFmt))
boolFmt = QTextCharFormat()
boolFmt.setForeground(QColor("#4FC1FF"))
boolFmt.setFontWeight(QFont.Weight.Bold)
self._rules.append((r"\.TRUE\.", boolFmt))
self._rules.append((r"\.FALSE\.", boolFmt))
funcFmt = QTextCharFormat()
funcFmt.setForeground(QColor("#DCDCAA"))
funcFmt.setFontWeight(QFont.Weight.Normal)
self._rules.append((r"\b(?:DATE|TIME)\b", funcFmt))
varFmt = QTextCharFormat()
varFmt.setForeground(QColor("#9CDCFE"))
varFmt.setFontWeight(QFont.Weight.Normal)
var_names = [name for _, (name, _) in ALL_VARIABLES.items()]
for var in var_names:
self._rules.append((r"\b" + var + r"\b", varFmt))
strFmt = QTextCharFormat()
strFmt.setForeground(QColor("#CE9178"))
strFmt.setFontWeight(QFont.Weight.Normal)
self._rules.append((r"'[^']*'", strFmt))
numFmt = QTextCharFormat()
numFmt.setForeground(QColor("#B5CEA8"))
numFmt.setFontWeight(QFont.Weight.Normal)
self._rules.append((r"\b\d+\b", numFmt))
commentFmt = QTextCharFormat()
commentFmt.setForeground(QColor("#6A9955"))
commentFmt.setFontItalic(True)
self._rules.append((r"//[^\n]*", commentFmt))
def highlightBlock(
self,
text
):
import re
for pattern, fmt in self._rules:
for match in re.finditer(pattern, text, re.IGNORECASE):
start = match.start()
length = match.end() - match.start()
self.setFormat(start, length, fmt)
class ALAutoScriptEditDialog(QDialog):
def __init__(
self,
parent = None,
script: str = ""
):
super().__init__(parent)
self._fontSize = 19
self.setupUi()
self.connectSignals()
self.textEdit.setPlainText(script)
self._highlighter = ALScriptHighlighter(
self.textEdit.document()
)
def setupUi(
self
):
self.setWindowTitle("AutoScript 编辑 - AutoLibrary")
self.setMinimumSize(640, 600)
layout = QVBoxLayout(self)
layout.setSpacing(4)
layout.setContentsMargins(4, 4, 4, 4)
toolbarLayout = QHBoxLayout()
self.zoomInBtn = QPushButton("")
self.zoomInBtn.setFixedSize(25, 25)
self.zoomOutBtn = QPushButton("")
self.zoomOutBtn.setFixedSize(25, 25)
self.zoomResetBtn = QPushButton(
QApplication.style().standardIcon(
QStyle.StandardPixmap.SP_BrowserReload
), ""
)
self.zoomResetBtn.setFixedSize(25, 25)
self.zoomResetBtn.setToolTip("重置缩放")
self.zoomLabel = QLabel(f"{self._fontSize}px")
self.zoomLabel.setFixedHeight(25)
toolbarLayout.addWidget(self.zoomInBtn)
toolbarLayout.addWidget(self.zoomOutBtn)
toolbarLayout.addWidget(self.zoomResetBtn)
toolbarLayout.addWidget(self.zoomLabel)
toolbarLayout.addStretch()
self.copyBtn = QPushButton(
QApplication.style().standardIcon(
QStyle.StandardPixmap.SP_FileDialogDetailedView
), ""
)
self.copyBtn.setFixedSize(25, 25)
self.copyBtn.setToolTip("复制脚本")
toolbarLayout.addWidget(self.copyBtn)
layout.addLayout(toolbarLayout)
self.textEdit = QPlainTextEdit(self)
self.textEdit.setLineWrapMode(
QPlainTextEdit.LineWrapMode.NoWrap
)
self.textEdit.setStyleSheet(
"QPlainTextEdit {"
" font-family: 'Courier New', 'Consolas', monospace;"
f" font-size: {self._fontSize}px;"
"}"
)
layout.addWidget(self.textEdit)
self._createButtonPanel(layout)
self.btnBox = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok |
QDialogButtonBox.StandardButton.Cancel
)
self.btnBox.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
self.btnBox.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
layout.addWidget(self.btnBox)
def _createButtonPanel(
self,
parent_layout
):
tab_widget = QTabWidget()
tab_widget.setMaximumHeight(200)
basic_widget = QWidget()
basic_layout = QGridLayout(basic_widget)
basic_layout.setSpacing(4)
basic_layout.setContentsMargins(4, 4, 4, 4)
basic_layout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
control_buttons = [
("IF", "IF()\n \nEND IF"),
("ELSE IF", "ELSE IF()\n "),
("ELSE", "ELSE"),
("END IF", "END IF"),
("PASS", "PASS"),
]
self._addButtonsToGrid(basic_layout, control_buttons, 0, 0, 5)
assign_buttons = [
("SET", "SET = "),
]
self._addButtonsToGrid(basic_layout, assign_buttons, 0, 5, 1)
func_buttons = [
("DATE()", "DATE()"),
("TIME()", "TIME()"),
]
self._addButtonsToGrid(basic_layout, func_buttons, 1, 0, 2)
tab_widget.addTab(basic_widget, "基本语法")
operator_widget = QWidget()
operator_layout = QGridLayout(operator_widget)
operator_layout.setSpacing(4)
operator_layout.setContentsMargins(4, 4, 4, 4)
operator_layout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
arithmetic_buttons = [
(".ADD.", ".ADD."),
(".SUB.", ".SUB."),
]
self._addButtonsToGrid(operator_layout, arithmetic_buttons, 0, 0, 2)
compare_buttons = [
(".EQ.", ".EQ."),
(".NEQ.", ".NEQ."),
(".BGT.", ".BGT."),
(".BLT.", ".BLT."),
(".BGE.", ".BGE."),
(".BLE.", ".BLE."),
]
self._addButtonsToGrid(operator_layout, compare_buttons, 1, 0, 6)
logic_buttons = [
(".AND.", ".AND."),
(".OR.", ".OR."),
]
self._addButtonsToGrid(operator_layout, logic_buttons, 2, 0, 2)
tab_widget.addTab(operator_widget, "运算符")
literal_widget = QWidget()
literal_layout = QGridLayout(literal_widget)
literal_layout.setSpacing(4)
literal_layout.setContentsMargins(4, 4, 4, 4)
literal_layout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
bool_buttons = [
(".TRUE.", ".TRUE."),
(".FALSE.", ".FALSE."),
]
self._addButtonsToGrid(literal_layout, bool_buttons, 0, 0, 2)
hint_buttons = [
("字符串", "'文本'"),
("数字", "123"),
("注释", "// 注释"),
]
self._addButtonsToGrid(literal_layout, hint_buttons, 1, 0, 3)
tab_widget.addTab(literal_widget, "字面量")
var_widget = QWidget()
var_layout = QGridLayout(var_widget)
var_layout.setSpacing(4)
var_layout.setContentsMargins(4, 4, 4, 4)
var_layout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
var_buttons = [
(display_name, name) for display_name, (name, _) in ALL_VARIABLES.items()
]
self._addButtonsToGrid(var_layout, var_buttons, 0, 0, 5)
tab_widget.addTab(var_widget, "变量")
parent_layout.addWidget(tab_widget)
def _addButtonsToGrid(
self,
grid_layout,
buttons,
start_row,
start_col,
max_columns
):
col = start_col
row = start_row
for btn_text, template in buttons:
btn = QPushButton(btn_text)
btn.setProperty("template", template)
btn.clicked.connect(self._insertTemplate)
btn.setFixedWidth(100)
btn.setFixedHeight(30)
btn.setToolTip(f"插入: {template}")
grid_layout.addWidget(btn, row, col)
col += 1
if col >= start_col + max_columns:
col = start_col
row += 1
@Slot()
def _insertTemplate(
self
):
btn = self.sender()
if not isinstance(btn, QPushButton):
return
template = btn.property("template")
if not template:
return
cursor = self.textEdit.textCursor()
cursor.insertText(template)
def connectSignals(
self
):
self.btnBox.accepted.connect(self.accept)
self.btnBox.rejected.connect(self.reject)
self.zoomInBtn.clicked.connect(self.onZoomIn)
self.zoomOutBtn.clicked.connect(self.onZoomOut)
self.zoomResetBtn.clicked.connect(self.onZoomReset)
self.copyBtn.clicked.connect(self.onCopy)
def getScript(
self
) -> str:
return self.textEdit.toPlainText()
def updateFontSize(
self
):
font = self.textEdit.font()
font.setPointSize(self._fontSize)
self.textEdit.setFont(font)
self.textEdit.setStyleSheet(
"QPlainTextEdit {"
" font-family: 'Courier New', 'Consolas', monospace;"
f" font-size: {self._fontSize}px;"
"}"
)
self.zoomLabel.setText(f"{self._fontSize}px")
@Slot()
def onZoomIn(
self
):
self._fontSize = min(self._fontSize + 2, 40)
self.updateFontSize()
@Slot()
def onZoomOut(
self
):
self._fontSize = max(self._fontSize - 2, 8)
self.updateFontSize()
@Slot()
def onZoomReset(
self
):
self._fontSize = 13
self.updateFontSize()
@Slot()
def onCopy(
self
):
clipboard = QApplication.clipboard()
clipboard.setText(self.textEdit.toPlainText())
original = self.copyBtn.text()
self.copyBtn.setText("已复制")
self.copyBtn.setEnabled(False)
from PySide6.QtCore import QTimer
QTimer.singleShot(2000, lambda: (
self.copyBtn.setText(original),
self.copyBtn.setEnabled(True)
))
@@ -0,0 +1,3 @@
from gui.ALAutoScriptOrchDialog._dialog import ALAutoScriptOrchDialog
__all__ = ["ALAutoScriptOrchDialog"]
+272
View File
@@ -0,0 +1,272 @@
"""
Conditional block widget for the AutoScript orchestration dialog.
"""
from PySide6.QtCore import Slot
from PySide6.QtWidgets import (
QComboBox,
QGroupBox,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from gui.ALAutoScriptOrchDialog._widgets import (
ActionStepFrame,
ConditionRowFrame,
)
class ConditionalBlock(QGroupBox):
def __init__(
self,
blockIndex: int,
varMgr = None,
parent = None
):
super().__init__(parent)
self.blockIndex = blockIndex
self._varMgr = varMgr
self._actionWidgets = []
self._conditionRows = []
self.setupUi()
self.connectSignals()
self.addInitialConditionRow()
def setupUi(
self
):
self.setUpdatesEnabled(False)
self.setStyleSheet(
"QGroupBox { font-weight: bold; border: 1px solid #ccc; "
"margin-top: 5px; padding-top: 5px; }"
)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
mainLayout = QVBoxLayout(self)
mainLayout.setSpacing(6)
mainLayout.setContentsMargins(8, 8, 8, 8)
headerLayout = QHBoxLayout()
headerLayout.setSpacing(8)
self.typeCombo = QComboBox(self)
self.typeCombo.addItem("IF", "IF")
self.typeCombo.addItem("ELSE IF", "ELSE IF")
self.typeCombo.addItem("ELSE", "ELSE")
self.typeCombo.setFixedHeight(25)
if self.blockIndex == 0:
self.typeCombo.setEnabled(False)
headerLayout.addWidget(QLabel("类型:", self))
headerLayout.addWidget(self.typeCombo)
headerLayout.addStretch()
self.deleteBlockBtn = QPushButton("删除此块", self)
self.deleteBlockBtn.setStyleSheet("color: red;")
self.deleteBlockBtn.setFixedHeight(25)
headerLayout.addWidget(self.deleteBlockBtn)
mainLayout.addLayout(headerLayout)
self.conditionWidget = QWidget(self)
self.conditionWidget.setSizePolicy(
QSizePolicy.Preferred, QSizePolicy.Preferred
)
condLayout = QVBoxLayout(self.conditionWidget)
condLayout.setContentsMargins(4, 4, 4, 4)
condLayout.setSpacing(6)
self.condRowsLayout = QVBoxLayout()
self.condRowsLayout.setSpacing(4)
condLayout.addLayout(self.condRowsLayout)
self.addCondBtn = QPushButton("+ 添加条件", self.conditionWidget)
self.addCondBtn.setFixedHeight(25)
condLayout.addWidget(self.addCondBtn)
mainLayout.addWidget(self.conditionWidget)
self.actionLabel = QLabel("执行步骤:", self)
self.actionLabel.setFixedHeight(25)
mainLayout.addWidget(self.actionLabel)
self.actionsLayout = QVBoxLayout()
self.actionsLayout.setSpacing(4)
mainLayout.addLayout(self.actionsLayout)
self.addActionBtn = QPushButton("+ 添加执行步骤", self)
self.addActionBtn.setFixedHeight(25)
mainLayout.addWidget(self.addActionBtn)
self.setUpdatesEnabled(True)
def connectSignals(
self
):
self.typeCombo.currentIndexChanged.connect(self.onTypeChanged)
self.addCondBtn.clicked.connect(self.addConditionRow)
self.addActionBtn.clicked.connect(self.addActionStep)
def addInitialConditionRow(
self
):
row = ConditionRowFrame(
self._varMgr, self.blockIndex,
isFirst=True, parent=self
)
self._conditionRows.append(row)
self.condRowsLayout.addWidget(row)
def addConditionRow(
self
):
row = ConditionRowFrame(
self._varMgr, self.blockIndex,
isFirst=False, parent=self
)
row.deleteBtn.clicked.connect(lambda: self.removeConditionRow(row))
self._conditionRows.append(row)
self.condRowsLayout.addWidget(row)
def removeConditionRow(
self,
row: ConditionRowFrame
):
if row in self._conditionRows and len(self._conditionRows) > 1:
self._conditionRows.remove(row)
self.condRowsLayout.removeWidget(row)
row.hide()
row.deleteLater()
def addActionStep(
self
):
step = ActionStepFrame(self._varMgr, self.blockIndex, parent=self)
step.deleteBtn.clicked.connect(lambda: self.removeActionStep(step))
self._actionWidgets.append(step)
self.actionsLayout.addWidget(step)
def removeActionStep(
self,
step: ActionStepFrame
):
if step in self._actionWidgets:
self._actionWidgets.remove(step)
self.actionsLayout.removeWidget(step)
step.hide()
step.deleteLater()
@Slot(int)
def onTypeChanged(
self,
_idx
):
isCond = self.typeCombo.currentData() in ("IF", "ELSE IF")
self.conditionWidget.setVisible(isCond)
self.actionLabel.setText(
"执行步骤:" if isCond else "ELSE 执行步骤:"
)
def getBlockType(
self
) -> str:
return self.typeCombo.currentData()
def getConditionRows(
self
):
return list(self._conditionRows)
def getActionSteps(
self
):
return list(self._actionWidgets)
def countActionSteps(
self
) -> int:
return len(self._actionWidgets)
def toScriptLines(
self
) -> list:
blockType = self.getBlockType()
lines = []
if blockType in ("IF", "ELSE IF"):
condTexts = [
r.toConditionText() for r in self._conditionRows if r.toConditionText()
]
if not condTexts:
condTexts = [".TRUE."]
if len(condTexts) == 1:
combined = condTexts[0]
else:
parts = []
for i, ct in enumerate(condTexts):
if i > 0:
logic = self._conditionRows[i].getLogic() or ".AND."
parts.append(f" {logic} ")
parts.append(f"({ct})")
combined = "".join(parts)
if blockType == "IF":
lines.append(f"IF({combined}) THEN")
else:
lines.append(f"ELSE IF({combined}) THEN")
else:
lines.append("ELSE")
for step in self._actionWidgets:
scriptLine = step.toScriptLine()
if scriptLine:
lines.append(scriptLine)
return lines
def refreshVarCombos(
self
):
for row in self._conditionRows:
row.refreshVarCombos()
for step in self._actionWidgets:
step.refreshVarCombos()
def setPrevBlockType(
self,
prevType: str | None
):
model = self.typeCombo.model()
if model is None:
return
for data in ("ELSE IF", "ELSE"):
idx = self.typeCombo.findData(data)
if idx < 0:
continue
item = model.item(idx)
shouldEnable = prevType != "ELSE"
item.setEnabled(shouldEnable)
if prevType == "ELSE" and self.typeCombo.currentData() in ("ELSE IF", "ELSE"):
self.typeCombo.setCurrentIndex(0)
+296
View File
@@ -0,0 +1,296 @@
"""
Orchestration dialog for visually composing AutoScript scripts.
"""
from PySide6.QtCore import Slot
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QFrame,
QMessageBox,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
from gui.ALAutoScriptOrchDialog._precheck import precheck
from gui.ALAutoScriptOrchDialog._orchestrate import parseBlocks
from gui.ALAutoScriptOrchDialog._helpers import (
COMPARE_OPERATORS,
PRESET_NAMES,
VariableManager,
findOperatorIn,
splitTopLevel,
stripOuterParens,
)
from gui.ALAutoScriptOrchDialog._blocks import ConditionalBlock
from gui.ALAutoScriptOrchDialog._widgets import ConditionRowFrame
class ALAutoScriptOrchDialog(QDialog):
def __init__(
self,
parent = None,
existingScript: str = ""
):
super().__init__(parent)
self._blocks = []
self._varMgr = VariableManager(self)
self.setupUi()
self.connectSignals()
if existingScript and existingScript.strip():
self.loadFromScript(existingScript)
else:
self.addBlock()
self.scrollLayout.addStretch()
def setupUi(
self
):
self.setWindowTitle("AutoScript 指令编排 - AutoLibrary")
self.setMinimumSize(640, 600)
self.setModal(True)
mainLayout = QVBoxLayout(self)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.NoFrame)
scrollContent = QWidget()
self.scrollLayout = QVBoxLayout(scrollContent)
self.scrollLayout.setSpacing(5)
scroll.setWidget(scrollContent)
mainLayout.addWidget(scroll)
self.addBlockBtn = QPushButton("+ 添加判断块")
self.addBlockBtn.setFixedHeight(25)
mainLayout.addWidget(self.addBlockBtn)
self.btnBox = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok |
QDialogButtonBox.StandardButton.Cancel
)
self.btnBox.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
self.btnBox.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
mainLayout.addWidget(self.btnBox)
def connectSignals(
self
):
self.btnBox.accepted.connect(self.onAccept)
self.btnBox.rejected.connect(self.reject)
self.addBlockBtn.clicked.connect(self.addBlock)
def _updateBlockTypeRestrictions(
self
):
prevType = None
for block in self._blocks:
block.setPrevBlockType(prevType)
prevType = block.getBlockType()
def addBlock(
self
):
block = ConditionalBlock(
len(self._blocks), self._varMgr, parent=self
)
block.deleteBlockBtn.clicked.connect(lambda: self.removeBlock(block))
block.typeCombo.currentIndexChanged.connect(self._updateBlockTypeRestrictions)
block.addActionStep()
self._blocks.append(block)
self._updateBlockTypeRestrictions()
if self.scrollLayout.count() > 0:
lastItem = self.scrollLayout.itemAt(
self.scrollLayout.count() - 1
)
if lastItem and lastItem.spacerItem():
self.scrollLayout.insertWidget(
self.scrollLayout.count() - 1, block
)
return
self.scrollLayout.addWidget(block)
def removeBlock(
self,
block: ConditionalBlock
):
if len(self._blocks) <= 1:
QMessageBox.information(self, "提示", "至少保留一个判断块。")
return
if block in self._blocks:
self._blocks.remove(block)
self.scrollLayout.removeWidget(block)
block.hide()
block.deleteLater()
for i, blk in enumerate(self._blocks):
blk.blockIndex = i
if i == 0:
blk.typeCombo.setEnabled(False)
blk.typeCombo.setCurrentIndex(0)
else:
blk.typeCombo.setEnabled(True)
blk.refreshVarCombos()
self._updateBlockTypeRestrictions()
def getScript(
self
) -> str:
parts = []
prevType = None
for block in self._blocks:
blockType = block.getBlockType()
if blockType == "IF" and prevType is not None:
parts.append("END IF")
lines = block.toScriptLines()
parts.extend(lines)
prevType = blockType
if self._blocks and self._blocks[0].getBlockType() == "IF":
parts.append("END IF")
return "\n".join(parts)
@Slot()
def onAccept(
self
):
script = self.getScript().strip()
if not script:
QMessageBox.warning(self, "提示", "脚本内容为空,请添加至少一个操作步骤。")
return
self.accept()
@staticmethod
def precheckScriptForOrchestration(
script: str
) -> tuple[bool, str]:
return precheck(script, allowed_vars=PRESET_NAMES)
def loadFromScript(
self,
script: str
):
if not script.strip():
self.addBlock()
return
ok, err = self.precheckScriptForOrchestration(script)
if not ok:
QMessageBox.warning(
self, "无法编排",
f"脚本检查失败:\n{err}\n\n"
"请通过\"编辑\"按钮打开脚本编辑窗口进行修改。"
)
self.addBlock()
return
# Structured block data via observer-based parsing — no duplicate logic
typeIdxMap = {"IF": 0, "ELSE IF": 1, "ELSE": 2}
parsedBlocks = parseBlocks(script)
self._blocks.clear()
while self.scrollLayout.count():
item = self.scrollLayout.takeAt(0)
if item.widget():
item.widget().deleteLater()
try:
for blockType, condition, actions in parsedBlocks:
self.addBlock()
block = self._blocks[-1]
idx = typeIdxMap.get(blockType, 0)
block.typeCombo.setCurrentIndex(idx)
block.onTypeChanged(idx)
for oldStep in list(block._actionWidgets):
block.removeActionStep(oldStep)
for target, valueExpr, opType in actions:
block.addActionStep()
step = block.getActionSteps()[-1]
step.setOpType(opType)
step.loadFromScript(target, valueExpr)
if blockType in ("IF", "ELSE IF") and condition:
self._parseConditions(block, condition)
except Exception:
self._blocks.clear()
while self.scrollLayout.count():
item = self.scrollLayout.takeAt(0)
if item.widget():
item.widget().deleteLater()
self._updateBlockTypeRestrictions()
if not self._blocks:
self.addBlock()
def _parseConditions(
self,
block: ConditionalBlock,
condStr: str
):
s = condStr.strip()
if not s:
return
s = stripOuterParens(s)
orParts = splitTopLevel(s, ".OR.")
allSubConds = []
allLogics = []
for pi, part in enumerate(orParts):
part = part.strip()
if pi > 0:
allLogics.append(".OR.")
andParts = splitTopLevel(part, ".AND.")
for ai, ap in enumerate(andParts):
ap = ap.strip()
if ai > 0:
allLogics.append(".AND.")
allSubConds.append(ap)
for row in list(block._conditionRows):
block.condRowsLayout.removeWidget(row)
row.hide()
row.deleteLater()
block._conditionRows.clear()
for i, subCond in enumerate(allSubConds):
subCond = subCond.strip()
subCond = stripOuterParens(subCond)
isFirst = (i == 0)
row = ConditionRowFrame(
self._varMgr, block.blockIndex,
isFirst=isFirst, parent=block
)
if not isFirst:
row.deleteBtn.clicked.connect(
lambda _checked=False, r=row: block.removeConditionRow(r)
)
if i - 1 < len(allLogics):
logic = allLogics[i - 1]
for li in range(row.logicCombo.count()):
if row.logicCombo.itemData(li) == logic:
row.logicCombo.setCurrentIndex(li)
break
block._conditionRows.append(row)
block.condRowsLayout.addWidget(row)
subUp = subCond.upper()
if subUp in (".TRUE.", ".FALSE."):
row.loadFromParts(subUp, "", "")
else:
opSyms = [op for _, op in COMPARE_OPERATORS]
result = findOperatorIn(subCond, opSyms)
if result:
idx, op = result
leftPart = subCond[:idx].strip()
rightPart = subCond[idx + len(op):].strip()
row.loadFromParts(leftPart, op, rightPart)
else:
row.loadFromParts(subCond, "", "")
if not block._conditionRows:
block.addInitialConditionRow()
+662
View File
@@ -0,0 +1,662 @@
"""
Helper utilities and constants for the AutoScript orchestration dialog.
"""
import re
from PySide6.QtCore import (
QObject,
QDate,
QTime
)
from PySide6.QtWidgets import (
QComboBox,
QDateEdit,
QDoubleSpinBox,
QHBoxLayout,
QLabel,
QLineEdit,
QSizePolicy,
QSpinBox,
QStackedWidget,
QTimeEdit,
QWidget,
)
from autoscript import (
ALL_VARIABLES,
splitTopLevel
)
from autoscript.ASOperator import (
ARITH_TYPES,
COMPARISON_OPERATORS
)
VAR_TYPE_ORDER = [
"String",
"Int",
"Float",
"Boolean",
"Date",
"Time"
]
PRESET_VARIABLES = [
{
"name": name.upper(),
"type": vtype,
"display": display
}
for display, (name, vtype) in ALL_VARIABLES.items()
]
PRESET_NAMES = {
p["name"] for p in PRESET_VARIABLES
}
# Operator display names (UI-specific), symbols derived from engine
_COMPARE_DISPLAY_MAP = {
".EQ.": "等于",
".NEQ.": "不等于",
".BGT.": "大于",
".BLT.": "小于",
".BGE.": "大于等于",
".BLE.": "小于等于",
}
COMPARE_OPERATORS = sorted(
[(name, op) for op, name in _COMPARE_DISPLAY_MAP.items() if op in COMPARISON_OPERATORS],
key=lambda x: len(x[1]), reverse=True
)
LOGIC_OPERATORS = [
("并且 (.AND.)", ".AND."),
("或者 (.OR.)", ".OR."),
]
ACTION_TYPES = [
("设置为", "set"),
("增加", "add"),
("减少", "sub"),
]
DATE_RELATIVE_OPTIONS = [
("前天", "day_before_yesterday"),
("昨天", "yesterday"),
("今天", "today"),
("明天", "tomorrow"),
("后天", "day_after_tomorrow")
]
DATE_OFFSET_UNITS = [
("", "days"),
("", "weeks"),
("", "months"),
("", "years"),
]
class VariableManager(QObject):
def __init__(
self,
parent = None
):
super().__init__(parent)
self._vars = []
self._nameMap = {}
self._initPresetVars()
def _initPresetVars(
self
):
for p in PRESET_VARIABLES:
entry = {"name": p["name"], "type": p["type"], "display": p["display"]}
self._vars.append(entry)
self._nameMap[p["name"]] = entry
def getInfoByName(
self,
name: str
):
return self._nameMap.get(name.upper().strip())
def populateCombo(
self,
combo: QComboBox
):
currentData = combo.currentData()
combo.blockSignals(True)
combo.clear()
for entry in self._vars:
combo.addItem(
entry["display"],
(entry["name"], entry["type"])
)
if currentData:
for i in range(combo.count()):
d = combo.itemData(i)
if d and d[0] == currentData[0]:
combo.setCurrentIndex(i)
break
combo.blockSignals(False)
def findExactNameEntry(
self,
combo: QComboBox,
name: str
) -> int:
name = name.upper().strip()
for i in range(combo.count()):
d = combo.itemData(i)
if d and len(d) >= 1 and d[0].upper().strip() == name:
return i
return -1
def makeValueWidget(
var_type: str,
parent: QWidget = None
) -> QWidget:
if var_type == "Int":
w = QSpinBox(parent)
w.setRange(-999999, 999999)
w.setFixedHeight(25)
w.setMinimumWidth(100)
return w
if var_type == "Float":
w = QDoubleSpinBox(parent)
w.setRange(-999999.0, 999999.0)
w.setDecimals(2)
w.setFixedHeight(25)
w.setMinimumWidth(100)
return w
if var_type == "String":
w = QLineEdit(parent)
w.setPlaceholderText("输入值")
w.setFixedHeight(25)
w.setMinimumWidth(120)
return w
if var_type == "Boolean":
w = QComboBox(parent)
w.addItem("是 (.TRUE.)", ".TRUE.")
w.addItem("否 (.FALSE.)", ".FALSE.")
w.setFixedHeight(25)
w.setMinimumWidth(100)
return w
if var_type == "Date":
return _DateInputContainer(parent)
if var_type == "Time":
return _TimeInputContainer(parent)
w = QLineEdit(parent)
w.setPlaceholderText("输入值")
w.setFixedHeight(25)
w.setMinimumWidth(120)
return w
def makeOffsetWidget(
var_type: str,
parent: QWidget = None
) -> QWidget:
if var_type == "Int":
w = QSpinBox(parent)
w.setRange(-999999, 999999)
w.setFixedHeight(25)
w.setMinimumWidth(100)
return w
if var_type == "Float":
w = QDoubleSpinBox(parent)
w.setRange(-999999.0, 999999.0)
w.setDecimals(2)
w.setFixedHeight(25)
w.setMinimumWidth(100)
return w
if var_type == "Date":
return _DateOffsetContainer(parent)
if var_type == "Time":
return _TimeOffsetContainer(parent)
w = QLabel("(不支持该操作)", parent)
w.setFixedHeight(25)
return w
def makeVarRefCombo(
parent: QWidget = None
) -> QComboBox:
cb = QComboBox(parent)
cb.setFixedHeight(25)
cb.setMinimumWidth(120)
cb.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
return cb
def makeComboWidget(
items,
min_width: int = 80,
parent: QWidget = None
) -> QComboBox:
cb = QComboBox(parent)
for display, data in items:
cb.addItem(display, data)
cb.setFixedHeight(25)
cb.setMinimumWidth(min_width)
return cb
def makeLabel(
text: str,
parent: QWidget = None,
width: int = None
) -> QLabel:
lbl = QLabel(text, parent)
lbl.setFixedHeight(25)
if width:
lbl.setFixedWidth(width)
return lbl
class _DateInputContainer(QWidget):
def __init__(
self,
parent = None
):
super().__init__(parent)
self.setupUi()
def setupUi(
self
):
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(4)
self._modeCombo = QComboBox(self)
self._modeCombo.addItem("相对日期", "relative")
self._modeCombo.addItem("绝对日期", "absolute")
self._modeCombo.setFixedHeight(25)
self._stack = QStackedWidget(self)
self._relCombo = QComboBox(self)
for display, data in DATE_RELATIVE_OPTIONS:
self._relCombo.addItem(display, data)
self._relCombo.setFixedHeight(25)
self._stack.addWidget(self._relCombo)
self._dateEdit = QDateEdit(self)
self._dateEdit.setDisplayFormat("yyyy-MM-dd")
self._dateEdit.setCalendarPopup(True)
self._dateEdit.setFixedHeight(25)
self._stack.addWidget(self._dateEdit)
self._modeCombo.currentIndexChanged.connect(
lambda i: self._stack.setCurrentIndex(i)
)
layout.addWidget(self._modeCombo)
layout.addWidget(self._stack)
layout.addStretch()
def getValue(
self
) -> str:
mode = self._modeCombo.currentData()
if mode == "relative":
return self._relCombo.currentText()
return self._dateEdit.date().toString("yyyy-MM-dd")
def setValue(
self,
expr: str
):
s = expr.strip().upper()
_RELATIVE_MAP = {
"CURRENT_DATE": 0, "TODAY": 0,
"CURRENT_DATE + 1": 1, "TOMORROW": 1,
"CURRENT_DATE + 2": 2,
"CURRENT_DATE - 1": 3,
"CURRENT_DATE - 2": 4,
}
idx = _RELATIVE_MAP.get(s)
if idx is not None:
self._modeCombo.setCurrentIndex(0)
self._relCombo.setCurrentIndex(idx)
elif s.startswith("DATE("):
self._modeCombo.setCurrentIndex(1)
m = re.match(r"DATE\((\d{4}-\d{2}-\d{2})\)", s)
if m:
parts = m.group(1).split("-")
self._dateEdit.setDate(QDate(int(parts[0]), int(parts[1]), int(parts[2])))
class _TimeInputContainer(QWidget):
def __init__(
self,
parent = None
):
super().__init__(parent)
self._timeEdit = QTimeEdit(self)
self._timeEdit.setDisplayFormat("HH:mm")
self._timeEdit.setFixedHeight(25)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self._timeEdit)
def getValue(
self
) -> str:
return self._timeEdit.time().toString("HH:mm")
def setValue(
self,
expr: str
):
s = expr.strip().upper()
m = re.match(r"TIME\((\d{1,2}:\d{2})\)", s)
if m:
parts = m.group(1).split(":")
self._timeEdit.setTime(QTime(int(parts[0]), int(parts[1])))
class _DateOffsetContainer(QWidget):
def __init__(
self,
parent = None
):
super().__init__(parent)
self._spinBox = QSpinBox(self)
self._spinBox.setRange(0, 99999)
self._spinBox.setFixedHeight(25)
self._unitCombo = QComboBox(self)
for display, data in DATE_OFFSET_UNITS:
self._unitCombo.addItem(display, data)
self._unitCombo.setFixedHeight(25)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(4)
layout.addWidget(self._spinBox)
layout.addWidget(self._unitCombo)
layout.addStretch()
def getValue(
self
) -> str:
return str(self.getOffsetDays())
def setValue(
self,
expr: str
):
s = expr.strip().lstrip("+")
try:
self._spinBox.setValue(int(s))
except ValueError:
pass
def getOffsetDays(
self
) -> int:
val = self._spinBox.value()
unit = self._unitCombo.currentData()
if unit == "weeks":
return val * 7
if unit == "months":
return val * 30
if unit == "years":
return val * 365
return val
def getRawValue(
self
) -> str:
return str(self._spinBox.value())
class _TimeOffsetContainer(QWidget):
def __init__(
self,
parent = None
):
super().__init__(parent)
self._spinBox = QSpinBox(self)
self._spinBox.setRange(0, 99999)
self._spinBox.setSuffix(" 小时")
self._spinBox.setFixedHeight(25)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self._spinBox)
def getValue(
self
) -> str:
return str(self.getOffsetHours())
def setValue(
self,
expr: str
):
s = expr.strip().lstrip("+")
try:
self._spinBox.setValue(int(s))
except ValueError:
pass
def getOffsetHours(
self
) -> int:
return self._spinBox.value()
def getRawValue(
self
) -> str:
return str(self._spinBox.value())
def getValueFromWidget(
w: QWidget
) -> str:
if hasattr(w, "getValue"):
return w.getValue()
if isinstance(w, QTimeEdit):
return w.time().toString("HH:mm")
if isinstance(w, QDateEdit):
return w.date().toString("yyyy-MM-dd")
if isinstance(w, QComboBox):
return w.currentData() or w.currentText()
if isinstance(w, QSpinBox):
return str(w.value())
if isinstance(w, QDoubleSpinBox):
return str(w.value())
if isinstance(w, QLineEdit):
return w.text()
return ""
def setWidgetValue(
w: QWidget,
var_type: str,
expr: str
):
if hasattr(w, "setValue"):
w.setValue(expr)
return
s = expr.strip().upper()
if isinstance(w, QTimeEdit):
m = re.match(r"TIME\((\d{1,2}:\d{2})\)", s)
if m:
parts = m.group(1).split(":")
w.setTime(QTime(int(parts[0]), int(parts[1])))
elif isinstance(w, QDateEdit):
m = re.match(r"DATE\((\d{4}-\d{2}-\d{2})\)", s)
if m:
parts = m.group(1).split("-")
w.setDate(QDate(int(parts[0]), int(parts[1]), int(parts[2])))
elif isinstance(w, QComboBox):
for i in range(w.count()):
if w.itemData(i) == s or w.itemText(i).upper() == s:
w.setCurrentIndex(i)
return
elif isinstance(w, QSpinBox):
try:
w.setValue(int(expr))
except ValueError:
pass
elif isinstance(w, QDoubleSpinBox):
try:
w.setValue(float(expr))
except ValueError:
pass
elif isinstance(w, QLineEdit):
inner = expr.strip()
if (inner.startswith("'") and inner.endswith("'")) or \
(inner.startswith('"') and inner.endswith('"')):
inner = inner[1:-1].replace("''", "'")
w.setText(inner)
def encodeValueStr(
raw_value: str,
var_type: str
) -> str:
if var_type == "Time":
if raw_value.startswith("+") or raw_value.startswith("-"):
return raw_value
if raw_value.startswith("TIME_OFFSET"):
m = re.match(r"TIME_OFFSET\(([+-]\d+),(\w+)\)", raw_value)
if m:
return m.group(1)
return raw_value
return f"TIME({raw_value})"
if var_type == "Date":
relMap = {
"前天": "CURRENT_DATE - 2",
"昨天": "CURRENT_DATE - 1",
"今天": "CURRENT_DATE",
"明天": "CURRENT_DATE + 1",
"后天": "CURRENT_DATE + 2"
}
if raw_value in relMap:
return relMap[raw_value]
return f"DATE({raw_value})"
if var_type == "Boolean":
up = raw_value.upper().strip()
if up in (".TRUE.", ".FALSE."):
return up
return ".TRUE." if raw_value else ".FALSE."
if var_type == "String":
escaped = raw_value.replace("'", "''")
return f"'{escaped}'"
return raw_value
def stripOuterParens(
s: str
) -> str:
s = s.strip()
if s.startswith("(") and s.endswith(")"):
depth = 0
for i, ch in enumerate(s):
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0 and i < len(s) - 1:
return s
return s[1:-1].strip()
return s
def isVarReference(
expr: str
) -> bool:
s = expr.strip()
up = s.upper()
if up in (".TRUE.", ".FALSE."):
return False
if re.match(r"^TIME\(|^DATE\(|^CURRENT_", up):
return False
if up.startswith("'") or up.startswith('"'):
return False
if re.match(r"^[+-]?\d", s):
return False
return bool(re.match(r"^[A-Z_][A-Z0-9_]*$", up))
def findOperatorIn(
text: str,
operators: list
) -> tuple[int, str] | None:
for op in operators:
op_upper = op.upper()
start = 0
while True:
idx = text.upper().find(op_upper, start)
if idx < 0:
break
if _isInsideLiteral(text, idx):
start = idx + 1
continue
return (idx, op)
return None
def _isInsideLiteral(
text: str,
pos: int
) -> bool:
in_single = False
in_double = False
for i, ch in enumerate(text):
if i >= pos:
break
if ch == "'" and not in_double:
in_single = not in_single
elif ch == '"' and not in_single:
in_double = not in_double
return in_single or in_double
@@ -0,0 +1,107 @@
"""
Orchestration observer for AutoScript scripts.
Subscribes to ASTokenizer parsing events to produce a structured
block representation for the orchestration dialog UI.
"""
from autoscript.ASObserver import ParsingObserver
from autoscript.ASTokenizer import (
ASTokenizer,
K_IF,
K_ELSE_IF,
K_ELSE,
K_ENDIF,
K_SET,
K_ADD,
K_SUB,
)
__all__ = ["ScriptOrchObserver", "parseBlocks"]
class ScriptOrchObserver(ParsingObserver):
"""
Builds an ordered list of (block_type, condition, actions) tuples
from tokenization events.
Each block:
(type: str, condition: str | None, actions: list[(target, value_expr, op_type)])
"""
def __init__(
self
):
super().__init__()
self._blocks = []
self._current_type = None
self._current_condition = None
self._current_actions = []
def onTokenParsed(
self,
kind: str | None,
data,
line_num: int,
raw_line: str
):
if kind in (K_IF, K_ELSE_IF, K_ELSE):
self._flushCurrentBlock()
self._current_type = kind
self._current_condition = data if kind != K_ELSE else None
self._current_actions = []
elif kind in (K_SET, K_ADD, K_SUB):
target, value = data
if kind == K_SET:
self._current_actions.append((target, value, "set"))
elif kind == K_ADD:
self._current_actions.append((target, f"+{value}", "add"))
else:
self._current_actions.append((target, f"-{value}", "sub"))
elif kind == K_ENDIF:
self._flushCurrentBlock()
self._current_type = None
self._current_condition = None
self._current_actions = []
def onParseComplete(
self,
statements: list
):
self._flushCurrentBlock()
def _flushCurrentBlock(
self
):
if self._current_type is not None:
self._blocks.append((
self._current_type,
self._current_condition,
list(self._current_actions),
))
@property
def blocks(
self
) -> list:
return list(self._blocks)
def parseBlocks(
script: str
) -> list:
"""
Tokenize a script via observer pipeline and return its
structured block representation.
"""
observer = ScriptOrchObserver()
ASTokenizer.tokenizeWithObservers(script, [observer])
return observer.blocks
+163
View File
@@ -0,0 +1,163 @@
"""
Pre-check observer for AutoScript scripts.
Subscribes to ASTokenizer parsing events to validate script syntax
before it reaches the orchestration dialog, eliminating duplicate parsing.
"""
from autoscript.ASObserver import ParsingObserver
from autoscript.ASTokenizer import (
K_IF,
K_ELSE_IF,
K_ELSE,
K_ENDIF,
K_SET,
K_ADD,
K_SUB,
ASTokenizer,
)
__all__ = ["ScriptPrecheckObserver", "precheck"]
class ScriptPrecheckObserver(ParsingObserver):
"""
Validates script syntax and structure during tokenization.
Checks performed:
- IF/ENDIF depth matching
- No nested IF blocks (orchestration limitation)
- ELSE IF / ELSE appear only inside an IF block
- Only allowed variables appear in SET/ADD/SUB targets
- No completely unrecognized syntax lines
"""
def __init__(
self,
allowed_vars: set = None
):
super().__init__()
self._allowed = allowed_vars or set()
self._if_depth = 0
self.errors = []
self._stmts = []
def onTokenParsed(
self,
kind: str | None,
data,
line_num: int,
raw_line: str
):
if kind == K_IF:
self._if_depth += 1
if self._if_depth > 1:
self.errors.append(
f"静态检查:错误(第{line_num}行): 检测到嵌套 IF,编排窗口不支持嵌套条件块。"
)
elif kind == K_ELSE_IF:
if self._if_depth < 1:
self.errors.append(
f"静态检查:错误(第{line_num}行): ELSE IF 前缺少 IF。"
)
elif kind == K_ELSE:
if self._if_depth < 1:
self.errors.append(
f"静态检查:错误(第{line_num}行): ELSE 前缺少 IF。"
)
elif kind == K_ENDIF:
self._if_depth -= 1
if self._if_depth < 0:
self.errors.append(
f"静态检查:错误(第{line_num}行): 多余的 ENDIF。"
)
elif kind is None:
self.errors.append(
f"静态检查:错误(第{line_num}行): 无法识别的语法 '{raw_line}'"
)
elif kind in (K_SET, K_ADD, K_SUB):
target = data[0] if isinstance(data, tuple) else ""
if self._allowed and target.upper() not in self._allowed:
self.errors.append(
f"静态检查:错误(第{line_num}行): 目标变量 '{target}' 不是预设变量,"
f"编排窗口不支持。"
)
def onParseComplete(
self,
statements: list
):
if self._if_depth != 0:
self.errors.append(
f"静态检查:错误(不适用): IF 与 ENDIF 不匹配。")
self._stmts = statements
@property
def valid(
self
) -> bool:
return len(self.errors) == 0
def getErrorMessage(
self
) -> str:
return self.errors[0] if self.errors else ""
def buildSimplifiedScript(
self
) -> str:
"""Replace all non-control-flow statements with PASS for engine validation."""
lines = []
for stmt in self._stmts:
if stmt.kind in (K_IF, K_ELSE_IF, K_ELSE, K_ENDIF):
lines.append(stmt.raw_line)
else:
lines.append("PASS")
return "\n".join(lines)
def precheck(
script: str,
allowed_vars: set = None
) -> tuple[bool, str]:
"""
Run the full precheck pipeline on a script.
Steps:
1. Create a ScriptPrecheckObserver and subscribe it to an ASTokenizer.
2. Tokenize — the observer validates syntax during token events.
3. Replace action lines with PASS and run engine validation
with mock target data.
"""
if not script or not script.strip():
return True, ""
observer = ScriptPrecheckObserver(allowed_vars=allowed_vars)
ASTokenizer.tokenizeWithObservers(script, [observer])
if not observer.valid:
return False, observer.getErrorMessage()
simplified = observer.buildSimplifiedScript()
if not simplified.strip():
return True, ""
try:
from autoscript import (
registerDefaultTargetVars,
buildMockTargetData,
execute
)
registerDefaultTargetVars()
execute(simplified, buildMockTargetData())
except ValueError as e:
return False, f"运行时检查: {e}"
except Exception:
return False, "执行环境异常,请检查 AutoScript 配置。"
return True, ""
+532
View File
@@ -0,0 +1,532 @@
"""
Widget components for the AutoScript orchestration dialog.
"""
from PySide6.QtCore import Slot
from PySide6.QtWidgets import (
QComboBox,
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QStackedWidget
)
from gui.ALAutoScriptOrchDialog._helpers import (
ACTION_TYPES,
ARITH_TYPES,
COMPARE_OPERATORS,
LOGIC_OPERATORS,
PRESET_VARIABLES,
VAR_TYPE_ORDER,
encodeValueStr,
getValueFromWidget,
isVarReference,
makeComboWidget,
makeLabel,
makeOffsetWidget,
makeValueWidget,
makeVarRefCombo,
setWidgetValue,
)
class ConditionRowFrame(QFrame):
def __init__(
self,
varMgr,
parentBlockIndex: int = 0,
isFirst: bool = False,
parent = None
):
super().__init__(parent)
self._varMgr = varMgr
self._blockIndex = parentBlockIndex
self._isFirst = isFirst
self._isBoolMode = False
self.setupUi()
self.connectSignals()
def setupUi(
self
):
self.setUpdatesEnabled(False)
self.setFrameShape(QFrame.StyledPanel)
self.setFrameShadow(QFrame.Raised)
self.setFixedHeight(32)
layout = QHBoxLayout(self)
layout.setContentsMargins(2, 2, 2, 2)
layout.setSpacing(4)
if self._isFirst:
self.logicCombo = None
else:
self.logicCombo = makeComboWidget(LOGIC_OPERATORS, min_width=110, parent=self)
layout.addWidget(self.logicCombo)
self.leftVarCombo = QComboBox(self)
self.leftVarCombo.setFixedHeight(25)
self.leftVarCombo.setMinimumWidth(120)
self.leftVarCombo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.populateLeftVarCombo()
layout.addWidget(self.leftVarCombo)
self.opCombo = makeComboWidget(COMPARE_OPERATORS, min_width=80, parent=self)
layout.addWidget(self.opCombo)
self._compTypeCombo = makeComboWidget([
("特定值", "literal"),
("变量", "variable"),
], min_width=70, parent=self)
layout.addWidget(self._compTypeCombo)
self.rhsStack = QStackedWidget(self)
self.rhsStack.setFixedHeight(25)
self.literalStack = QStackedWidget(self)
self.literalStack.setFixedHeight(25)
self.literalWidgets = {}
for vt in VAR_TYPE_ORDER:
w = makeValueWidget(vt, self.literalStack)
self.literalWidgets[vt] = w
self.literalStack.addWidget(w)
self.literalStack.setCurrentWidget(self.literalWidgets.get("String"))
self.rhsStack.addWidget(self.literalStack)
self.rhsVarCombo = makeVarRefCombo(self)
self.rhsStack.addWidget(self.rhsVarCombo)
self.rhsStack.setCurrentIndex(0)
layout.addWidget(self.rhsStack)
if not self._isFirst:
self.deleteBtn = QPushButton("×", self)
self.deleteBtn.setFixedSize(25, 25)
self.deleteBtn.setStyleSheet("color: red; font-weight: bold;")
layout.addWidget(self.deleteBtn)
else:
self.deleteBtn = None
layout.addStretch()
self.setUpdatesEnabled(True)
def populateLeftVarCombo(
self
):
self._varMgr.populateCombo(self.leftVarCombo)
def populateRhsVarCombo(
self
):
self._varMgr.populateCombo(self.rhsVarCombo)
def connectSignals(
self
):
self.leftVarCombo.currentIndexChanged.connect(self.onLeftVarChanged)
self._compTypeCombo.currentIndexChanged.connect(self.onCompTypeChanged)
@Slot(int)
def onLeftVarChanged(
self,
idx
):
if idx < 0:
return
data = self.leftVarCombo.itemData(idx)
if not data:
return
_, vartype = data
self.updateRhsLiteralWidget(vartype)
def updateRhsLiteralWidget(
self,
vartype: str
):
if vartype not in self.literalWidgets:
vartype = "String"
self.literalStack.setCurrentWidget(self.literalWidgets[vartype])
@Slot(int)
def onCompTypeChanged(
self,
idx
):
isVar = (self._compTypeCombo.currentData() == "variable")
self.rhsStack.setCurrentIndex(1 if isVar else 0)
if isVar:
self.populateRhsVarCombo()
def getLogic(
self
) -> str:
return self.logicCombo.currentData() if self.logicCombo else ""
def toConditionText(
self
) -> str:
data = self.leftVarCombo.currentData()
if not data:
return ""
name, vartype = data
opSym = self.opCombo.currentData()
isVarRef = (self._compTypeCombo.currentData() == "variable")
if isVarRef:
rd = self.rhsVarCombo.currentData()
if rd:
rhsName = rd[0]
return f"{name} {opSym} {rhsName}"
rhsText = self.rhsVarCombo.currentText().strip()
if rhsText:
return f"{name} {opSym} {rhsText}"
return ""
w = self.literalWidgets.get(vartype)
if w:
rawVal = getValueFromWidget(w)
encoded = encodeValueStr(rawVal, vartype)
return f"{name} {opSym} {encoded}"
return ""
def loadFromParts(
self,
operandName: str,
opSym: str,
valueExpr: str
):
for ci in range(self.leftVarCombo.count()):
d = self.leftVarCombo.itemData(ci)
if d and d[0] == operandName:
self.leftVarCombo.setCurrentIndex(ci)
break
if opSym:
for oi in range(self.opCombo.count()):
if self.opCombo.itemData(oi) == opSym:
self.opCombo.setCurrentIndex(oi)
break
if not valueExpr:
return
up = valueExpr.strip().upper()
data = self.leftVarCombo.currentData()
vartype = data[1] if data else "String"
if isVarReference(valueExpr) or self._isKnownVar(up):
self._compTypeCombo.setCurrentIndex(1)
self.populateRhsVarCombo()
found = self._varMgr.findExactNameEntry(self.rhsVarCombo, up)
if found >= 0:
self.rhsVarCombo.setCurrentIndex(found)
else:
self.rhsVarCombo.addItem(up, (up, "String"))
self.rhsVarCombo.setCurrentIndex(self.rhsVarCombo.count() - 1)
else:
self._compTypeCombo.setCurrentIndex(0)
w = self.literalWidgets.get(vartype)
if w:
setWidgetValue(w, vartype, valueExpr)
def _isKnownVar(
self,
name: str
) -> bool:
return self._varMgr.getInfoByName(name) is not None
def refreshVarCombos(
self
):
self.populateLeftVarCombo()
self.populateRhsVarCombo()
class ActionStepFrame(QFrame):
def __init__(
self,
varMgr,
parentBlockIndex: int = 0,
parent = None
):
super().__init__(parent)
self._varMgr = varMgr
self._blockIndex = parentBlockIndex
self._currentTargetType = "String"
self.setupUi()
self.connectSignals()
def setupUi(
self
):
self.setUpdatesEnabled(False)
self.setFrameShape(QFrame.StyledPanel)
self.setFrameShadow(QFrame.Raised)
self.setFixedHeight(35)
layout = QHBoxLayout(self)
layout.setContentsMargins(2, 2, 2, 2)
layout.setSpacing(4)
self.opTypeCombo = makeComboWidget(ACTION_TYPES, min_width=70, parent=self)
layout.addWidget(self.opTypeCombo)
layout.addWidget(makeLabel("设置", self))
self.targetCombo = QComboBox(self)
self.targetCombo.setFixedHeight(25)
self.targetCombo.setMinimumWidth(120)
self.buildTargetCombo()
layout.addWidget(self.targetCombo)
layout.addWidget(makeLabel("", self))
self.valueSrcCombo = makeComboWidget([
("特定值", "literal"),
("变量", "variable"),
], min_width=70, parent=self)
layout.addWidget(self.valueSrcCombo)
self.valueStack = QStackedWidget(self)
self.valueStack.setFixedHeight(25)
self.initValueStacks()
layout.addWidget(self.valueStack)
self.existingVarCombo = makeVarRefCombo(self)
self.existingVarCombo.setVisible(False)
layout.addWidget(self.existingVarCombo)
self.deleteBtn = QPushButton("×", self)
self.deleteBtn.setFixedSize(25, 25)
self.deleteBtn.setStyleSheet("color: red; font-weight: bold;")
layout.addWidget(self.deleteBtn)
self.setUpdatesEnabled(True)
def buildTargetCombo(
self
):
self.targetCombo.blockSignals(True)
self.targetCombo.clear()
for p in PRESET_VARIABLES:
if p["name"] in ("CURRENT_TIME", "CURRENT_DATE"):
continue
info = self._varMgr.getInfoByName(p["name"])
if info:
self.targetCombo.addItem(
info["display"],
(info["name"], info["type"])
)
self.targetCombo.blockSignals(False)
def initValueStacks(
self
):
self._literalWidgets = {}
self._offsetWidgets = {}
for vt in VAR_TYPE_ORDER:
self._literalWidgets[vt] = makeValueWidget(vt, self.valueStack)
self.valueStack.addWidget(self._literalWidgets[vt])
if vt in ARITH_TYPES:
self._offsetWidgets[vt] = makeOffsetWidget(vt, self.valueStack)
self.valueStack.addWidget(self._offsetWidgets[vt])
else:
lbl = QLabel("(不支持该操作)", self.valueStack)
lbl.setFixedHeight(25)
self._offsetWidgets[vt] = lbl
self.valueStack.addWidget(lbl)
def connectSignals(
self
):
self.opTypeCombo.currentIndexChanged.connect(self.onOpTypeChanged)
self.targetCombo.currentIndexChanged.connect(self.onTargetChanged)
self.valueSrcCombo.currentIndexChanged.connect(self.onValueSrcChanged)
@Slot(int)
def onTargetChanged(
self,
idx
):
if idx < 0:
return
data = self.targetCombo.itemData(idx)
if not data:
return
_, vartype = data
self._currentTargetType = vartype
self.updateRHSWidget()
self.onValueSrcChanged(self.valueSrcCombo.currentIndex())
@Slot(int)
def onOpTypeChanged(
self,
idx
):
self.updateRHSWidget()
def updateRHSWidget(
self
):
op = self.opTypeCombo.currentData()
isArith = (op in ("add", "sub"))
actualType = self._currentTargetType
if isArith and actualType in self._offsetWidgets:
self.valueStack.setCurrentWidget(self._offsetWidgets[actualType])
elif actualType in self._literalWidgets:
self.valueStack.setCurrentWidget(self._literalWidgets[actualType])
else:
self.valueStack.setCurrentWidget(self._literalWidgets.get("String"))
@Slot(int)
def onValueSrcChanged(
self,
idx
):
isVar = (self.valueSrcCombo.currentData() == "variable")
self.valueStack.setVisible(not isVar)
self.existingVarCombo.setVisible(isVar)
if isVar:
self._varMgr.populateCombo(self.existingVarCombo)
else:
self.updateRHSWidget()
def getTargetName(
self
) -> str:
data = self.targetCombo.currentData()
return data[0] if data else ""
def toScriptLine(
self
) -> str:
target = self.getTargetName()
if not target:
return ""
op = self.opTypeCombo.currentData()
rawVal = self._getValueRaw()
if op == "set":
vartype = self._currentTargetType
encoded = encodeValueStr(rawVal, vartype)
if vartype == "Time":
if rawVal.startswith("+"):
return f" {target} .ADD. {rawVal[1:]}"
if rawVal.startswith("-"):
return f" {target} .SUB. {rawVal[1:]}"
return f" SET {target} = {encoded}"
elif op == "add":
vartype = self._currentTargetType
if vartype == "Date" and hasattr(self.valueStack.currentWidget(), "getOffsetDays"):
days = self.valueStack.currentWidget().getOffsetDays()
return f" {target} .ADD. {days}"
if vartype == "Time" and hasattr(self.valueStack.currentWidget(), "getOffsetHours"):
hours = self.valueStack.currentWidget().getOffsetHours()
return f" {target} .ADD. {hours}"
return f" {target} .ADD. {rawVal}"
elif op == "sub":
vartype = self._currentTargetType
if vartype == "Date" and hasattr(self.valueStack.currentWidget(), "getOffsetDays"):
days = self.valueStack.currentWidget().getOffsetDays()
return f" {target} .SUB. {days}"
if vartype == "Time" and hasattr(self.valueStack.currentWidget(), "getOffsetHours"):
hours = self.valueStack.currentWidget().getOffsetHours()
return f" {target} .SUB. {hours}"
return f" {target} .SUB. {rawVal}"
return ""
def _getValueRaw(
self
) -> str:
if self.valueSrcCombo.currentData() == "variable":
return self.existingVarCombo.currentText().strip()
w = self.valueStack.currentWidget()
if w:
return getValueFromWidget(w)
return ""
def setOpType(
self,
opType: str
):
for i in range(self.opTypeCombo.count()):
if self.opTypeCombo.itemData(i) == opType:
self.opTypeCombo.setCurrentIndex(i)
break
def loadFromScript(
self,
targetVar: str,
valueExpr: str
):
targetUp = targetVar.upper().strip()
for ci in range(self.targetCombo.count()):
d = self.targetCombo.itemData(ci)
if d and d[0] == targetUp:
self.targetCombo.setCurrentIndex(ci)
break
self._setValueFromExpr(valueExpr)
def _setValueFromExpr(
self,
expr: str
):
s = expr.strip()
if not s:
return
up = s.upper()
if isVarReference(s):
self.valueSrcCombo.setCurrentIndex(1)
self._varMgr.populateCombo(self.existingVarCombo)
idx = self._varMgr.findExactNameEntry(self.existingVarCombo, up)
if idx >= 0:
self.existingVarCombo.setCurrentIndex(idx)
else:
self.existingVarCombo.addItem(up, (up, "String"))
self.existingVarCombo.setCurrentIndex(self.existingVarCombo.count() - 1)
else:
self.valueSrcCombo.setCurrentIndex(0)
w = self.valueStack.currentWidget()
if w:
setWidgetValue(w, self._currentTargetType, expr)
def refreshVarCombos(
self
):
currentData = self.targetCombo.currentData()
self.buildTargetCombo()
if currentData:
for i in range(self.targetCombo.count()):
d = self.targetCombo.itemData(i)
if d and d[0] == currentData[0]:
self.targetCombo.setCurrentIndex(i)
break
self._varMgr.populateCombo(self.existingVarCombo)
+9 -7
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -24,6 +24,8 @@ import managers.config.ConfigManager as ConfigManager
from utils.JSONReader import JSONReader
from utils.JSONWriter import JSONWriter
from interfaces.ConfigProvider import ConfigProvider, CfgKey
from managers.config.ConfigUtils import ConfigUtils
from gui.resources.ui.Ui_ALConfigWidget import Ui_ALConfigWidget
from gui.ALSeatMapSelectDialog import ALSeatMapSelectDialog
@@ -42,8 +44,8 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
):
super().__init__(parent)
self.__cfg_mgr = ConfigManager.instance()
self.__config_paths = ConfigManager.getValidateAutomationConfigPaths()
self.__cfg_mgr: ConfigProvider = ConfigManager.instance()
self.__config_paths = ConfigUtils.getAutomationConfigPaths()
self.__config_data = {"run": {}, "user": {}}
self.setupUi(self)
@@ -984,13 +986,13 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.setRunConfigToWidget(data)
self.__config_paths["run"] = run_config_path
self.CurrentRunConfigEdit.setText(run_config_path)
paths = self.__cfg_mgr.get(ConfigManager.ConfigType.GLOBAL, "automation.run_path.paths", [])
paths = self.__cfg_mgr.get(CfgKey.GLOBAL.AUTOMATION.RUN_PATH.PATHS, [])
if run_config_path not in paths:
paths.append(run_config_path)
index = len(paths) - 1
else:
index = paths.index(run_config_path)
self.__cfg_mgr.set(ConfigManager.ConfigType.GLOBAL, "automation.run_path", {"current": index, "paths": paths})
self.__cfg_mgr.set(CfgKey.GLOBAL.AUTOMATION.RUN_PATH.ROOT, {"current": index, "paths": paths})
else:
QMessageBox.warning(
self,
@@ -1019,13 +1021,13 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.setUsersToTreeWidget(data)
self.__config_paths["user"] = user_config_path
self.CurrentUserConfigEdit.setText(user_config_path)
paths = self.__cfg_mgr.get(ConfigManager.ConfigType.GLOBAL, "automation.user_path.paths", [])
paths = self.__cfg_mgr.get(CfgKey.GLOBAL.AUTOMATION.USER_PATH.PATHS, [])
if user_config_path not in paths:
paths.append(user_config_path)
index = len(paths) - 1
else:
index = paths.index(user_config_path)
self.__cfg_mgr.set(ConfigManager.ConfigType.GLOBAL, "automation.user_path", {"current": index, "paths": paths})
self.__cfg_mgr.set(CfgKey.GLOBAL.AUTOMATION.USER_PATH.ROOT, {"current": index, "paths": paths})
else:
QMessageBox.warning(
self,
+4 -6
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -19,9 +19,8 @@ from PySide6.QtGui import (
QTextCursor, QCloseEvent, QFont, QIcon, QDesktopServices
)
import managers.config.ConfigManager as ConfigManager
from base.MsgBase import MsgBase
from managers.config.ConfigUtils import ConfigUtils
from gui.resources.ui.Ui_ALMainWindow import Ui_ALMainWindow
from gui.resources import ALResource
@@ -44,9 +43,8 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
MsgBase.__init__(self, queue.Queue(), queue.Queue())
QMainWindow.__init__(self)
self.__cfg_mgr = ConfigManager.instance()
self.__timer_task_queue = queue.Queue()
self.__config_paths = ConfigManager.getValidateAutomationConfigPaths()
self.__config_paths = ConfigUtils.getAutomationConfigPaths()
self.__alTimerTaskManageWidget = None
self.__alConfigWidget = None
self.__auto_lib_thread = None
@@ -300,7 +298,7 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.__alConfigWidget.configWidgetIsClosed.disconnect(self.onConfigWidgetClosed)
self.__alConfigWidget.deleteLater()
self.__alConfigWidget = None
self.__config_paths = ConfigManager.getValidateAutomationConfigPaths()
self.__config_paths = ConfigUtils.getAutomationConfigPaths()
self.setControlButtons(True, None, None)
self._showLog("配置窗口已关闭,配置文件路径已更新")
+92 -17
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -18,6 +18,7 @@ from PySide6.QtCore import (
from base.MsgBase import MsgBase
from operators.AutoLib import AutoLib
from utils.JSONReader import JSONReader
from autoscript import execute, registerDefaultTargetVars
class AutoLibWorker(MsgBase, QThread):
@@ -76,25 +77,28 @@ class AutoLibWorker(MsgBase, QThread):
f"正在加载配置文件, 运行配置文件路径: {self.__config_paths["run"]}",
no_log=True
)
self.__run_config = JSONReader(self.__config_paths["run"]).data()
self._run_config = JSONReader(self.__config_paths["run"]).data()
self._showTrace(
f"正在加载配置文件, 用户配置文件路径: {self.__config_paths["user"]}",
no_log=True
)
self.__user_config = JSONReader(self.__config_paths["user"]).data()
if self.__run_config is None or self.__user_config is None:
self._user_config = JSONReader(self.__config_paths["user"]).data()
if self._run_config is None or self._user_config is None:
self._showTrace(
"配置文件加载失败, 请检查配置文件是否正确",
self.TraceLevel.ERROR
)
return False
if not self.__user_config.get("groups"):
if not self._user_config.get("groups"):
self._showTrace(
"用户配置文件中无有效任务组, 请检查用户配置文件是否正确",
self.TraceLevel.WARNING
)
return False
self._showLog(f"配置文件加载成功, 任务组数量: {len(self.__user_config.get('groups', []))}", self.TraceLevel.INFO)
self._showLog(
f"配置文件加载成功, 任务组数量: {len(self._user_config.get('groups', []))}",
self.TraceLevel.INFO
)
return True
@@ -115,9 +119,9 @@ class AutoLibWorker(MsgBase, QThread):
auto_lib = AutoLib(
self._input_queue,
self._output_queue,
self.__run_config
self._run_config
)
groups = self.__user_config.get("groups")
groups = self._user_config.get("groups")
for group in groups:
if not group["enabled"]:
self._showTrace(f"任务组 {group["name"]} 已跳过", no_log=True)
@@ -157,12 +161,91 @@ class TimerTaskWorker(AutoLibWorker):
self.autoLibWorkerIsFinished.connect(self.onTimerTaskIsFinished)
self.autoLibWorkerFinishedWithError.connect(self.onTimerTaskFinishedWithError)
def run(
self
):
self._showTrace(f"定时任务 {self.__timer_task['name']} 开始运行")
super().run()
if not self.checkTimeAvailable() or not self.checkConfigPaths():
self._showTrace("定时任务跳过执行 (时间或配置文件检查未通过)")
self.timerTaskWorkerIsFinished.emit(False, self.__timer_task)
return
try:
if not self.loadConfigs():
raise Exception("配置文件加载失败")
self.applyRepeatAutoScript()
auto_lib = AutoLib(
self._input_queue,
self._output_queue,
self._run_config
)
groups = self._user_config.get("groups")
for group in groups:
if not group["enabled"]:
self._showTrace(
f"任务组 {group['name']} 已跳过",
no_log=True
)
continue
self._showTrace(
f"正在运行任务组 {group['name']}",
no_log=True
)
auto_lib.run(
{"users": group.get("users", [])}
)
auto_lib.close()
except Exception as e:
self._showTrace(
f"定时任务 {self.__timer_task['name']} 运行时发生异常: {e}",
self.TraceLevel.ERROR
)
self.timerTaskWorkerIsFinished.emit(True, self.__timer_task)
return
self._showTrace(f"定时任务 {self.__timer_task['name']} 运行结束")
self.timerTaskWorkerIsFinished.emit(False, self.__timer_task)
def applyRepeatAutoScript(
self
):
auto_script = self.__timer_task.get("repeat_auto_script", "")
if not auto_script or not auto_script.strip():
return
self._showTrace(
f"检测到重复定时任务 AutoScript, 开始执行...",
no_log=True
)
groups = self._user_config.get("groups", [])
affected_count = 0
for group in groups:
if not group.get("enabled", False):
continue
for user in group.get("users", []):
try:
registerDefaultTargetVars()
execute(auto_script, user)
affected_count += 1
except ValueError as e:
self._showTrace(
f"AutoScript 执行错误 (用户 {user['username']}): {e}",
self.TraceLevel.ERROR
)
self._showLog(
f"AutoScript 执行完毕, "
f"影响 {affected_count} 个用户",
self.TraceLevel.INFO
)
@Slot()
def onTimerTaskIsFinished(
self
):
self._showTrace(f"定时任务 {self.__timer_task['name']} 运行结束")
self.timerTaskWorkerIsFinished.emit(False, self.__timer_task)
@Slot()
def onTimerTaskFinishedWithError(
@@ -174,11 +257,3 @@ class TimerTaskWorker(AutoLibWorker):
self.TraceLevel.ERROR
)
self.timerTaskWorkerIsFinished.emit(True, self.__timer_task)
@Slot()
def onTimerTaskIsFinished(
self
):
self._showTrace(f"定时任务 {self.__timer_task['name']} 运行结束")
self.timerTaskWorkerIsFinished.emit(False, self.__timer_task)
+3 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -31,6 +31,7 @@ class ALSeatFrame(QFrame):
self.setupUi()
def setupUi(
self
):
@@ -54,6 +55,7 @@ class ALSeatFrame(QFrame):
self.Label.setAlignment(Qt.AlignCenter)
self.Label.setGeometry(0, 0, 60, 40)
def mousePressEvent(
self,
event
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
+8
View File
@@ -1,4 +1,12 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
from enum import Enum
from PySide6.QtWidgets import (
+166 -19
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -12,11 +12,13 @@ import uuid
from enum import Enum
from datetime import datetime, timedelta
from PySide6.QtCore import Slot, QDateTime
from PySide6.QtWidgets import QLabel, QDialog, QWidget, QSpinBox, QHBoxLayout, QGridLayout, QDateTimeEdit
from PySide6.QtCore import Slot, QDateTime, QUrl
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QLabel, QDialog, QWidget, QSpinBox, QHBoxLayout, QVBoxLayout, QGridLayout, QDateTimeEdit, QGroupBox, QPushButton
from gui.resources.ui.Ui_ALTimerTaskAddDialog import Ui_ALTimerTaskAddDialog
import utils.TimerUtils as TimerUtils
from gui.ALAutoScriptOrchDialog import ALAutoScriptOrchDialog
from utils.TimerUtils import TimerUtils
class ALTimerTaskStatus(Enum):
@@ -34,15 +36,20 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
def __init__(
self,
parent = None
parent = None,
timer_task: dict = None
):
super().__init__(parent)
self.__edit_timer_task = timer_task
self.setupUi(self)
self.modifyUi()
self.connectSignals()
if self.__edit_timer_task:
self.loadTask(self.__edit_timer_task)
def modifyUi(
self
@@ -51,6 +58,8 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
self.TimerTypeComboBox.setCurrentIndex(0)
self.SpecificTimerWidget = QWidget()
self.SpecificTimerLayout = QHBoxLayout(self.SpecificTimerWidget)
self.SpecificTimerLayout.setContentsMargins(0, 0, 0, 0)
self.SpecificTimerLayout.setSpacing(5)
self.SpecificTimerLayout.addWidget(QLabel("定时时间:"))
self.SpecificDateTimeEdit = QDateTimeEdit()
self.SpecificDateTimeEdit.setCalendarPopup(True)
@@ -62,6 +71,8 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
self.RelativeTimerWidget = QWidget()
self.RelativeTimerLayout = QHBoxLayout(self.RelativeTimerWidget)
self.RelativeTimerLayout.setContentsMargins(0, 0, 0, 0)
self.RelativeTimerLayout.setSpacing(5)
self.RelativeTimerLayout.addWidget(QLabel("相对时间:"))
self.RelativeDaySpinBox = QSpinBox()
self.RelativeDaySpinBox.setMinimum(0)
@@ -86,6 +97,84 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
self.TimerConfigLayout.addWidget(self.RelativeTimerWidget)
self.RelativeTimerWidget.setVisible(False)
self.AutoScriptGroupBox = QGroupBox("AutoScript 指令")
self.AutoScriptLayout = QVBoxLayout(self.AutoScriptGroupBox)
self.AutoScriptLayout.setContentsMargins(3, 3, 3, 3)
self.AutoScriptLayout.setSpacing(3)
autoScriptBtnLayout = QHBoxLayout()
self.AutoScriptSetButton = QPushButton("设置指令")
self.AutoScriptSetButton.setMinimumHeight(25)
self.AutoScriptSetButton.setFixedWidth(130)
autoScriptBtnLayout.addWidget(self.AutoScriptSetButton)
self.AutoScriptPreviewButton = QPushButton("编辑")
self.AutoScriptPreviewButton.setMinimumHeight(25)
self.AutoScriptPreviewButton.setFixedWidth(60)
autoScriptBtnLayout.addWidget(self.AutoScriptPreviewButton)
autoScriptBtnLayout.addStretch()
self.AutoScriptHelpButton = QPushButton("?")
self.AutoScriptHelpButton.setFixedSize(20, 20)
self.AutoScriptHelpButton.setToolTip(
"AutoScript 是一种轻量级 DSL\n"
"用于在重复定时任务执行前,对用户的预约数据进行预处理\n"
"\n"
"点击查看完整在线文档"
)
self.AutoScriptHelpButton.setStyleSheet(
"QPushButton { border-radius: 10px; border: 1px solid #999; "
"font-weight: bold; color: #555; }"
"QPushButton:hover { background-color: #E0E0E0; }"
)
autoScriptBtnLayout.addWidget(self.AutoScriptHelpButton)
self.AutoScriptStatusLabel = QLabel("未设置")
self.AutoScriptStatusLabel.setStyleSheet("color: #969696;")
self.AutoScriptStatusLabel.setFixedHeight(25)
autoScriptBtnLayout.addWidget(self.AutoScriptStatusLabel)
self.AutoScriptLayout.addLayout(autoScriptBtnLayout)
self.ALAddTimerTaskLayout.insertWidget(
self.ALAddTimerTaskLayout.indexOf(self.TaskConfigGroupBox) + 1,
self.AutoScriptGroupBox
)
self.AutoScriptGroupBox.setVisible(False)
self.__auto_script = ""
def loadTask(
self,
task: dict
):
self.TaskNameLineEdit.setText(task.get("name", ""))
time_type = task.get("time_type", "特定时间")
self.TimerTypeComboBox.setCurrentText(time_type)
self.SpecificDateTimeEdit.setDateTime(
QDateTime(task["execute_time"])
)
self.RelativeDaySpinBox.setValue(0)
self.RelativeHourSpinBox.setValue(0)
self.RelativeMinuteSpinBox.setValue(0)
self.RelativeSecondSpinBox.setValue(0)
if task.get("silent", False):
self.SilentlyRunRadioButton.setChecked(True)
else:
self.ShowBeforeRunRadioButton.setChecked(True)
repeat = task.get("repeat", False)
self.RepeatCheckBox.setChecked(repeat)
if repeat:
repeat_days = task.get("repeat_days", [])
self.MonCheckBox.setChecked(0 in repeat_days)
self.TueCheckBox.setChecked(1 in repeat_days)
self.WedCheckBox.setChecked(2 in repeat_days)
self.ThuCheckBox.setChecked(3 in repeat_days)
self.FriCheckBox.setChecked(4 in repeat_days)
self.SatCheckBox.setChecked(5 in repeat_days)
self.SunCheckBox.setChecked(6 in repeat_days)
auto_script = task.get("repeat_auto_script", "")
if auto_script:
self.__auto_script = auto_script
self.AutoScriptStatusLabel.setText("已设置")
self.AutoScriptStatusLabel.setStyleSheet("color: #4CAF50;")
self.ConfirmButton.setText("保存")
def connectSignals(
self
@@ -95,6 +184,9 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
self.ConfirmButton.clicked.connect(self.accept)
self.TimerTypeComboBox.currentIndexChanged.connect(self.onTimerTypeComboBoxIndexChanged)
self.RepeatCheckBox.toggled.connect(self.onRepeatCheckBoxToggled)
self.AutoScriptSetButton.clicked.connect(self.onSetAutoScript)
self.AutoScriptPreviewButton.clicked.connect(self.onPreviewAutoScript)
self.AutoScriptHelpButton.clicked.connect(self.onAutoScriptHelp)
def getTimerTask(
@@ -119,18 +211,34 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
minutes = self.RelativeMinuteSpinBox.value(),
seconds = self.RelativeSecondSpinBox.value()
)
task_data = {
"name": name,
"uuid": uuid.uuid4().hex.upper() + f"-{added_time.strftime("%Y%m%d%H%M%S")}",
"time_type": self.TimerTypeComboBox.currentText(),
"execute_time": execute_time,
"silent": silent,
"added_time": added_time,
"status": ALTimerTaskStatus.PENDING,
"executed": False,
"repeat": self.RepeatCheckBox.isChecked(),
}
if task_data["repeat"]:
if self.__edit_timer_task:
task_data = dict(self.__edit_timer_task)
task_data["name"] = name
task_data["execute_time"] = execute_time
task_data["silent"] = silent
task_data["status"] = ALTimerTaskStatus.PENDING
task_data["executed"] = False
task_data["repeat_auto_script"] = self.__auto_script
else:
task_data = {
"name": name,
"uuid": uuid.uuid4().hex.upper() + f"-{added_time.strftime("%Y%m%d%H%M%S")}",
"time_type": self.TimerTypeComboBox.currentText(),
"execute_time": execute_time,
"silent": silent,
"added_time": added_time,
"status": ALTimerTaskStatus.PENDING,
"executed": False,
"repeat": self.RepeatCheckBox.isChecked(),
"repeat_auto_script": self.__auto_script,
}
repeat = self.RepeatCheckBox.isChecked()
task_data["repeat"] = repeat
if repeat:
if "repeat_history" not in task_data:
task_data["repeat_history"] = []
repeat_days = []
if self.MonCheckBox.isChecked():
repeat_days.append(0)
@@ -152,7 +260,7 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
task_data["repeat_hour"] = execute_time.hour
task_data["repeat_minute"] = execute_time.minute
task_data["repeat_second"] = execute_time.second
task_data["execute_time"] = TimerUtils.calculateNextRepeatTime(
task_data["execute_time"] = TimerUtils.getNextTimerRepeatTime(
task_data["repeat_days"],
task_data["repeat_hour"],
task_data["repeat_minute"],
@@ -181,4 +289,43 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
self.ThuCheckBox.setEnabled(checked)
self.FriCheckBox.setEnabled(checked)
self.SatCheckBox.setEnabled(checked)
self.SunCheckBox.setEnabled(checked)
self.SunCheckBox.setEnabled(checked)
self.AutoScriptGroupBox.setVisible(checked)
@Slot()
def onSetAutoScript(self):
dlg = ALAutoScriptOrchDialog(self, existingScript=self.__auto_script)
if dlg.exec() == QDialog.DialogCode.Accepted:
script = dlg.getScript()
self.__auto_script = script
if script:
self.AutoScriptStatusLabel.setText("已设置")
self.AutoScriptStatusLabel.setStyleSheet("color: #4CAF50;")
else:
self.AutoScriptStatusLabel.setText("未设置")
self.AutoScriptStatusLabel.setStyleSheet("color: #969696;")
dlg.deleteLater()
@Slot()
def onPreviewAutoScript(self):
from gui.ALAutoScriptEditDialog import ALAutoScriptEditDialog
dlg = ALAutoScriptEditDialog(self, self.__auto_script)
if dlg.exec() == QDialog.DialogCode.Accepted:
script = dlg.getScript()
self.__auto_script = script
if script:
self.AutoScriptStatusLabel.setText("已设置")
self.AutoScriptStatusLabel.setStyleSheet("color: #4CAF50;")
else:
self.AutoScriptStatusLabel.setText("未设置")
self.AutoScriptStatusLabel.setStyleSheet("color: #969696;")
dlg.deleteLater()
@Slot()
def onAutoScriptHelp(
self
):
QDesktopServices.openUrl(
QUrl("https://www.autolibrary.kenanzhu.com/manuals/autoscript")
)
+11 -12
View File
@@ -28,15 +28,14 @@ class ALTimerTaskHistoryDialog(QDialog):
):
super().__init__(parent)
self.__task_data = task_data
self.__history = task_data.get("history", [])
self.__history = task_data.get("repeat_history", [])
self.modifyUi()
self.setupUi()
self.connectSignals()
def modifyUi(
def setupUi(
self
):
@@ -130,6 +129,13 @@ class ALTimerTaskHistoryDialog(QDialog):
self.HistoryTableWidget.setItem(row, 2, DurationItem)
self.HistoryTableWidget.setRowHeight(row, 25)
def getHistory(
self
) -> list:
return self.__history
@Slot()
def onClearHistoryButtonClicked(
self
@@ -137,11 +143,4 @@ class ALTimerTaskHistoryDialog(QDialog):
self.__history.clear()
self.HistoryTableWidget.setRowCount(0)
self.__task_data["history"] = self.__history
def getHistory(
self
) -> list:
return self.__history
self.__task_data["repeat_history"] = self.__history
+66 -18
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -19,14 +19,16 @@ from PySide6.QtCore import (
)
from PySide6.QtWidgets import (
QDialog, QWidget, QListWidgetItem, QMessageBox,
QHBoxLayout, QVBoxLayout, QLabel, QPushButton
QHBoxLayout, QVBoxLayout, QLabel, QPushButton, QMenu
)
from PySide6.QtGui import (
QCloseEvent
QCloseEvent, QAction
)
import managers.config.ConfigManager as ConfigManager
import utils.TimerUtils as TimerUtils
from utils.TimerUtils import TimerUtils
from interfaces.ConfigProvider import ConfigProvider, CfgKey
from gui.resources.ui.Ui_ALTimerTaskManageWidget import Ui_ALTimerTaskManageWidget
from gui.ALTimerTaskAddDialog import ALTimerTaskAddDialog, ALTimerTaskStatus
@@ -35,6 +37,8 @@ from gui.ALTimerTaskHistoryDialog import ALTimerTaskHistoryDialog
class ALTimerTaskItemWidget(QWidget):
editRequested = Signal(dict)
def __init__(
self,
parent = None,
@@ -43,8 +47,11 @@ class ALTimerTaskItemWidget(QWidget):
super().__init__(parent)
self.__timer_task = timer_task
self.__manage_widget = parent
self.modifyUi()
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self.showContextMenu)
def modifyUi(
@@ -145,6 +152,27 @@ class ALTimerTaskItemWidget(QWidget):
self.DeleteButton.setEnabled(False)
self.setFixedHeight(55)
@Slot(object)
def showContextMenu(
self,
pos
):
menu = QMenu(self)
edit_action = QAction("编辑", self)
edit_action.triggered.connect(
lambda: self.editRequested.emit(self.__timer_task)
)
menu.addAction(edit_action)
if self.__timer_task["status"] != ALTimerTaskStatus.RUNNING\
and self.__timer_task["status"] != ALTimerTaskStatus.READY:
delete_action = QAction("删除", self)
delete_action.triggered.connect(
lambda: self.__manage_widget.deleteTask(self.__timer_task)
)
menu.addAction(delete_action)
menu.exec(self.mapToGlobal(pos))
class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
@@ -164,7 +192,7 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
):
super().__init__(parent)
self.__cfg_mgr = ConfigManager.instance()
self.__cfg_mgr: ConfigProvider = ConfigManager.instance()
self.__timer_tasks = []
self.__check_timer = None
self.__sort_policy = self.SortPolicy.BY_EXECUTE_TIME
@@ -218,14 +246,14 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
) -> list:
try:
timer_tasks = self.__cfg_mgr.get(ConfigManager.ConfigType.TIMERTASK)
timer_tasks = self.__cfg_mgr.get(CfgKey.TIMERTASK.ROOT)
if timer_tasks and "timer_tasks" in timer_tasks:
for task in timer_tasks["timer_tasks"]:
task["added_time"] = datetime.strptime(task["added_time"], "%Y-%m-%d %H:%M:%S")
task["execute_time"] = datetime.strptime(task["execute_time"], "%Y-%m-%d %H:%M:%S")
task["status"] = ALTimerTaskStatus(task["status"])
if "history" in task:
for item in task["history"]:
if "repeat_history" in task:
for item in task["repeat_history"]:
item["result"] = ALTimerTaskStatus(item["result"])
return timer_tasks["timer_tasks"]
raise Exception("定时任务配置文件格式错误")
@@ -248,10 +276,10 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
task["added_time"] = task["added_time"].strftime("%Y-%m-%d %H:%M:%S")
task["execute_time"] = task["execute_time"].strftime("%Y-%m-%d %H:%M:%S")
task["status"] = task["status"].value
if "history" in task:
for item in task["history"]:
if "repeat_history" in task:
for item in task["repeat_history"]:
item["result"] = item["result"].value
self.__cfg_mgr.set(ConfigManager.ConfigType.TIMERTASK, "", { "timer_tasks": timer_tasks })
self.__cfg_mgr.set(CfgKey.TIMERTASK.ROOT, { "timer_tasks": timer_tasks })
return True
except Exception as e:
QMessageBox.warning(
@@ -363,6 +391,7 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
widget.HistoryButton.clicked.connect(
lambda _, task = timer_task: self.showTaskHistory(task)
)
widget.editRequested.connect(self.editTask)
item.setSizeHint(widget.size())
self.TimerTasksListWidget.addItem(item)
self.TimerTasksListWidget.setItemWidget(item, widget)
@@ -378,17 +407,37 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
self.__timer_tasks.append(timer_task)
self.timerTasksChanged.emit()
def editTask(
self,
timer_task: dict
):
dialog = ALTimerTaskAddDialog(self, timer_task)
if dialog.exec() == QDialog.DialogCode.Accepted:
updated = dialog.getTimerTask()
for i, task in enumerate(self.__timer_tasks):
if task["uuid"] == updated["uuid"]:
self.__timer_tasks[i] = updated
break
self.timerTasksChanged.emit()
@staticmethod
def getTimerTaskDetailMessage(
timer_task: dict
):
if "repeat_history" not in timer_task:
history = []
else:
history = timer_task["repeat_history"]
history_count = len(history)
return (
f"任务名称:{timer_task["name"]}\n"
f"添加时间:{timer_task["added_time"]}\n"
f"当前状态:{timer_task["status"].value}\n"
f"下次执行时间:{datetime.strftime(timer_task["execute_time"], "%Y-%m-%d %H:%M:%S")}\n"
f"已记录次数:{len(timer_task['history'] if 'history' in timer_task else 0)}"
f"已记录次数:{history_count}"
)
@@ -554,7 +603,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
self.updateTimerTaskList()
self.updateStat()
@Slot(dict)
def onTimerTaskIsRunning(
self,
@@ -579,12 +627,12 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
ALTimerTaskStatus.OUTDATED}
if status not in valid_statuses:
return timer_task
if "history" not in timer_task:
timer_task["history"] = []
if "repeat_history" not in timer_task:
timer_task["repeat_history"] = []
if status != ALTimerTaskStatus.OUTDATED:
executed_time = datetime.now()
duration = (executed_time - timer_task["execute_time"]).total_seconds()
timer_task["history"].append({
timer_task["repeat_history"].append({
"execute_time": timer_task["execute_time"].strftime("%Y-%m-%d %H:%M:%S"),
"executed_time": executed_time.strftime("%Y-%m-%d %H:%M:%S"),
"result": status,
@@ -598,14 +646,14 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
delta_days = (current_time - execute_time).days
for i in range(delta_days + 1):
if (execute_weekday + i)%7 in timer_task["repeat_days"]:
timer_task["history"].append({
timer_task["repeat_history"].append({
"execute_time": (execute_time + timedelta(days=i)).strftime("%Y-%m-%d %H:%M:%S"),
"executed_time": (execute_time + timedelta(days=i)).strftime("%Y-%m-%d %H:%M:%S"),
"result": status,
"duration": 0,
"uuid": timer_task["uuid"]
})
next_time = TimerUtils.calculateNextRepeatTime(
next_time = TimerUtils.getNextTimerRepeatTime(
timer_task["repeat_days"],
timer_task["repeat_hour"],
timer_task["repeat_minute"],
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
+3 -3
View File
@@ -5,11 +5,11 @@
workflow process. Do not edit manually.
This file is auto-generated during the workflow process.
Last updated: 2026-03-21 10:54:51 UTC
Last updated: 2026-05-09 06:05:13 UTC
"""
AL_VERSION = "1.2.0"
AL_TAG = "v1.2.0"
AL_VERSION = "1.3.0"
AL_TAG = "v1.3.0"
AL_COMMIT_SHA = "local"
AL_COMMIT_DATE = "null" # time zone : UTC
AL_BUILD_DATE = "null" # time zone : UTC
+1
View File
@@ -10,6 +10,7 @@
- ALSeatMapTable: Seat map table class.
- ALSeatMapSelectDialog: Seat map select dialog class.
- ALTimerTaskAddDialog: Timer task add dialog class.
- ALAutoScriptOrchDialog: AutoScript orchestration dialog class.
- ALTimerTaskHistoryDialog: Timer task history dialog class.
- ALTimerTaskManageWidget: Timer task manage widget class.
- ALUserTreeWidget: User tree widget class.
+1 -1
View File
@@ -13,7 +13,7 @@
<property name="minimumSize">
<size>
<width>350</width>
<height>400</height>
<height>460</height>
</size>
</property>
<property name="maximumSize">
+117
View File
@@ -0,0 +1,117 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
from dataclasses import dataclass
from enum import Enum
from typing import Any, Optional, Protocol
class ConfigType(Enum):
"""
Config type enum. Values represent the default filename.
"""
GLOBAL = "autolibrary.json"
BULLETIN = "bulletin.json"
TIMERTASK = "timer_task.json"
@dataclass(frozen=True)
class ConfigPath:
"""
A typed configuration path that carries both the config file
and the dot-separated key in a single object.
Consumers pass this directly to ConfigProvider.get/set,
eliminating the need to import ConfigType separately.
"""
config_type: ConfigType
key: str = ""
class CfgKey:
"""
Type-safe hierarchical configuration key constants.
Each leaf is a ConfigPath that can be passed directly to
``ConfigProvider.get()`` or ``ConfigProvider.set()``.
Usage::
CfgKey.GLOBAL.AUTOMATION.RUN_PATH.PATHS
# -> ConfigPath(ConfigType.GLOBAL, "automation.run_path.paths")
config.get(CfgKey.GLOBAL.AUTOMATION.RUN_PATH.PATHS, [])
config.set(CfgKey.GLOBAL.AUTOMATION.RUN_PATH.PATHS, value)
"""
class GLOBAL:
class AUTOMATION:
ROOT = ConfigPath(ConfigType.GLOBAL, "automation")
class RUN_PATH:
ROOT = ConfigPath(ConfigType.GLOBAL, "automation.run_path")
CURRENT = ConfigPath(ConfigType.GLOBAL, "automation.run_path.current")
PATHS = ConfigPath(ConfigType.GLOBAL, "automation.run_path.paths")
class USER_PATH:
ROOT = ConfigPath(ConfigType.GLOBAL, "automation.user_path")
CURRENT = ConfigPath(ConfigType.GLOBAL, "automation.user_path.current")
PATHS = ConfigPath(ConfigType.GLOBAL, "automation.user_path.paths")
class TIMERTASK:
ROOT = ConfigPath(ConfigType.TIMERTASK, "")
TIMER_TASKS = ConfigPath(ConfigType.TIMERTASK, "timer_tasks")
class BULLETIN:
ROOT = ConfigPath(ConfigType.BULLETIN, "")
BULLETIN = ConfigPath(ConfigType.BULLETIN, "bulletin")
LAST_SYNC_TIME = ConfigPath(ConfigType.BULLETIN, "last_sync_time")
class ConfigProvider(Protocol):
"""
Abstract interface for configuration storage access.
Concrete implementations (e.g. ConfigManager) conform to
this protocol structurally rather than through explicit
inheritance.
"""
def get(
self,
key: ConfigPath,
default: Optional[Any] = None
) -> Any:
"""
Retrieve a configuration value.
Args:
key: A ConfigPath object specifying which config file
and key to read from.
default: Fallback value if the key is not found.
Returns:
The configuration value at the given key path.
"""
...
def set(
self,
key: ConfigPath,
value: Any = None
) -> None:
"""
Set a configuration value and persist to disk.
Args:
key: A ConfigPath object specifying which config file
and key to write to.
value: The value to store.
"""
...
+11
View File
@@ -0,0 +1,11 @@
"""
Interfaces module for the AutoLibrary project.
Defines abstract interfaces (Protocols) and shared type definitions
used across layers to decouple consumers from concrete implementations.
Key components:
- ConfigProvider: Abstract interface for configuration access.
- ConfigType: Enumeration of configuration file types.
- ConfigKey: Type-safe hierarchical key constants for config lookups.
"""
+13 -66
View File
@@ -10,26 +10,17 @@ See the LICENSE file for details.
import os
import threading
from enum import Enum
from typing import Any, Optional
from utils.JSONReader import JSONReader
from utils.JSONWriter import JSONWriter
from interfaces.ConfigProvider import ConfigType, ConfigPath
# This config manager class only responsible for global and other
# unconfigurable config files.
class ConfigType(Enum):
"""
Config type class. Values represent the default filename.
"""
GLOBAL = "autolibrary.json" # Global config file.
BULLETIN = "bulletin.json" # Bulletin board config file.
TIMERTASK = "timer_task.json" # Timer task config file.
class ConfigTemplate:
"""
Config template class.
@@ -120,16 +111,15 @@ class ConfigManager:
def get(
self,
config_type: ConfigType,
key: str = "",
key: ConfigPath,
default: Optional[Any] = None
) -> Any:
with self.__config_lock:
config_data = self.__config_data[config_type.value]
if key == "":
config_data = self.__config_data[key.config_type.value]
if key.key == "":
return config_data
keys = key.split('.')
keys = key.key.split('.')
for k in keys[:-1]:
config_data = config_data.get(k, None)
if config_data is None:
@@ -139,24 +129,23 @@ class ConfigManager:
def set(
self,
config_type: ConfigType,
key: str = "",
key: ConfigPath,
value: Any = None
):
with self.__config_lock:
root_data = self.__config_data[config_type.value]
if key == "":
self.__config_data[config_type.value] = value
root_data = self.__config_data[key.config_type.value]
if key.key == "":
self.__config_data[key.config_type.value] = value
else:
keys = key.split('.')
keys = key.key.split('.')
config_data = root_data
for k in keys[:-1]:
if k not in config_data:
config_data[k] = {}
config_data = config_data[k]
config_data[keys[-1]] = value
self.save(config_type)
self.save(key.config_type)
def save(
@@ -176,49 +165,7 @@ class ConfigManager:
# ConfigManager singleton instance.
_config_manager_instance = None
# Utility functions.
#
# Utility function to get validated automation config paths.
def getValidateAutomationConfigPaths(
) -> dict:
"""
Get validated automation config paths from ConfigManager instance.
These function will validate the config paths and return the validated paths in a dict.
Returns:
dict: Validated automation config paths.
"""
config_paths = {"run": "", "user": ""}
auto_config = _config_manager_instance.get(ConfigType.GLOBAL, "automation", {})
for cfg_type in ["run", "user"]:
paths = auto_config.get(f"{cfg_type}_path", {}).get("paths", [])
index = auto_config.get(f"{cfg_type}_path", {}).get("current", 0)
if paths == []:
paths.append(os.path.join(_config_manager_instance.configDir(), f"{cfg_type}.json"))
if index < 0:
index = 0
if index >= len(paths):
index = len(paths) - 1
config_paths[cfg_type] = paths[index]
data = {"current": index, "paths": paths}
auto_config[f"{cfg_type}_path"] = data
_config_manager_instance.set(ConfigType.GLOBAL, "automation", auto_config)
return config_paths
# Utility function to get base config directory.
def getBaseConfigDir(
) -> str:
"""
Get base config directory, on Windows, it is usually at :
'C:\\Users\\<username>\\AppData\\Local\\AutoLibrary\\config'.
Returns:
str: Base config directory.
"""
return _config_manager_instance.configDir()
_config_manager_instance : ConfigManager | None = None
# Singleton instance of ConfigManager.
_instance_lock = threading.Lock()
@@ -240,6 +187,6 @@ def instance(
else:
if config_dir == "":
return _config_manager_instance
if getBaseConfigDir() != config_dir:
if _config_manager_instance.configDir() != config_dir:
raise ValueError("ConfigManager 的实例已初始化,不能使用不同的配置目录。")
return _config_manager_instance
+48
View File
@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
import os
import managers.config.ConfigManager as ConfigManager
from interfaces.ConfigProvider import CfgKey
class ConfigUtils:
"""
Config utilities class.
"""
@staticmethod
def getAutomationConfigPaths(
) -> dict[str]:
"""
Get validated automation config paths from ConfigManager instance.
These function will validate the config paths and return the validated paths in a dict.
Returns:
dict[str]: Validated automation config paths (include user and run config paths).
"""
cfg_mgr = ConfigManager.instance() # config manager instance
config_paths = {"run": "", "user": ""}
auto_config = cfg_mgr.get(CfgKey.GLOBAL.AUTOMATION.ROOT, {})
for cfg_type in ["run", "user"]:
paths = auto_config.get(f"{cfg_type}_path", {}).get("paths", [])
index = auto_config.get(f"{cfg_type}_path", {}).get("current", 0)
if paths == []:
paths.append(os.path.join(cfg_mgr.configDir(), f"{cfg_type}.json"))
if index < 0:
index = 0
if index >= len(paths):
index = len(paths) - 1
config_paths[cfg_type] = paths[index]
data = {"current": index, "paths": paths}
auto_config[f"{cfg_type}_path"] = data
cfg_mgr.set(CfgKey.GLOBAL.AUTOMATION.ROOT, auto_config)
return config_paths
+23 -5
View File
@@ -1,5 +1,14 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
import platform
import installed_browsers
import browsers
from pathlib import Path
from enum import Enum
@@ -128,7 +137,7 @@ class WebBrowserDetector:
self.browser_infos = []
try:
all_browsers = installed_browsers.browsers()
all_browsers = list(browsers.browsers())
except Exception as e:
self.browser_infos = []
return self.browser_infos
@@ -140,14 +149,14 @@ class WebBrowserDetector:
'msedge': WebBrowserType.EDGE,
}
for browser in all_browsers:
internal_name = browser.get('name', '').lower()
internal_name = browser.get("browser_type", "").lower()
if internal_name not in type_map:
continue # Not one of the browsers we care about
version = browser.get('version')
version = browser.get("version", "")
if not version:
# Skip browsers with no version info (unlikely, but defensive)
continue
exe_path = browser.get('location')
exe_path = browser.get("path", "")
if not exe_path:
continue
try:
@@ -163,4 +172,13 @@ class WebBrowserDetector:
browser_path=path,
)
self.browser_infos.append(info)
# Deduplicate: keep only one entry per (type, version)
seen = set()
unique = []
for info in self.browser_infos:
key = (info.browser_type, info.browser_version)
if key not in seen:
seen.add(key)
unique.append(info)
self.browser_infos = unique
return self.browser_infos
@@ -1,3 +1,12 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
import os
import time
import shutil
+1 -1
View File
@@ -186,7 +186,7 @@ def instance(
raise ValueError("LogManager 的实例已初始化, 不能使用不同的日志目录")
return _log_manager_instance
# export function to get logger
def getLogger(
name: Optional[str] = None
) -> logging.Logger:
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
+42 -36
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -10,41 +10,47 @@ See the LICENSE file for details.
from datetime import datetime, timedelta
def calculateNextRepeatTime(
repeat_days: list,
hour: int,
minute: int,
second: int
) -> datetime:
class TimerUtils:
"""
Calculate the next repeat time based on repeat days and target time.
This function calculates the next execution time for a repeatable task.
If the current day is in repeat_days and the target time has not passed,
it returns today's target time. Otherwise, it finds the next matching day.
Args:
repeat_days (list): List of weekdays to repeat (0=Monday, 6=Sunday).
hour (int): Target hour (0-23).
minute (int): Target minute (0-59).
second (int): Target second (0-59).
Returns:
datetime: The next repeat execution time.
Timer utilities class.
"""
current_time = datetime.now()
current_weekday = current_time.weekday()
target_time = current_time.replace(hour=hour, minute=minute, second=second, microsecond=0)
if current_weekday in repeat_days:
if target_time > current_time:
return target_time
repeat_days_sorted = sorted(repeat_days)
for day in repeat_days_sorted:
if day > current_weekday:
days_until = day - current_weekday
next_time = target_time + timedelta(days=days_until)
return next_time
days_until = 7 - current_weekday + repeat_days_sorted[0]
next_time = target_time + timedelta(days=days_until)
return next_time
@staticmethod
def getNextTimerRepeatTime(
repeat_days: list[int],
hour: int,
minute: int,
second: int
) -> datetime:
"""
Calculate the next repeat time based on repeat days and target time.
This function calculates the next execution time for a repeatable task.
If the current day is in repeat_days and the target time has not passed,
it returns today's target time. Otherwise, it finds the next matching day.
Args:
repeat_days (list[int]): List of weekdays to repeat (0=Monday, 6=Sunday).
hour (int): Target hour (0-23).
minute (int): Target minute (0-59).
second (int): Target second (0-59).
Returns:
datetime: The next repeat execution time.
"""
current_time = datetime.now()
current_weekday = current_time.weekday()
target_time = current_time.replace(hour=hour, minute=minute, second=second, microsecond=0)
if current_weekday in repeat_days:
if target_time > current_time:
return target_time
repeat_days_sorted = sorted(repeat_days)
for day in repeat_days_sorted:
if day > current_weekday:
days_until = day - current_weekday
next_time = target_time + timedelta(days=days_until)
return next_time
days_until = 7 - current_weekday + repeat_days_sorted[0]
next_time = target_time + timedelta(days=days_until)
return next_time
+1 -1
View File
@@ -5,4 +5,4 @@
- TimerUtils: Timer utils class for the AutoLibrary project.
- JSONReader: JSON reader class for the AutoLibrary project.
- JSONWriter: JSON writer class for the AutoLibrary project.
"""
"""