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

Compare commits

...

18 Commits

Author SHA1 Message Date
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
39 changed files with 1986 additions and 181 deletions
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2026 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+3 -3
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
@@ -11,7 +11,7 @@ import logging
import queue import queue
import datetime import datetime
from managers.log.LogManager import getLogger import managers.log.LogManager as LogManager
class MsgBase: class MsgBase:
@@ -54,7 +54,7 @@ class MsgBase:
self._input_queue = input_queue self._input_queue = input_queue
self._output_queue = output_queue self._output_queue = output_queue
try: try:
self._logger = getLogger(self._class_name) self._logger = LogManager.getLogger(self._class_name)
except RuntimeError: except RuntimeError:
self._logger = None self._logger = None
+2 -3
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2026 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. 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) app_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
driver_dir = os.path.join(app_dir, "drivers") driver_dir = os.path.join(app_dir, "drivers")
logger.info("初始化驱动目录 %s", driver_dir)
if not QDir(driver_dir).exists(): if not QDir(driver_dir).exists():
logger.error("创建驱动目录 %s 失败", driver_dir) logger.info("初始化驱动目录 %s", driver_dir)
if not QDir().mkpath(driver_dir): if not QDir().mkpath(driver_dir):
logger.error("创建驱动目录 %s 失败", driver_dir) logger.error("创建驱动目录 %s 失败", driver_dir)
return False return False
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+884
View File
@@ -0,0 +1,884 @@
# -*- 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 QTime, QDate, Slot
from PySide6.QtWidgets import (
QDialog, QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QComboBox, QPushButton, QScrollArea, QTimeEdit,
QDateEdit, QLineEdit, QSpinBox, QDoubleSpinBox,
QStackedWidget, QFrame, QDialogButtonBox,
QGroupBox, QSizePolicy
)
from utils.AutoScriptEngine import AutoScriptEngine
VARIABLE_META = AutoScriptEngine.VARIABLE_META
_VAR_COMBO_ITEMS = [
(display, varname, vartype)
for display, (varname, vartype) in VARIABLE_META.items()
]
_VAR_COMBO_ITEMS_SET = [
(display, varname, vartype)
for display, (varname, vartype) in VARIABLE_META.items()
if not varname.startswith("CURRENT_")
]
OP_ITEMS = [
("等于", ".EQ."),
("不等于", ".NEQ."),
("大于", ".BGT."),
("小于", ".BLT."),
("大于等于", ".BGE."),
("小于等于", ".BLE."),
]
def _makeVarCombo(
) -> QComboBox:
cb = QComboBox()
for display, varname, vartype in _VAR_COMBO_ITEMS:
cb.addItem(display, (varname, vartype))
cb.setMinimumWidth(120)
cb.setFixedHeight(25)
return cb
def _makeSetVarCombo(
) -> QComboBox:
cb = QComboBox()
for display, varname, vartype in _VAR_COMBO_ITEMS_SET:
cb.addItem(display, (varname, vartype))
cb.setMinimumWidth(120)
cb.setFixedHeight(25)
return cb
def _makeOpCombo(
) -> QComboBox:
cb = QComboBox()
for display, op in OP_ITEMS:
cb.addItem(display, op)
cb.setMinimumWidth(80)
cb.setFixedHeight(25)
return cb
def _makeValueWidget(
data_type: str
) -> QWidget:
if data_type == "Time":
w = QTimeEdit()
w.setDisplayFormat("HH:mm")
w.setMinimumWidth(100)
w.setFixedHeight(25)
elif data_type == "Date":
w = QDateEdit()
w.setDisplayFormat("yyyy-MM-dd")
w.setCalendarPopup(True)
w.setMinimumWidth(130)
w.setFixedHeight(25)
elif data_type == "Integer":
w = QSpinBox()
w.setMinimum(-999999)
w.setMaximum(999999)
w.setMinimumWidth(100)
w.setFixedHeight(25)
elif data_type == "Float":
w = QDoubleSpinBox()
w.setMinimum(-999999)
w.setMaximum(999999)
w.setDecimals(2)
w.setMinimumWidth(100)
w.setFixedHeight(25)
elif data_type == "Boolean":
w = QComboBox()
w.addItem(".TRUE.", ".TRUE.")
w.addItem(".FALSE.", ".FALSE.")
w.setMinimumWidth(100)
w.setFixedHeight(25)
else:
w = QLineEdit()
w.setPlaceholderText("输入值")
w.setMinimumWidth(120)
w.setFixedHeight(25)
return w
def _makeActionValueWidget(
data_type: str
) -> QWidget:
if data_type == "Date":
w = QComboBox()
w.addItem("今天", "today")
w.addItem("明天", "tomorrow")
w.setFixedHeight(25)
w.setMinimumWidth(100)
w._is_date_action = True
return w
if data_type == "Time":
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(2)
modeCombo = QComboBox()
modeCombo.addItem("固定时间", "fixed")
modeCombo.addItem("相对当前", "relative")
modeCombo.setFixedHeight(25)
stack = QStackedWidget()
timeEdit = QTimeEdit()
timeEdit.setDisplayFormat("HH:mm")
timeEdit.setFixedHeight(25)
spinBox = QSpinBox()
spinBox.setRange(0, 23)
spinBox.setSuffix("小时")
spinBox.setFixedHeight(25)
stack.addWidget(timeEdit)
stack.addWidget(spinBox)
modeCombo.currentIndexChanged.connect(
lambda i: stack.setCurrentIndex(i)
)
layout.addWidget(modeCombo)
layout.addWidget(stack)
container._modeCombo = modeCombo
container._timeEdit = timeEdit
container._spinBox = spinBox
container._isActionTime = True
return container
return _makeValueWidget(data_type)
def _getValueFromWidget(
w: QWidget
) -> str:
if getattr(w, '_isActionTime', False):
if w._modeCombo.currentData() == "fixed":
return w._timeEdit.time().toString("HH:mm")
else:
return f"+{w._spinBox.value()}"
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.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 _encodeValueStr(
raw_value: str,
data_type: str
) -> str:
if data_type == "Time":
if raw_value.startswith("+"):
return raw_value
return f"TIME({raw_value})"
elif data_type == "Date":
if raw_value == "今天":
return "CURRENT_DATE"
elif raw_value == "明天":
return "CURRENT_DATE + 1"
return f"DATE({raw_value})"
elif data_type == "Boolean":
return raw_value
elif data_type == "String":
escaped = raw_value.replace("'", "''")
return f"'{escaped}'"
else:
return raw_value
def _setWidgetValue(
w: QWidget,
vartype: str,
expr: str
):
import re
s = expr.strip()
if getattr(w, '_isActionTime', False):
timeMatch = re.match(r"TIME\((\d{1,2}:\d{2})\)", s, re.IGNORECASE)
if timeMatch:
w._modeCombo.setCurrentIndex(0)
parts = timeMatch.group(1).split(":")
w._timeEdit.setTime(QTime(int(parts[0]), int(parts[1])))
return
relMatch = re.match(r"^\+(\d+)$", s)
if relMatch:
w._modeCombo.setCurrentIndex(1)
w._spinBox.setValue(int(relMatch.group(1)))
return
return
if getattr(w, '_is_date_action', False) and isinstance(w, QComboBox):
if s.upper() in ("CURRENT_DATE", "TODAY"):
w.setCurrentIndex(0)
elif s.upper() in ("CURRENT_DATE + 1", "TOMORROW"):
w.setCurrentIndex(1)
else:
dateMatch = re.match(
r"DATE\((\d{4}-\d{2}-\d{2})\)", s, re.IGNORECASE
)
if dateMatch:
from datetime import datetime, timedelta
dateStr = dateMatch.group(1)
today = datetime.now().strftime("%Y-%m-%d")
tomorrow = (
datetime.now() + timedelta(days=1)
).strftime("%Y-%m-%d")
if dateStr == today:
w.setCurrentIndex(0)
elif dateStr == tomorrow:
w.setCurrentIndex(1)
return
if vartype == "Time":
m = re.match(r"TIME\((\d{1,2}:\d{2})\)", s, re.IGNORECASE)
if m and isinstance(w, QTimeEdit):
parts = m.group(1).split(":")
w.setTime(QTime(int(parts[0]), int(parts[1])))
elif vartype == "Date":
m = re.match(r"DATE\((\d{4}-\d{2}-\d{2})\)", s, re.IGNORECASE)
if m and isinstance(w, QDateEdit):
parts = m.group(1).split("-")
w.setDate(QDate(int(parts[0]), int(parts[1]), int(parts[2])))
elif vartype == "Boolean" and isinstance(w, QComboBox):
for i in range(w.count()):
if w.itemData(i) == s.upper():
w.setCurrentIndex(i)
break
elif vartype == "Integer" and isinstance(w, QSpinBox):
try:
w.setValue(int(s))
except ValueError:
pass
elif vartype == "Float" and isinstance(w, QDoubleSpinBox):
try:
w.setValue(float(s))
except ValueError:
pass
elif isinstance(w, QLineEdit):
inner = s
if (inner.startswith("'") and inner.endswith("'")) or \
(inner.startswith('"') and inner.endswith('"')):
inner = inner[1:-1].replace("''", "'")
w.setText(inner)
class ActionStepFrame(QFrame):
def __init__(
self,
parent=None
):
super().__init__(parent)
self.setupUi()
self.connectSignals()
self.onTargetChanged(0)
def setupUi(
self
):
self.setFrameShape(QFrame.Shape.StyledPanel)
self.setFrameShadow(QFrame.Shadow.Raised)
self.setFixedHeight(35)
layout = QHBoxLayout(self)
layout.setContentsMargins(2, 2, 2, 2)
layout.setSpacing(4)
self.targetCombo = _makeSetVarCombo()
self.valueWidgetStack = QStackedWidget()
self.valueWidgetStack.setFixedHeight(25)
self.initValueStack()
setLabel = QLabel("设置")
setLabel.setFixedHeight(25)
layout.addWidget(setLabel)
layout.addWidget(self.targetCombo)
toLabel = QLabel("")
toLabel.setFixedHeight(25)
layout.addWidget(toLabel)
layout.addWidget(self.valueWidgetStack)
self.deleteBtn = QPushButton("×")
self.deleteBtn.setFixedSize(24, 25)
self.deleteBtn.setStyleSheet("color: red; font-weight: bold;")
layout.addWidget(self.deleteBtn)
def connectSignals(
self
):
self.targetCombo.currentIndexChanged.connect(self.onTargetChanged)
def initValueStack(
self
):
self._valueWidgets = {}
for _, _, vartype in _VAR_COMBO_ITEMS:
if vartype not in self._valueWidgets:
w = _makeActionValueWidget(vartype)
self._valueWidgets[vartype] = w
self.valueWidgetStack.addWidget(w)
self.valueWidgetStack.setCurrentWidget(
self._valueWidgets.get("String", self.valueWidgetStack.widget(0))
)
def getTarget(
self
) -> str:
data = self.targetCombo.currentData()
return data[0] if data else ""
def getTargetType(
self
) -> str:
data = self.targetCombo.currentData()
return data[1] if data else "String"
def getValueRaw(
self
) -> str:
currentType = self.getTargetType()
w = self._valueWidgets.get(currentType)
if w:
return _getValueFromWidget(w)
return ""
def toScriptLine(
self
) -> str:
target = self.getTarget()
if not target:
return ""
rawVal = self.getValueRaw()
targetType = self.getTargetType()
encoded = _encodeValueStr(rawVal, targetType)
if targetType == "Time" and rawVal.startswith("+"):
hours = rawVal[1:]
return f" {target} .ADD. {hours}"
return f" SET {target} = {encoded}"
def loadFromScript(
self,
targetVar: str,
valueExpr: str
):
for idx in range(self.targetCombo.count()):
data = self.targetCombo.itemData(idx)
if data and data[0] == targetVar:
self.targetCombo.setCurrentIndex(idx)
break
self.setValueFromExpr(valueExpr)
def setValueFromExpr(
self,
expr: str
):
targetType = self.getTargetType()
w = self._valueWidgets.get(targetType)
if not w:
return
_setWidgetValue(w, targetType, expr)
@Slot(int)
def onTargetChanged(
self,
idx
):
if idx < 0:
return
data = self.targetCombo.itemData(idx)
if data:
_, vartype = data
w = self._valueWidgets.get(vartype)
if w:
self.valueWidgetStack.setCurrentWidget(w)
class ConditionalBlock(QGroupBox):
def __init__(
self,
blockIndex: int,
parent=None
):
super().__init__(parent)
self.blockIndex = blockIndex
self._actionWidgets = []
self.setupUi()
self.connectSignals()
self.onOperandChanged(0)
def setupUi(
self
):
self.setStyleSheet(
"QGroupBox { font-weight: bold; border: 1px solid #ccc; "
"margin-top: 5px; padding-top: 5px; }"
)
self.setSizePolicy(
QSizePolicy.Policy.Preferred,
QSizePolicy.Policy.Fixed
)
mainLayout = QVBoxLayout(self)
mainLayout.setSpacing(4)
mainLayout.setContentsMargins(5, 5, 5, 5)
headerLayout = QHBoxLayout()
self.typeCombo = QComboBox()
self.typeCombo.addItem("IF", "IF")
self.typeCombo.addItem("ELSE IF", "ELSE IF")
self.typeCombo.addItem("ELSE", "ELSE")
if self.blockIndex == 0:
self.typeCombo.setEnabled(False)
typeLabel = QLabel("类型:")
typeLabel.setFixedHeight(25)
headerLayout.addWidget(typeLabel)
headerLayout.addWidget(self.typeCombo)
headerLayout.addStretch()
self.deleteBlockBtn = QPushButton("删除此块")
self.deleteBlockBtn.setStyleSheet("color: red;")
self.deleteBlockBtn.setFixedHeight(25)
headerLayout.addWidget(self.deleteBlockBtn)
mainLayout.addLayout(headerLayout)
self.conditionWidget = QWidget()
self.conditionWidget.setFixedHeight(60)
condLayout = QHBoxLayout(self.conditionWidget)
condLayout.setContentsMargins(0, 0, 0, 0)
ifLabel = QLabel("如果")
ifLabel.setFixedHeight(25)
condLayout.addWidget(ifLabel)
self.operandCombo = _makeVarCombo()
condLayout.addWidget(self.operandCombo)
self.opCombo = _makeOpCombo()
condLayout.addWidget(self.opCombo)
self.condValueStack = QStackedWidget()
self.condValueStack.setFixedHeight(25)
self._condValueWidgets = {}
for vartype in ["Time", "Date", "String", "Integer", "Float", "Boolean"]:
w = _makeValueWidget(vartype)
self._condValueWidgets[vartype] = w
self.condValueStack.addWidget(w)
self.condValueStack.setCurrentWidget(self._condValueWidgets.get("String"))
condLayout.addWidget(self.condValueStack)
mainLayout.addWidget(self.conditionWidget)
self.actionLabel = QLabel("执行步骤:")
self.actionLabel.setFixedHeight(25)
mainLayout.addWidget(self.actionLabel)
self.actionsLayout = QVBoxLayout()
self.actionsLayout.setSpacing(2)
mainLayout.addLayout(self.actionsLayout)
self.addActionBtn = QPushButton("+ 添加执行步骤")
self.addActionBtn.setFixedHeight(25)
mainLayout.addWidget(self.addActionBtn)
def connectSignals(
self
):
self.operandCombo.currentIndexChanged.connect(self.onOperandChanged)
self.typeCombo.currentIndexChanged.connect(self.onTypeChanged)
self.addActionBtn.clicked.connect(self.addActionStep)
def removeActionStep(
self,
step: ActionStepFrame
):
if step in self._actionWidgets:
self._actionWidgets.remove(step)
self.actionsLayout.removeWidget(step)
step.hide()
step.deleteLater()
def getBlockType(
self
) -> str:
return self.typeCombo.currentData()
def toScriptLines(
self
) -> list:
blockType = self.getBlockType()
lines = []
if blockType in ("IF", "ELSE IF"):
operand = self.operandCombo.currentData()
operandName = operand[0] if operand else ""
operandType = operand[1] if operand else "String"
opSym = self.opCombo.currentData()
rawVal = _getValueFromWidget(
self._condValueWidgets.get(operandType, QLineEdit())
)
encodedVal = _encodeValueStr(rawVal, operandType)
if blockType == "IF":
lines.append(f"IF({operandName} {opSym} {encodedVal}) THEN")
else:
lines.append(f"ELSE IF({operandName} {opSym} {encodedVal}) THEN")
else:
lines.append("ELSE")
for step in self._actionWidgets:
scriptLine = step.toScriptLine()
if scriptLine:
lines.append(scriptLine)
return lines
def getConditionSummary(
self
) -> str:
bt = self.getBlockType()
if bt == "ELSE":
return "ELSE"
operandData = self.operandCombo.currentData()
if not operandData:
return bt
operandDisplay = self.operandCombo.currentText()
opDisplay = self.opCombo.currentText()
rawVal = self.getConditionRawValuePreview()
return f"{bt} ({operandDisplay} {opDisplay} {rawVal})"
def getConditionRawValuePreview(
self
) -> str:
data = self.operandCombo.currentData()
if not data:
return ""
_, vartype = data
w = self._condValueWidgets.get(vartype)
if w:
return _getValueFromWidget(w)
return ""
def countActionSteps(
self
) -> int:
return len(self._actionWidgets)
@Slot(int)
def onOperandChanged(
self,
idx
):
if idx < 0:
return
data = self.operandCombo.itemData(idx)
if data:
_, vartype = data
w = self._condValueWidgets.get(vartype)
if w:
self.condValueStack.setCurrentWidget(w)
@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 执行步骤:")
@Slot()
def addActionStep(
self
):
step = ActionStepFrame(self)
step.deleteBtn.clicked.connect(lambda: self.removeActionStep(step))
self._actionWidgets.append(step)
self.actionsLayout.addWidget(step)
class ALAutoScriptOrchDialog(QDialog):
def __init__(
self,
parent=None,
existingScript: str = ""
):
super().__init__(parent)
self._blocks: list[ConditionalBlock] = []
self.modifyUi()
self.connectSignals()
if existingScript and existingScript.strip():
self.loadFromScript(existingScript)
else:
self.addBlock()
self._scrollLayout.addStretch()
def modifyUi(
self
):
self.setWindowTitle("AutoScript 指令编排 - AutoLibrary")
self.setMinimumSize(420, 400)
self.setModal(True)
mainLayout = QVBoxLayout(self)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scrollContent = QWidget()
self._scrollLayout = QVBoxLayout(scrollContent)
self._scrollLayout.setSpacing(5)
scroll.setWidget(scrollContent)
mainLayout.addWidget(scroll)
addBlockLayout = QHBoxLayout()
self.addBlockBtn = QPushButton("+ 添加判断块")
self.addBlockBtn.setFixedHeight(25)
addBlockLayout.addStretch()
addBlockLayout.addWidget(self.addBlockBtn)
addBlockLayout.addStretch()
mainLayout.addLayout(addBlockLayout)
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.accept)
self.btnBox.rejected.connect(self.reject)
self.addBlockBtn.clicked.connect(self.addBlock)
def removeBlock(
self,
block: ConditionalBlock
):
if block in self._blocks:
self._blocks.remove(block)
self._scrollLayout.removeWidget(block)
block.hide()
block.deleteLater()
def getScript(
self
) -> str:
parts = []
for i, block in enumerate(self._blocks):
blockType = block.getBlockType()
if blockType == "IF" and i > 0:
parts.append("ENDIF")
lines = block.toScriptLines()
parts.extend(lines)
if self._blocks and self._blocks[0].getBlockType() == "IF":
parts.append("ENDIF")
return "\n".join(parts)
def getScriptPreview(
self
) -> str:
s = self.getScript()
if len(s) > 10:
return s[:7] + "..."
return s
def loadFromScript(
self,
script: str
):
import re
lines = [l.strip() for l in script.split("\n") if l.strip()]
if not lines:
self.addBlock()
return
currentBlock = None
currentBlockType = None
actionsBuffer = []
def flushBlock():
nonlocal currentBlock, currentBlockType, actionsBuffer
if currentBlock is None:
return
typeIdxMap = {"IF": 0, "ELSE IF": 1, "ELSE": 2}
idx = typeIdxMap.get(currentBlockType, 0)
currentBlock.typeCombo.setCurrentIndex(idx)
currentBlock.onTypeChanged(idx)
for oldStep in list(currentBlock._actionWidgets):
currentBlock.removeActionStep(oldStep)
for target, valueExpr in actionsBuffer:
currentBlock.addActionStep()
step = currentBlock._actionWidgets[-1]
step.loadFromScript(target, valueExpr)
self._blocks.clear()
while self._scrollLayout.count():
item = self._scrollLayout.takeAt(0)
if item.widget():
item.widget().deleteLater()
for line in lines:
upper = line.upper()
ifMatch = re.match(r"^IF\((.+)\)\s*THEN\s*$", upper)
if ifMatch:
flushBlock()
currentBlockType = "IF"
actionsBuffer = []
self.addBlock()
currentBlock = self._blocks[-1]
self.parseConditionToBlock(currentBlock, ifMatch.group(1))
continue
elifIfMatch = re.match(r"^ELSE\s+IF\((.+)\)\s*THEN\s*$", upper)
if elifIfMatch:
flushBlock()
currentBlockType = "ELSE IF"
actionsBuffer = []
self.addBlock()
currentBlock = self._blocks[-1]
self.parseConditionToBlock(currentBlock, elifIfMatch.group(1))
continue
if upper == "ELSE":
flushBlock()
currentBlockType = "ELSE"
actionsBuffer = []
self.addBlock()
currentBlock = self._blocks[-1]
currentBlock.conditionWidget.setVisible(False)
continue
setMatch = re.match(r"^SET\s+(\w+)\s*=\s*(.+)$", line, re.IGNORECASE)
if setMatch:
target = setMatch.group(1).strip()
valueExpr = setMatch.group(2).strip()
actionsBuffer.append((target, valueExpr))
continue
addMatch = re.match(r"^(\w+)\s+\.ADD\.\s+(\d+)$", line, re.IGNORECASE)
if addMatch:
target = addMatch.group(1).strip()
hours = addMatch.group(2).strip()
actionsBuffer.append((target, f"+{hours}"))
continue
if upper in ("ENDIF", "END IF"):
flushBlock()
currentBlock = None
currentBlockType = None
actionsBuffer = []
continue
flushBlock()
if not self._blocks:
self.addBlock()
def parseConditionToBlock(
self,
block: ConditionalBlock,
condStr: str
):
condStr = condStr.strip()
for _, opSym in OP_ITEMS:
idx = condStr.upper().find(opSym)
if idx >= 0:
leftPart = condStr[:idx].strip()
rightPart = condStr[idx + len(opSym):].strip()
for ci in range(block.operandCombo.count()):
data = block.operandCombo.itemData(ci)
if data and data[0] == leftPart:
block.operandCombo.setCurrentIndex(ci)
break
for oi in range(block.opCombo.count()):
if block.opCombo.itemData(oi) == opSym:
block.opCombo.setCurrentIndex(oi)
break
opData = block.operandCombo.currentData()
vartype = opData[1] if opData else "String"
w = block._condValueWidgets.get(vartype)
if w:
_setWidgetValue(w, vartype, rightPart)
return
@Slot()
def addBlock(
self
):
block = ConditionalBlock(len(self._blocks), self)
block.deleteBlockBtn.clicked.connect(lambda: self.removeBlock(block))
self._blocks.append(block)
block.addActionStep()
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)
+226
View File
@@ -0,0 +1,226 @@
# -*- 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 Slot
from PySide6.QtGui import (
QSyntaxHighlighter, QTextCharFormat, QColor, QFont, QIcon
)
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QPlainTextEdit,
QDialogButtonBox, QPushButton, QLabel, QApplication, QStyle
)
class ALScriptHighlighter(QSyntaxHighlighter):
def __init__(
self,
parent=None
):
super().__init__(parent)
self._rules = []
keywordFmt = QTextCharFormat()
keywordFmt.setForeground(QColor("#316BFF"))
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))
literalFmt = QTextCharFormat()
literalFmt.setForeground(QColor("#C2185B"))
literalFmt.setFontWeight(QFont.Weight.Bold)
for lit in [".TRUE.", ".FALSE."]:
self._rules.append((r"\b" + lit.replace(".", r"\.") + r"\b", literalFmt))
opFmt = QTextCharFormat()
opFmt.setForeground(QColor("#9C27B0"))
for op in [r"\.EQ\.", r"\.NEQ\.", r"\.BGT\.", r"\.BLT\.",
r"\.BGE\.", r"\.BLE\.", r"\.ADD\.", r"\.SUB\."]:
self._rules.append((op, opFmt))
varFmt = QTextCharFormat()
varFmt.setForeground(QColor("#E65100"))
for var in ["RESERVE_BEGIN_TIME", "RESERVE_END_TIME",
"RESERVE_DATE", "USERNAME", "USER_ENABLE",
"PRIORITY", "CURRENT_TIME", "CURRENT_DATE"]:
self._rules.append((r"\b" + var + r"\b", varFmt))
funcFmt = QTextCharFormat()
funcFmt.setForeground(QColor("#2E7D32"))
self._rules.append((r"\bTIME\([^)]+\)", funcFmt))
self._rules.append((r"\bDATE\([^)]+\)", funcFmt))
strFmt = QTextCharFormat()
strFmt.setForeground(QColor("#388E3C"))
self._rules.append((r"'[^']*'", strFmt))
numFmt = QTextCharFormat()
numFmt.setForeground(QColor("#D32F2F"))
self._rules.append((r"\b\d+\b", numFmt))
commentFmt = QTextCharFormat()
commentFmt.setForeground(QColor("#999999"))
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 ALAutoScriptPreviewDialog(QDialog):
def __init__(
self,
parent=None,
script: str = ""
):
super().__init__(parent)
self.__fontSize = 13
self.modifyUi()
self.connectSignals()
self._textEdit.setPlainText(script)
self._highlighter = ALScriptHighlighter(
self._textEdit.document()
)
def modifyUi(
self
):
self.setWindowTitle("AutoScript 预览 - AutoLibrary")
self.setMinimumSize(520, 360)
layout = QVBoxLayout(self)
toolbarLayout = QHBoxLayout()
self._zoomInBtn = QPushButton("")
self._zoomInBtn.setFixedSize(30, 25)
self._zoomOutBtn = QPushButton("")
self._zoomOutBtn.setFixedSize(30, 25)
self._zoomResetBtn = QPushButton(
QApplication.style().standardIcon(
QStyle.StandardPixmap.SP_BrowserReload
), ""
)
self._zoomResetBtn.setFixedSize(30, 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(30, 25)
self._copyBtn.setToolTip("复制脚本")
toolbarLayout.addWidget(self._copyBtn)
layout.addLayout(toolbarLayout)
self._textEdit = QPlainTextEdit(self)
self._textEdit.setReadOnly(True)
self._textEdit.setLineWrapMode(
QPlainTextEdit.LineWrapMode.NoWrap
)
self._textEdit.setStyleSheet(
"QPlainTextEdit {"
" font-family: 'Courier New', 'Consolas', monospace;"
" font-size: 13px;"
"}"
)
layout.addWidget(self._textEdit)
self._btnBox = QDialogButtonBox(
QDialogButtonBox.StandardButton.Close
)
self._btnBox.button(
QDialogButtonBox.StandardButton.Close
).setText("关闭")
layout.addWidget(self._btnBox)
def connectSignals(
self
):
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 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)
))
+3 -2
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
@@ -24,6 +24,7 @@ import managers.config.ConfigManager as ConfigManager
from utils.JSONReader import JSONReader from utils.JSONReader import JSONReader
from utils.JSONWriter import JSONWriter from utils.JSONWriter import JSONWriter
from utils.ConfigUtils import ConfigUtils
from gui.resources.ui.Ui_ALConfigWidget import Ui_ALConfigWidget from gui.resources.ui.Ui_ALConfigWidget import Ui_ALConfigWidget
from gui.ALSeatMapSelectDialog import ALSeatMapSelectDialog from gui.ALSeatMapSelectDialog import ALSeatMapSelectDialog
@@ -43,7 +44,7 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
super().__init__(parent) super().__init__(parent)
self.__cfg_mgr = ConfigManager.instance() self.__cfg_mgr = ConfigManager.instance()
self.__config_paths = ConfigManager.getValidateAutomationConfigPaths() self.__config_paths = ConfigUtils.getAutomationConfigPaths()
self.__config_data = {"run": {}, "user": {}} self.__config_data = {"run": {}, "user": {}}
self.setupUi(self) self.setupUi(self)
+4 -6
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. 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 QTextCursor, QCloseEvent, QFont, QIcon, QDesktopServices
) )
import managers.config.ConfigManager as ConfigManager
from base.MsgBase import MsgBase from base.MsgBase import MsgBase
from utils.ConfigUtils import ConfigUtils
from gui.resources.ui.Ui_ALMainWindow import Ui_ALMainWindow from gui.resources.ui.Ui_ALMainWindow import Ui_ALMainWindow
from gui.resources import ALResource from gui.resources import ALResource
@@ -44,9 +43,8 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
MsgBase.__init__(self, queue.Queue(), queue.Queue()) MsgBase.__init__(self, queue.Queue(), queue.Queue())
QMainWindow.__init__(self) QMainWindow.__init__(self)
self.__cfg_mgr = ConfigManager.instance()
self.__timer_task_queue = queue.Queue() self.__timer_task_queue = queue.Queue()
self.__config_paths = ConfigManager.getValidateAutomationConfigPaths() self.__config_paths = ConfigUtils.getAutomationConfigPaths()
self.__alTimerTaskManageWidget = None self.__alTimerTaskManageWidget = None
self.__alConfigWidget = None self.__alConfigWidget = None
self.__auto_lib_thread = None self.__auto_lib_thread = None
@@ -300,7 +298,7 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.__alConfigWidget.configWidgetIsClosed.disconnect(self.onConfigWidgetClosed) self.__alConfigWidget.configWidgetIsClosed.disconnect(self.onConfigWidgetClosed)
self.__alConfigWidget.deleteLater() self.__alConfigWidget.deleteLater()
self.__alConfigWidget = None self.__alConfigWidget = None
self.__config_paths = ConfigManager.getValidateAutomationConfigPaths() self.__config_paths = ConfigUtils.getAutomationConfigPaths()
self.setControlButtons(True, None, None) self.setControlButtons(True, None, None)
self._showLog("配置窗口已关闭,配置文件路径已更新") self._showLog("配置窗口已关闭,配置文件路径已更新")
+91 -17
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. 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 base.MsgBase import MsgBase
from operators.AutoLib import AutoLib from operators.AutoLib import AutoLib
from utils.JSONReader import JSONReader from utils.JSONReader import JSONReader
from utils.AutoScriptEngine import AutoScriptEngine
class AutoLibWorker(MsgBase, QThread): class AutoLibWorker(MsgBase, QThread):
@@ -76,25 +77,28 @@ class AutoLibWorker(MsgBase, QThread):
f"正在加载配置文件, 运行配置文件路径: {self.__config_paths["run"]}", f"正在加载配置文件, 运行配置文件路径: {self.__config_paths["run"]}",
no_log=True no_log=True
) )
self.__run_config = JSONReader(self.__config_paths["run"]).data() self._run_config = JSONReader(self.__config_paths["run"]).data()
self._showTrace( self._showTrace(
f"正在加载配置文件, 用户配置文件路径: {self.__config_paths["user"]}", f"正在加载配置文件, 用户配置文件路径: {self.__config_paths["user"]}",
no_log=True no_log=True
) )
self.__user_config = JSONReader(self.__config_paths["user"]).data() self._user_config = JSONReader(self.__config_paths["user"]).data()
if self.__run_config is None or self.__user_config is None: if self._run_config is None or self._user_config is None:
self._showTrace( self._showTrace(
"配置文件加载失败, 请检查配置文件是否正确", "配置文件加载失败, 请检查配置文件是否正确",
self.TraceLevel.ERROR self.TraceLevel.ERROR
) )
return False return False
if not self.__user_config.get("groups"): if not self._user_config.get("groups"):
self._showTrace( self._showTrace(
"用户配置文件中无有效任务组, 请检查用户配置文件是否正确", "用户配置文件中无有效任务组, 请检查用户配置文件是否正确",
self.TraceLevel.WARNING self.TraceLevel.WARNING
) )
return False 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 return True
@@ -115,9 +119,9 @@ class AutoLibWorker(MsgBase, QThread):
auto_lib = AutoLib( auto_lib = AutoLib(
self._input_queue, self._input_queue,
self._output_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: for group in groups:
if not group["enabled"]: if not group["enabled"]:
self._showTrace(f"任务组 {group["name"]} 已跳过", no_log=True) self._showTrace(f"任务组 {group["name"]} 已跳过", no_log=True)
@@ -157,12 +161,90 @@ class TimerTaskWorker(AutoLibWorker):
self.autoLibWorkerIsFinished.connect(self.onTimerTaskIsFinished) self.autoLibWorkerIsFinished.connect(self.onTimerTaskIsFinished)
self.autoLibWorkerFinishedWithError.connect(self.onTimerTaskFinishedWithError) self.autoLibWorkerFinishedWithError.connect(self.onTimerTaskFinishedWithError)
def run( def run(
self self
): ):
self._showTrace(f"定时任务 {self.__timer_task['name']} 开始运行") 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:
AutoScriptEngine.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() @Slot()
def onTimerTaskFinishedWithError( def onTimerTaskFinishedWithError(
@@ -174,11 +256,3 @@ class TimerTaskWorker(AutoLibWorker):
self.TraceLevel.ERROR self.TraceLevel.ERROR
) )
self.timerTaskWorkerIsFinished.emit(True, self.__timer_task) 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)
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2026 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2026 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. 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 enum import Enum
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
+162 -19
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
@@ -12,11 +12,13 @@ import uuid
from enum import Enum from enum import Enum
from datetime import datetime, timedelta from datetime import datetime, timedelta
from PySide6.QtCore import Slot, QDateTime from PySide6.QtCore import Slot, QDateTime, QUrl
from PySide6.QtWidgets import QLabel, QDialog, QWidget, QSpinBox, QHBoxLayout, QGridLayout, QDateTimeEdit 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 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): class ALTimerTaskStatus(Enum):
@@ -34,15 +36,20 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
def __init__( def __init__(
self, self,
parent = None parent = None,
timer_task: dict = None
): ):
super().__init__(parent) super().__init__(parent)
self.__edit_timer_task = timer_task
self.setupUi(self) self.setupUi(self)
self.modifyUi() self.modifyUi()
self.connectSignals() self.connectSignals()
if self.__edit_timer_task:
self.loadTask(self.__edit_timer_task)
def modifyUi( def modifyUi(
self self
@@ -86,6 +93,86 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
self.TimerConfigLayout.addWidget(self.RelativeTimerWidget) self.TimerConfigLayout.addWidget(self.RelativeTimerWidget)
self.RelativeTimerWidget.setVisible(False) 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)
self.AutoScriptPreviewButton.setEnabled(False)
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.AutoScriptPreviewButton.setEnabled(True)
self.ConfirmButton.setText("保存")
def connectSignals( def connectSignals(
self self
@@ -95,6 +182,9 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
self.ConfirmButton.clicked.connect(self.accept) self.ConfirmButton.clicked.connect(self.accept)
self.TimerTypeComboBox.currentIndexChanged.connect(self.onTimerTypeComboBoxIndexChanged) self.TimerTypeComboBox.currentIndexChanged.connect(self.onTimerTypeComboBoxIndexChanged)
self.RepeatCheckBox.toggled.connect(self.onRepeatCheckBoxToggled) 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( def getTimerTask(
@@ -119,18 +209,34 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
minutes = self.RelativeMinuteSpinBox.value(), minutes = self.RelativeMinuteSpinBox.value(),
seconds = self.RelativeSecondSpinBox.value() seconds = self.RelativeSecondSpinBox.value()
) )
task_data = {
"name": name, if self.__edit_timer_task:
"uuid": uuid.uuid4().hex.upper() + f"-{added_time.strftime("%Y%m%d%H%M%S")}", task_data = dict(self.__edit_timer_task)
"time_type": self.TimerTypeComboBox.currentText(), task_data["name"] = name
"execute_time": execute_time, task_data["execute_time"] = execute_time
"silent": silent, task_data["silent"] = silent
"added_time": added_time, task_data["status"] = ALTimerTaskStatus.PENDING
"status": ALTimerTaskStatus.PENDING, task_data["executed"] = False
"executed": False, task_data["repeat_auto_script"] = self.__auto_script
"repeat": self.RepeatCheckBox.isChecked(), else:
} task_data = {
if task_data["repeat"]: "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 = [] repeat_days = []
if self.MonCheckBox.isChecked(): if self.MonCheckBox.isChecked():
repeat_days.append(0) repeat_days.append(0)
@@ -152,7 +258,7 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
task_data["repeat_hour"] = execute_time.hour task_data["repeat_hour"] = execute_time.hour
task_data["repeat_minute"] = execute_time.minute task_data["repeat_minute"] = execute_time.minute
task_data["repeat_second"] = execute_time.second 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_days"],
task_data["repeat_hour"], task_data["repeat_hour"],
task_data["repeat_minute"], task_data["repeat_minute"],
@@ -181,4 +287,41 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
self.ThuCheckBox.setEnabled(checked) self.ThuCheckBox.setEnabled(checked)
self.FriCheckBox.setEnabled(checked) self.FriCheckBox.setEnabled(checked)
self.SatCheckBox.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;")
self.AutoScriptPreviewButton.setEnabled(True)
else:
self.AutoScriptStatusLabel.setText("未设置")
self.AutoScriptStatusLabel.setStyleSheet("color: #969696;")
self.AutoScriptPreviewButton.setEnabled(False)
dlg.deleteLater()
@Slot()
def onPreviewAutoScript(self):
if not self.__auto_script:
return
from gui.ALAutoScriptPrevDialog import ALAutoScriptPreviewDialog
dlg = ALAutoScriptPreviewDialog(self, self.__auto_script)
dlg.exec()
dlg.deleteLater()
@Slot()
def onAutoScriptHelp(
self
):
QDesktopServices.openUrl(
QUrl("https://www.autolibrary.kenanzhu.com/manuals/autoscript")
)
+9 -9
View File
@@ -30,7 +30,7 @@ class ALTimerTaskHistoryDialog(QDialog):
super().__init__(parent) super().__init__(parent)
self.__task_data = task_data self.__task_data = task_data
self.__history = task_data.get("history", []) self.__history = task_data.get("repeat_history", [])
self.modifyUi() self.modifyUi()
self.connectSignals() self.connectSignals()
@@ -130,6 +130,13 @@ class ALTimerTaskHistoryDialog(QDialog):
self.HistoryTableWidget.setItem(row, 2, DurationItem) self.HistoryTableWidget.setItem(row, 2, DurationItem)
self.HistoryTableWidget.setRowHeight(row, 25) self.HistoryTableWidget.setRowHeight(row, 25)
def getHistory(
self
) -> list:
return self.__history
@Slot() @Slot()
def onClearHistoryButtonClicked( def onClearHistoryButtonClicked(
self self
@@ -137,11 +144,4 @@ class ALTimerTaskHistoryDialog(QDialog):
self.__history.clear() self.__history.clear()
self.HistoryTableWidget.setRowCount(0) self.HistoryTableWidget.setRowCount(0)
self.__task_data["history"] = self.__history self.__task_data["repeat_history"] = self.__history
def getHistory(
self
) -> list:
return self.__history
+62 -16
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
@@ -19,14 +19,14 @@ from PySide6.QtCore import (
) )
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QWidget, QListWidgetItem, QMessageBox, QDialog, QWidget, QListWidgetItem, QMessageBox,
QHBoxLayout, QVBoxLayout, QLabel, QPushButton QHBoxLayout, QVBoxLayout, QLabel, QPushButton, QMenu
) )
from PySide6.QtGui import ( from PySide6.QtGui import (
QCloseEvent QCloseEvent, QAction
) )
import managers.config.ConfigManager as ConfigManager import managers.config.ConfigManager as ConfigManager
import utils.TimerUtils as TimerUtils from utils.TimerUtils import TimerUtils
from gui.resources.ui.Ui_ALTimerTaskManageWidget import Ui_ALTimerTaskManageWidget from gui.resources.ui.Ui_ALTimerTaskManageWidget import Ui_ALTimerTaskManageWidget
from gui.ALTimerTaskAddDialog import ALTimerTaskAddDialog, ALTimerTaskStatus from gui.ALTimerTaskAddDialog import ALTimerTaskAddDialog, ALTimerTaskStatus
@@ -35,6 +35,8 @@ from gui.ALTimerTaskHistoryDialog import ALTimerTaskHistoryDialog
class ALTimerTaskItemWidget(QWidget): class ALTimerTaskItemWidget(QWidget):
editRequested = Signal(dict)
def __init__( def __init__(
self, self,
parent = None, parent = None,
@@ -43,8 +45,11 @@ class ALTimerTaskItemWidget(QWidget):
super().__init__(parent) super().__init__(parent)
self.__timer_task = timer_task self.__timer_task = timer_task
self.__manage_widget = parent
self.modifyUi() self.modifyUi()
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self.showContextMenu)
def modifyUi( def modifyUi(
@@ -145,6 +150,27 @@ class ALTimerTaskItemWidget(QWidget):
self.DeleteButton.setEnabled(False) self.DeleteButton.setEnabled(False)
self.setFixedHeight(55) 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): class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
@@ -224,8 +250,8 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
task["added_time"] = datetime.strptime(task["added_time"], "%Y-%m-%d %H:%M:%S") 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["execute_time"] = datetime.strptime(task["execute_time"], "%Y-%m-%d %H:%M:%S")
task["status"] = ALTimerTaskStatus(task["status"]) task["status"] = ALTimerTaskStatus(task["status"])
if "history" in task: if "repeat_history" in task:
for item in task["history"]: for item in task["repeat_history"]:
item["result"] = ALTimerTaskStatus(item["result"]) item["result"] = ALTimerTaskStatus(item["result"])
return timer_tasks["timer_tasks"] return timer_tasks["timer_tasks"]
raise Exception("定时任务配置文件格式错误") raise Exception("定时任务配置文件格式错误")
@@ -248,8 +274,8 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
task["added_time"] = task["added_time"].strftime("%Y-%m-%d %H:%M:%S") 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["execute_time"] = task["execute_time"].strftime("%Y-%m-%d %H:%M:%S")
task["status"] = task["status"].value task["status"] = task["status"].value
if "history" in task: if "repeat_history" in task:
for item in task["history"]: for item in task["repeat_history"]:
item["result"] = item["result"].value item["result"] = item["result"].value
self.__cfg_mgr.set(ConfigManager.ConfigType.TIMERTASK, "", { "timer_tasks": timer_tasks }) self.__cfg_mgr.set(ConfigManager.ConfigType.TIMERTASK, "", { "timer_tasks": timer_tasks })
return True return True
@@ -363,6 +389,7 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
widget.HistoryButton.clicked.connect( widget.HistoryButton.clicked.connect(
lambda _, task = timer_task: self.showTaskHistory(task) lambda _, task = timer_task: self.showTaskHistory(task)
) )
widget.editRequested.connect(self.editTask)
item.setSizeHint(widget.size()) item.setSizeHint(widget.size())
self.TimerTasksListWidget.addItem(item) self.TimerTasksListWidget.addItem(item)
self.TimerTasksListWidget.setItemWidget(item, widget) self.TimerTasksListWidget.setItemWidget(item, widget)
@@ -378,19 +405,39 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
self.__timer_tasks.append(timer_task) self.__timer_tasks.append(timer_task)
self.timerTasksChanged.emit() 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 @staticmethod
def getTimerTaskDetailMessage( def getTimerTaskDetailMessage(
timer_task: dict timer_task: dict
): ):
if "repeat_history" not in timer_task:
history = []
else:
history = timer_task["repeat_history"]
history_count = len(history)
return ( return (
f"任务名称:{timer_task["name"]}\n" f"任务名称:{timer_task["name"]}\n"
f"添加时间:{timer_task["added_time"]}\n" f"添加时间:{timer_task["added_time"]}\n"
f"当前状态:{timer_task["status"].value}\n" f"当前状态:{timer_task["status"].value}\n"
f"下次执行时间:{datetime.strftime(timer_task["execute_time"], "%Y-%m-%d %H:%M:%S")}\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}"
) )
def deleteTask( def deleteTask(
self, self,
@@ -554,7 +601,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
self.updateTimerTaskList() self.updateTimerTaskList()
self.updateStat() self.updateStat()
@Slot(dict) @Slot(dict)
def onTimerTaskIsRunning( def onTimerTaskIsRunning(
self, self,
@@ -579,12 +625,12 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
ALTimerTaskStatus.OUTDATED} ALTimerTaskStatus.OUTDATED}
if status not in valid_statuses: if status not in valid_statuses:
return timer_task return timer_task
if "history" not in timer_task: if "repeat_history" not in timer_task:
timer_task["history"] = [] timer_task["repeat_history"] = []
if status != ALTimerTaskStatus.OUTDATED: if status != ALTimerTaskStatus.OUTDATED:
executed_time = datetime.now() executed_time = datetime.now()
duration = (executed_time - timer_task["execute_time"]).total_seconds() 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"), "execute_time": timer_task["execute_time"].strftime("%Y-%m-%d %H:%M:%S"),
"executed_time": executed_time.strftime("%Y-%m-%d %H:%M:%S"), "executed_time": executed_time.strftime("%Y-%m-%d %H:%M:%S"),
"result": status, "result": status,
@@ -598,14 +644,14 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
delta_days = (current_time - execute_time).days delta_days = (current_time - execute_time).days
for i in range(delta_days + 1): for i in range(delta_days + 1):
if (execute_weekday + i)%7 in timer_task["repeat_days"]: 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"), "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"), "executed_time": (execute_time + timedelta(days=i)).strftime("%Y-%m-%d %H:%M:%S"),
"result": status, "result": status,
"duration": 0, "duration": 0,
"uuid": timer_task["uuid"] "uuid": timer_task["uuid"]
}) })
next_time = TimerUtils.calculateNextRepeatTime( next_time = TimerUtils.getNextTimerRepeatTime(
timer_task["repeat_days"], timer_task["repeat_days"],
timer_task["repeat_hour"], timer_task["repeat_hour"],
timer_task["repeat_minute"], timer_task["repeat_minute"],
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. 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. workflow process. Do not edit manually.
This file is auto-generated during the workflow process. 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_VERSION = "1.3.0"
AL_TAG = "v1.2.0" AL_TAG = "v1.3.0"
AL_COMMIT_SHA = "local" AL_COMMIT_SHA = "local"
AL_COMMIT_DATE = "null" # time zone : UTC AL_COMMIT_DATE = "null" # time zone : UTC
AL_BUILD_DATE = "null" # time zone : UTC AL_BUILD_DATE = "null" # time zone : UTC
+1
View File
@@ -10,6 +10,7 @@
- ALSeatMapTable: Seat map table class. - ALSeatMapTable: Seat map table class.
- ALSeatMapSelectDialog: Seat map select dialog class. - ALSeatMapSelectDialog: Seat map select dialog class.
- ALTimerTaskAddDialog: Timer task add dialog class. - ALTimerTaskAddDialog: Timer task add dialog class.
- ALAutoScriptOrchDialog: AutoScript orchestration dialog class.
- ALTimerTaskHistoryDialog: Timer task history dialog class. - ALTimerTaskHistoryDialog: Timer task history dialog class.
- ALTimerTaskManageWidget: Timer task manage widget class. - ALTimerTaskManageWidget: Timer task manage widget class.
- ALUserTreeWidget: User tree widget class. - ALUserTreeWidget: User tree widget class.
+1 -1
View File
@@ -13,7 +13,7 @@
<property name="minimumSize"> <property name="minimumSize">
<size> <size>
<width>350</width> <width>350</width>
<height>400</height> <height>460</height>
</size> </size>
</property> </property>
<property name="maximumSize"> <property name="maximumSize">
+2 -44
View File
@@ -176,49 +176,7 @@ class ConfigManager:
# ConfigManager singleton instance. # ConfigManager singleton instance.
_config_manager_instance = None _config_manager_instance : ConfigManager | None = 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()
# Singleton instance of ConfigManager. # Singleton instance of ConfigManager.
_instance_lock = threading.Lock() _instance_lock = threading.Lock()
@@ -240,6 +198,6 @@ def instance(
else: else:
if config_dir == "": if config_dir == "":
return _config_manager_instance return _config_manager_instance
if getBaseConfigDir() != config_dir: if _config_manager_instance.configDir() != config_dir:
raise ValueError("ConfigManager 的实例已初始化,不能使用不同的配置目录。") raise ValueError("ConfigManager 的实例已初始化,不能使用不同的配置目录。")
return _config_manager_instance return _config_manager_instance
+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 platform
import installed_browsers import browsers
from pathlib import Path from pathlib import Path
from enum import Enum from enum import Enum
@@ -128,7 +137,7 @@ class WebBrowserDetector:
self.browser_infos = [] self.browser_infos = []
try: try:
all_browsers = installed_browsers.browsers() all_browsers = list(browsers.browsers())
except Exception as e: except Exception as e:
self.browser_infos = [] self.browser_infos = []
return self.browser_infos return self.browser_infos
@@ -140,14 +149,14 @@ class WebBrowserDetector:
'msedge': WebBrowserType.EDGE, 'msedge': WebBrowserType.EDGE,
} }
for browser in all_browsers: 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: if internal_name not in type_map:
continue # Not one of the browsers we care about continue # Not one of the browsers we care about
version = browser.get('version') version = browser.get("version", "")
if not version: if not version:
# Skip browsers with no version info (unlikely, but defensive) # Skip browsers with no version info (unlikely, but defensive)
continue continue
exe_path = browser.get('location') exe_path = browser.get("path", "")
if not exe_path: if not exe_path:
continue continue
try: try:
@@ -163,4 +172,13 @@ class WebBrowserDetector:
browser_path=path, browser_path=path,
) )
self.browser_infos.append(info) 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 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 os
import time import time
import shutil import shutil
+1 -1
View File
@@ -186,7 +186,7 @@ def instance(
raise ValueError("LogManager 的实例已初始化, 不能使用不同的日志目录") raise ValueError("LogManager 的实例已初始化, 不能使用不同的日志目录")
return _log_manager_instance return _log_manager_instance
# export function to get logger
def getLogger( def getLogger(
name: Optional[str] = None name: Optional[str] = None
) -> logging.Logger: ) -> logging.Logger:
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+386
View File
@@ -0,0 +1,386 @@
# -*- 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
class AutoScriptEngine:
"""
AutoScript script engine.
Parses and executes AutoScript — a lightweight scripting DSL
used in repeatable timer tasks to preprocess user reservation
data before automation runs.
Supports IF/ELSE IF/ELSE/END IF control flow, SET assignments,
.ADD./.SUB. operations on Date/Time fields, and rich comparison
operators (.EQ. .NEQ. .BGT. .BLT. .BGE. .BLE.).
Examples:
>>> engine = AutoScriptEngine
>>> user = {
... "username": "test",
... "enabled": True,
... "reserve_info": {"date": "2026-05-07"}
... }
>>> engine.execute(
... 'IF(CURRENT_TIME .BGT. TIME(19:00))\\n'
... ' RESERVE_DATE .ADD. 1\\n'
... 'END IF',
... user
... )
"""
COMPARE_OPS = { # compare operators
".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,
}
VARIABLE_META = { # variable metadata
"预约开始时间": ("RESERVE_BEGIN_TIME", "Time"),
"预约结束时间": ("RESERVE_END_TIME", "Time"),
"预约日期": ("RESERVE_DATE", "Date"),
"用户名": ("USERNAME", "String"),
"用户启用": ("USER_ENABLE", "Boolean"),
"当前时间": ("CURRENT_TIME", "Time"),
"当前日期": ("CURRENT_DATE", "Date"),
}
_FIELD_TYPE_MAP = {meta[0]: meta[1] for meta in VARIABLE_META.values()}
@staticmethod
def execute(
script_text: str,
user_data: dict
):
"""
Execute an AutoScript against the given user data.
The script is parsed line-by-line. All modifications are
applied directly to ``user_data`` in-place.
Args:
script_text (str): Raw AutoScript source code.
user_data (dict): User data dictionary to read from and
write to. Must conform to the standard user profile
structure (username, enabled, reserve_info, etc.).
Raises:
ValueError: On any syntax or type error encountered
during parsing or execution.
"""
if not script_text or not script_text.strip():
return
lines = [l.strip() for l in script_text.split("\n") if l.strip()]
if not lines:
return
if_stack = []
for line in lines:
upper_line = line.upper().strip()
if upper_line.startswith("IF("):
cond_end = _findConditionEnd(upper_line)
if cond_end < 0:
raise ValueError("AutoScript 语法错误: IF 缺少右括号")
condition_str = line[3:cond_end].strip()
matched = AutoScriptEngine._evaluateCondition(
condition_str, user_data
)
if_stack.append([matched, matched])
elif upper_line.startswith("ELSE IF("):
if not if_stack:
raise ValueError("AutoScript 语法错误: ELSE IF 前缺少 IF")
cond_end = _findConditionEnd(upper_line)
if cond_end < 0:
raise ValueError("AutoScript 语法错误: ELSE IF 缺少右括号")
condition_str = line[8:cond_end].strip()
_, has_matched = if_stack[-1]
if not has_matched:
matched = AutoScriptEngine._evaluateCondition(
condition_str, user_data
)
if_stack[-1] = [matched, matched]
else:
if_stack[-1][0] = False
elif upper_line == "ELSE":
if not if_stack:
raise ValueError("AutoScript 语法错误: ELSE 前缺少 IF")
_, has_matched = if_stack[-1]
if not has_matched:
if_stack[-1] = [True, True]
else:
if_stack[-1][0] = False
elif upper_line in ("ENDIF", "END IF"):
if not if_stack:
raise ValueError("AutoScript 语法错误: ENDIF/END IF 前缺少 IF")
if_stack.pop()
elif upper_line.startswith("SET "):
should_execute = (
all(ctx[0] for ctx in if_stack) if if_stack else True
)
if should_execute:
AutoScriptEngine._executeSet(line, user_data)
elif upper_line == "PASS":
continue
else:
should_execute = (
all(ctx[0] for ctx in if_stack) if if_stack else True
)
if should_execute:
AutoScriptEngine._executeOperation(line, user_data)
if if_stack:
raise ValueError("AutoScript 语法错误: IF 与 ENDIF/END IF 不匹配")
@staticmethod
def _resolveField(
field_name: str,
user_data: dict
):
upper_name = field_name.upper().strip()
if upper_name == "CURRENT_DATE":
return datetime.now().strftime("%Y-%m-%d")
elif upper_name == "CURRENT_TIME":
return datetime.now().strftime("%H:%M")
elif upper_name == "USERNAME":
return user_data.get("username", "")
elif upper_name == "USER_ENABLE":
return user_data.get("enabled", False)
elif upper_name == "RESERVE_DATE":
return user_data.get("reserve_info", {}).get("date", "")
elif upper_name == "RESERVE_BEGIN_TIME":
return (
user_data
.get("reserve_info", {})
.get("begin_time", {})
.get("time", "")
)
elif upper_name == "RESERVE_END_TIME":
return (
user_data
.get("reserve_info", {})
.get("end_time", {})
.get("time", "")
)
return ""
@staticmethod
def _resolveValue(
value_str: str,
user_data: dict
):
s = value_str.strip()
time_match = re.match(r"^TIME\((\d{1,2}):(\d{2})\)$", s, re.IGNORECASE)
if time_match:
h, m = time_match.group(1), time_match.group(2)
return f"{int(h):02d}:{int(m):02d}"
date_match = re.match(r"^DATE\((\d{4})-(\d{2})-(\d{2})\)$", s, re.IGNORECASE)
if date_match:
y, mo, d = date_match.group(1), date_match.group(2), date_match.group(3)
return f"{int(y):04d}-{int(mo):02d}-{int(d):02d}"
if s.upper() == ".TRUE.":
return True
if s.upper() == ".FALSE.":
return False
if s.startswith("'") and s.endswith("'"):
inner = s[1:-1].replace("''", "'")
return inner
if s.startswith('"') and s.endswith('"'):
return s[1:-1]
relDate = re.match(r"^CURRENT_DATE\s*\+\s*(\d+)$", s, re.IGNORECASE)
if relDate:
days = int(relDate.group(1))
return (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d")
relTime = re.match(r"^CURRENT_TIME\s*\+\s*(\d+)$", s, re.IGNORECASE)
if relTime:
hours = int(relTime.group(1))
return (datetime.now() + timedelta(hours=hours)).strftime("%H:%M")
try:
return int(s)
except ValueError:
pass
try:
return float(s)
except ValueError:
pass
resolved = AutoScriptEngine._resolveField(s, user_data)
return resolved
@staticmethod
def _setField(
field_name: str,
value: str,
user_data: dict
):
upper_name = field_name.upper().strip()
if upper_name == "RESERVE_DATE":
user_data.setdefault("reserve_info", {})["date"] = value
elif upper_name == "RESERVE_BEGIN_TIME":
ri = user_data.setdefault("reserve_info", {})
ri.setdefault("begin_time", {})["time"] = value
elif upper_name == "RESERVE_END_TIME":
ri = user_data.setdefault("reserve_info", {})
ri.setdefault("end_time", {})["time"] = value
elif upper_name == "USERNAME":
user_data["username"] = value
elif upper_name == "USER_ENABLE":
if isinstance(value, bool):
user_data["enabled"] = value
else:
user_data["enabled"] = (str(value).upper() == "TRUE")
@staticmethod
def _evaluateCondition(
condition_str: str,
user_data: dict
) -> bool:
for op, cmp_func in AutoScriptEngine.COMPARE_OPS.items():
if op not in condition_str.upper():
continue
idx = condition_str.upper().find(op)
parts = [condition_str[:idx], condition_str[idx + len(op):]]
if len(parts) != 2:
continue
field_name = parts[0].strip()
value_str = parts[1].strip()
left_val = AutoScriptEngine._resolveField(field_name, user_data)
right_val = AutoScriptEngine._resolveValue(value_str, user_data)
try:
return cmp_func(left_val, right_val)
except TypeError:
raise ValueError(
f"AutoScript 语法错误: 无法比较 "
f"'{field_name}' ({type(left_val).__name__}) "
f"'{value_str}' ({type(right_val).__name__})"
)
return False
@staticmethod
def _executeSet(
line: str,
user_data: dict
):
rest = line[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 = AutoScriptEngine._resolveValue(value_str, user_data)
AutoScriptEngine._setField(field_name, resolved, user_data)
@staticmethod
def _executeOperation(
line: str,
user_data: dict
):
parts = line.split()
if len(parts) < 3:
return
field_name = parts[0].upper().strip()
op = parts[1].upper().strip()
raw_value = parts[2].strip()
field_type = AutoScriptEngine._FIELD_TYPE_MAP.get(field_name)
if not field_type:
raise ValueError(
f"AutoScript 语法错误: 未知字段 '{field_name}'"
)
try:
num_value = float(raw_value) if "." in raw_value else int(raw_value)
except (ValueError, TypeError):
raise ValueError(
f"AutoScript 语法错误: 无效操作数 '{raw_value}'"
)
if field_type == "Date":
date_str = AutoScriptEngine._resolveField(field_name, user_data)
if not date_str:
return
try:
date_obj = datetime.strptime(date_str, "%Y-%m-%d")
except (ValueError, TypeError):
return
if op == ".ADD.":
date_obj += timedelta(days=num_value)
elif op == ".SUB.":
date_obj -= timedelta(days=num_value)
else:
raise ValueError(
f"AutoScript 语法错误: Date 类型不支持操作 '{op}'"
)
AutoScriptEngine._setField(
field_name, date_obj.strftime("%Y-%m-%d"), user_data
)
elif field_type == "Time":
time_str = AutoScriptEngine._resolveField(field_name, user_data)
if not time_str:
return
try:
time_obj = datetime.strptime(time_str, "%H:%M")
except (ValueError, TypeError):
return
if op == ".ADD.":
time_obj += timedelta(hours=num_value)
elif op == ".SUB.":
time_obj -= timedelta(hours=num_value)
else:
raise ValueError(
f"AutoScript 语法错误: Time 类型不支持操作 '{op}'"
)
AutoScriptEngine._setField(
field_name, time_obj.strftime("%H:%M"), user_data
)
elif field_type in ("String", "Boolean"):
raise ValueError(
f"AutoScript 语法错误: '{field_type}' 类型字段不支持操作运算"
)
else:
raise ValueError(
f"AutoScript 语法错误: 未知字段类型 '{field_type}'"
)
def _findConditionEnd(
upper_line: str
) -> int:
"""
Find the index of the closing parenthesis that matches the
opening parenthesis in a condition expression, handling nested
parentheses and optional ``THEN`` keyword.
Args:
upper_line (str): The uppercased line text containing the
condition, e.g. ``"IF(A .BGT. B) THEN"``.
Returns:
int: Index of the matching ``)``, or ``-1`` if no match
is found.
"""
line = upper_line.rstrip()
if line.endswith(" THEN"):
line = line[:-5].rstrip()
paren_depth = 0
start_found = False
for i, ch in enumerate(line):
if ch == "(":
paren_depth += 1
start_found = True
elif ch == ")":
paren_depth -= 1
if start_found and paren_depth == 0:
return i
return -1
+46
View File
@@ -0,0 +1,46 @@
# -*- 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
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(ConfigManager.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(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(ConfigManager.ConfigType.GLOBAL, "automation", auto_config)
return config_paths
+42 -36
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2026 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. 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 from datetime import datetime, timedelta
def calculateNextRepeatTime( class TimerUtils:
repeat_days: list,
hour: int,
minute: int,
second: int
) -> datetime:
""" """
Calculate the next repeat time based on repeat days and target time. Timer utilities class.
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.
""" """
current_time = datetime.now() @staticmethod
current_weekday = current_time.weekday() def getNextTimerRepeatTime(
target_time = current_time.replace(hour=hour, minute=minute, second=second, microsecond=0) repeat_days: list[int],
if current_weekday in repeat_days: hour: int,
if target_time > current_time: minute: int,
return target_time second: int
repeat_days_sorted = sorted(repeat_days) ) -> datetime:
for day in repeat_days_sorted: """
if day > current_weekday: Calculate the next repeat time based on repeat days and target time.
days_until = day - current_weekday
next_time = target_time + timedelta(days=days_until) This function calculates the next execution time for a repeatable task.
return next_time If the current day is in repeat_days and the target time has not passed,
days_until = 7 - current_weekday + repeat_days_sorted[0] it returns today's target time. Otherwise, it finds the next matching day.
next_time = target_time + timedelta(days=days_until)
return next_time 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
+2
View File
@@ -5,4 +5,6 @@
- TimerUtils: Timer utils class for the AutoLibrary project. - TimerUtils: Timer utils class for the AutoLibrary project.
- JSONReader: JSON reader class for the AutoLibrary project. - JSONReader: JSON reader class for the AutoLibrary project.
- JSONWriter: JSON writer class for the AutoLibrary project. - JSONWriter: JSON writer class for the AutoLibrary project.
- ConfigUtils: Config utils class for the AutoLibrary project.
- AutoScriptEngine: AutoScript script engine class for the AutoLibrary project.
""" """