This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
"""Schedule Task — multi-format importer.
|
||||
|
||||
Import tasks from several input file formats, all producing the same task dicts
|
||||
the Excel importer returns (previewed, then saved on confirm):
|
||||
|
||||
* .xlsx / .xls → the fill-in template (delegates to ``task_excel``)
|
||||
* .csv → same columns as the template (header row, order-flexible)
|
||||
* .json → a list of task objects (or ``{"tasks": [...]}``) — the most
|
||||
expressive form: supports Co4E-flow tasks via ``flow_id``.
|
||||
|
||||
Every format runs through the SAME field mapping / enum fallbacks as the Excel
|
||||
importer (``task_excel.task_from_cells`` / ``resolve_depends``), so behaviour is
|
||||
consistent across formats.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from . import task_excel
|
||||
from .tasks import PRIORITIES, REPEAT_TYPES, TASK_TYPES, new_task, parse_run_at
|
||||
|
||||
SUPPORTED_EXTS = (".xlsx", ".xls", ".csv", ".json")
|
||||
IMPORT_FILTER = "Tasks (*.xlsx *.xls *.csv *.json)"
|
||||
|
||||
_HEADER_ALIASES = {h.split(" (")[0].strip().lower(): i for i, h in enumerate(task_excel.HEADERS)}
|
||||
|
||||
|
||||
def import_tasks(path: str | Path) -> List[Dict[str, Any]]:
|
||||
"""Dispatch by file extension. Raises ``ValueError`` with a human message on
|
||||
an unusable / unsupported file. Imported tasks are auto-chained to run in the
|
||||
file's top→bottom order (unless the file already defines dependencies)."""
|
||||
p = Path(path)
|
||||
ext = p.suffix.lower()
|
||||
if ext in (".xlsx", ".xls"):
|
||||
tasks = task_excel.import_tasks(p)
|
||||
elif ext == ".csv":
|
||||
tasks = _import_csv(p)
|
||||
elif ext == ".json":
|
||||
tasks = _import_json(p)
|
||||
else:
|
||||
raise ValueError(f"Unsupported file type '{ext}'. Use one of: {', '.join(SUPPORTED_EXTS)}.")
|
||||
return auto_chain_in_order(tasks)
|
||||
|
||||
|
||||
def auto_chain_in_order(tasks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Chain imported tasks so they run one after another in the file's row order
|
||||
(top → bottom): each task triggers the next on success, and the FIRST task is
|
||||
scheduled to start immediately. Skipped when the file already defines its own
|
||||
dependencies/chains (those are respected instead)."""
|
||||
if len(tasks) < 1:
|
||||
return tasks
|
||||
already = any((t.get("dependency", {}).get("depends_on")
|
||||
or t.get("dependency", {}).get("next_task_id")) for t in tasks)
|
||||
if already:
|
||||
return tasks
|
||||
from datetime import datetime
|
||||
|
||||
from .tasks import format_run_at
|
||||
for i in range(len(tasks) - 1):
|
||||
dep = tasks[i].setdefault("dependency", {})
|
||||
dep["next_task_id"] = tasks[i + 1]["task_id"]
|
||||
dep["run_next_mode"] = "run_after_success"
|
||||
first = tasks[0]
|
||||
if not first.get("schedule", {}).get("enabled"):
|
||||
first.setdefault("schedule", {})["enabled"] = True
|
||||
first["schedule"]["run_at"] = format_run_at(datetime.now())
|
||||
first["status"] = "scheduled"
|
||||
return tasks
|
||||
|
||||
|
||||
# ---- CSV -----------------------------------------------------------------
|
||||
def _import_csv(path: Path) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8-sig")
|
||||
except OSError as exc:
|
||||
raise ValueError(f"Cannot read CSV file: {exc}") from exc
|
||||
reader = list(csv.reader(text.splitlines()))
|
||||
if not reader:
|
||||
raise ValueError("The CSV file is empty.")
|
||||
header = [str(c or "").strip().lower() for c in reader[0]]
|
||||
# A header row lets columns be in any order; without one, assume template order.
|
||||
has_header = any(h in _HEADER_ALIASES for h in header)
|
||||
col_map = None
|
||||
if has_header:
|
||||
col_map = {i: _HEADER_ALIASES[h] for i, h in enumerate(header) if h in _HEADER_ALIASES}
|
||||
data_rows = reader[1:] if has_header else reader
|
||||
|
||||
tasks: List[Dict[str, Any]] = []
|
||||
depends_raw: List[List[str]] = []
|
||||
for row in data_rows:
|
||||
if col_map is not None:
|
||||
cells = [None] * len(task_excel.HEADERS)
|
||||
for src_i, dst_i in col_map.items():
|
||||
if src_i < len(row):
|
||||
cells[dst_i] = row[src_i]
|
||||
else:
|
||||
cells = list(row)
|
||||
t, depends = task_excel.task_from_cells(cells)
|
||||
if t is None:
|
||||
continue
|
||||
tasks.append(t)
|
||||
depends_raw.append(depends)
|
||||
if not tasks:
|
||||
raise ValueError("No task rows found in the CSV file.")
|
||||
task_excel.resolve_depends(tasks, depends_raw)
|
||||
return tasks
|
||||
|
||||
|
||||
# ---- JSON ----------------------------------------------------------------
|
||||
def _import_json(path: Path) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"Cannot read JSON file: {exc}") from exc
|
||||
if isinstance(data, dict) and isinstance(data.get("tasks"), list):
|
||||
data = data["tasks"]
|
||||
if not isinstance(data, list):
|
||||
raise ValueError("JSON must be a list of task objects (or {\"tasks\": [...]}).")
|
||||
|
||||
tasks: List[Dict[str, Any]] = []
|
||||
depends_raw: List[List[str]] = []
|
||||
for obj in data:
|
||||
if not isinstance(obj, dict):
|
||||
continue
|
||||
t, depends = _task_from_mapping(obj)
|
||||
if t is None:
|
||||
continue
|
||||
tasks.append(t)
|
||||
depends_raw.append(depends)
|
||||
if not tasks:
|
||||
raise ValueError("No task objects found in the JSON file.")
|
||||
# depends_on may reference titles OR ids — resolve titles here, keep ids.
|
||||
task_excel.resolve_depends(tasks, depends_raw)
|
||||
return tasks
|
||||
|
||||
|
||||
def _pick(d: Dict[str, Any], *keys, default=""):
|
||||
for k in keys:
|
||||
if k in d and d[k] not in (None, ""):
|
||||
return d[k]
|
||||
return default
|
||||
|
||||
|
||||
def _clamp(value, allowed, default):
|
||||
v = str(value or "").strip().lower()
|
||||
return v if v in allowed else default
|
||||
|
||||
|
||||
def _task_from_mapping(d: Dict[str, Any]):
|
||||
"""Map a JSON object to a task dict + its depends-on titles. Recognises the
|
||||
template field names plus friendly aliases, and — uniquely for JSON — a
|
||||
``flow_id`` that turns the task into a Co4E-flow run."""
|
||||
title = str(_pick(d, "title", "name")).strip()
|
||||
if not title:
|
||||
return None, None
|
||||
t = new_task(title)
|
||||
t["description"] = str(_pick(d, "description", "desc")).strip()
|
||||
flow_id = str(_pick(d, "flow_id", "co4e_flow", "flow")).strip()
|
||||
if flow_id:
|
||||
t["task_type"] = "flow"
|
||||
t["flow"]["flow_id"] = flow_id
|
||||
else:
|
||||
t["task_type"] = _clamp(_pick(d, "task_type", "type", default="cowork"),
|
||||
TASK_TYPES, "cowork")
|
||||
t["priority"] = _clamp(_pick(d, "priority", default="medium"), PRIORITIES, "medium")
|
||||
t["script_command"] = str(_pick(d, "script_command", "command")).strip()
|
||||
t["provider"] = str(_pick(d, "provider")).strip()
|
||||
t["model"] = str(_pick(d, "model")).strip()
|
||||
t["skill_slug"] = str(_pick(d, "skill_slug", "skill")).strip()
|
||||
sched = d.get("schedule") if isinstance(d.get("schedule"), dict) else d
|
||||
enabled = _truthy(_pick(sched, "schedule_enabled", "enabled", default=False))
|
||||
run_at = str(_pick(sched, "run_at")).strip()[:16]
|
||||
t["schedule"]["run_at"] = run_at if parse_run_at(run_at) else None
|
||||
t["schedule"]["repeat_type"] = _clamp(_pick(sched, "repeat", "repeat_type", default="none"),
|
||||
REPEAT_TYPES, "none")
|
||||
t["schedule"]["cron_expression"] = str(_pick(sched, "cron_expression", "cron")).strip() or None
|
||||
if enabled and (t["schedule"]["run_at"] or t["schedule"]["repeat_type"] == "cron"):
|
||||
t["schedule"]["enabled"] = True
|
||||
t["status"] = "scheduled"
|
||||
if _truthy(_pick(d, "use_previous_output", default=False)):
|
||||
t["input"]["mode"] = "previous_task_output"
|
||||
manual_text = str(_pick(d, "manual_text", "input_text")).strip()
|
||||
if manual_text:
|
||||
t["input"]["mode"] = "manual"
|
||||
t["input"]["manual_text"] = manual_text
|
||||
files = _pick(d, "file_paths", "files", default=[])
|
||||
if isinstance(files, list):
|
||||
t["input"]["file_paths"] = [str(x) for x in files if x]
|
||||
links = _pick(d, "links", "urls", default=[])
|
||||
if isinstance(links, list):
|
||||
t["input"]["links"] = [str(x) for x in links if x]
|
||||
t["execution"]["requires_approval"] = _truthy(_pick(d, "requires_approval", default=False))
|
||||
channel = str(_pick(d, "notify_channel", "reminder")).strip().lower()
|
||||
t["execution"]["notify_channel"] = channel if channel in ("teams", "outlook") else "none"
|
||||
t["execution"]["notify_email"] = str(_pick(d, "notify_email", "reminder_email")).strip()
|
||||
if t["execution"]["notify_channel"] != "none":
|
||||
t["execution"]["notify_on_complete"] = True
|
||||
t["execution"]["notify_on_error"] = True
|
||||
depends = _pick(d, "depends_on", "depends", default=[])
|
||||
if isinstance(depends, str):
|
||||
depends = [s.strip() for s in depends.split(";") if s.strip()]
|
||||
elif isinstance(depends, list):
|
||||
depends = [str(x).strip() for x in depends if str(x).strip()]
|
||||
else:
|
||||
depends = []
|
||||
return t, depends
|
||||
|
||||
|
||||
_TRUE = {"yes", "y", "true", "1", "x", "có", "co"}
|
||||
|
||||
|
||||
def _truthy(value) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value or "").strip().lower() in _TRUE
|
||||
Reference in New Issue
Block a user