Added Web UI for task definitions
This commit is contained in:
parent
7b645ad2ad
commit
09c0267404
29 changed files with 3475 additions and 17 deletions
38
.vscode/settings.json
vendored
38
.vscode/settings.json
vendored
|
|
@ -41,6 +41,7 @@
|
|||
"copyts",
|
||||
"creationflags",
|
||||
"cronsim",
|
||||
"crosshairs",
|
||||
"currsize",
|
||||
"dailymotion",
|
||||
"datas",
|
||||
|
|
@ -81,6 +82,8 @@
|
|||
"hookspath",
|
||||
"httpx",
|
||||
"imagetools",
|
||||
"jmespath",
|
||||
"jsonschema",
|
||||
"kibibytes",
|
||||
"lastgroup",
|
||||
"levelno",
|
||||
|
|
@ -112,6 +115,7 @@
|
|||
"noninteractive",
|
||||
"noprogress",
|
||||
"onefile",
|
||||
"parsel",
|
||||
"pathex",
|
||||
"pickleable",
|
||||
"platformdirs",
|
||||
|
|
@ -195,9 +199,39 @@
|
|||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": [
|
||||
"**/var/config/tasks/*.json"
|
||||
"**/tasks/*.json"
|
||||
],
|
||||
"url": "./app/library/task_handlers/task_definition.schema.json"
|
||||
"url": "./app/schema/task_definition.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": [
|
||||
"**/config/conditions.json"
|
||||
],
|
||||
"url": "./app/schema/conditions.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": [
|
||||
"**/config/dl_fields.json"
|
||||
],
|
||||
"url": "./app/schema/dl_fields.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": [
|
||||
"**/config/notifications.json"
|
||||
],
|
||||
"url": "./app/schema/notifications.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": [
|
||||
"**/config/presets.json"
|
||||
],
|
||||
"url": "./app/schema/presets.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": [
|
||||
"**/config/tasks.json"
|
||||
],
|
||||
"url": "./app/schema/tasks.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
2
FAQ.md
2
FAQ.md
|
|
@ -262,7 +262,7 @@ Definitions are reloaded automatically when files change, so you can tweak them
|
|||
`var/config/tasks/01-*.json` for sample files.
|
||||
|
||||
> [!NOTE]
|
||||
> A machine-readable schema is available at `app/library/task_handlers/task_definition.schema.json` if you want to validate your JSON with editors or CI tools.
|
||||
> A machine-readable schema is available at `app/schema/task_definition.json` if you want to validate your JSON with editors or CI tools.
|
||||
|
||||
# How to generate POT tokens?
|
||||
|
||||
|
|
|
|||
|
|
@ -267,7 +267,7 @@ class Presets(metaclass=Singleton):
|
|||
if not isinstance(item, dict):
|
||||
if not isinstance(item, Preset):
|
||||
msg = f"Unexpected '{type(item).__name__}' type was given."
|
||||
raise ValueError(msg) # noqa: TRY004
|
||||
raise ValueError(msg)
|
||||
|
||||
item = item.serialize()
|
||||
|
||||
|
|
|
|||
435
app/library/TaskDefinitions.py
Normal file
435
app/library/TaskDefinitions.py
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
from jsonschema import Draft7Validator, SchemaError, ValidationError
|
||||
|
||||
from .config import Config
|
||||
from .encoder import Encoder
|
||||
from .Services import Services
|
||||
from .Singleton import Singleton
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("task_definitions")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TaskDefinitionRecord:
|
||||
identifier: str
|
||||
"""UUID identifier of the task definition."""
|
||||
filename: str
|
||||
"""Filename of the task definition JSON file."""
|
||||
name: str
|
||||
"""Human-readable name of the task definition."""
|
||||
priority: int
|
||||
"""Priority of the task definition."""
|
||||
path: Path
|
||||
"""Path to the task definition JSON file."""
|
||||
data: dict[str, Any]
|
||||
"""The task definition data."""
|
||||
updated_at: float
|
||||
"""Last modified timestamp of the task definition file."""
|
||||
|
||||
def serialize(self, *, include_definition: bool = False) -> dict[str, Any]:
|
||||
"""
|
||||
Serialize the task definition record to a dictionary.
|
||||
|
||||
Args:
|
||||
include_definition (bool): Whether to include the full task definition data.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: The serialized task definition record.
|
||||
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
"id": self.identifier,
|
||||
"name": self.name,
|
||||
"priority": self.priority,
|
||||
"updated_at": self.updated_at,
|
||||
}
|
||||
|
||||
if include_definition:
|
||||
payload["definition"] = self.data
|
||||
|
||||
return payload
|
||||
|
||||
def json(self, *, include_definition: bool = False) -> str:
|
||||
"""
|
||||
Serialize the task definition record to a JSON string.
|
||||
|
||||
Args:
|
||||
include_definition (bool): Whether to include the full task definition data.
|
||||
|
||||
Returns:
|
||||
str: The JSON string representation of the task definition record.
|
||||
|
||||
"""
|
||||
return Encoder().encode(self.serialize(include_definition=include_definition))
|
||||
|
||||
|
||||
class TaskDefinitions(metaclass=Singleton):
|
||||
def __init__(
|
||||
self,
|
||||
directory: str | Path | None = None,
|
||||
config: Config | None = None,
|
||||
validator: Draft7Validator | None = None,
|
||||
):
|
||||
self._config: Config = config or Config.get_instance()
|
||||
"Instance of Config to use."
|
||||
self._directory: Path = Path(directory) if directory else Path(self._config.config_path) / "tasks"
|
||||
"Directory where task definition files are stored."
|
||||
self._validator: Draft7Validator | None = validator
|
||||
"JSON schema validator instance."
|
||||
self._items: dict[str, TaskDefinitionRecord] = {}
|
||||
"Mapping of task definition ID to TaskDefinitionRecord."
|
||||
self._schema: Path = Path(self._config.app_path) / "schema" / "task_definition.json"
|
||||
"Path to the JSON schema file for task definitions."
|
||||
|
||||
try:
|
||||
self._directory.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to create tasks directory '{self._directory}': {e}")
|
||||
|
||||
self.load()
|
||||
|
||||
@staticmethod
|
||||
def get_instance(
|
||||
directory: str | Path | None = None,
|
||||
config: Config | None = None,
|
||||
validator: Draft7Validator | None = None,
|
||||
) -> "TaskDefinitions":
|
||||
"""
|
||||
Get the singleton instance of TaskDefinitions.
|
||||
|
||||
Args:
|
||||
directory (str | Path | None): Optional directory to store task definitions.
|
||||
config (Config | None): Optional Config instance to use.
|
||||
validator (Draft7Validator | None): Optional JSON schema validator to use.
|
||||
|
||||
Returns:
|
||||
TaskDefinitions: The singleton instance of TaskDefinitions.
|
||||
|
||||
"""
|
||||
return TaskDefinitions(directory=directory, config=config, validator=validator)
|
||||
|
||||
def attach(self, _: web.Application) -> None:
|
||||
"""
|
||||
Attach the TaskDefinitions service to the application.
|
||||
|
||||
Args:
|
||||
_ (web.Application): The aiohttp web application instance.
|
||||
|
||||
"""
|
||||
Services.get_instance().add("task_definitions", self)
|
||||
|
||||
async def on_shutdown(self, _: web.Application) -> None:
|
||||
"""
|
||||
Handle application shutdown event.
|
||||
|
||||
Args:
|
||||
_ (web.Application): The aiohttp web application instance.
|
||||
|
||||
"""
|
||||
return
|
||||
|
||||
def _get_validator(self) -> Draft7Validator:
|
||||
"""
|
||||
Get or create the JSON schema validator for task definitions.
|
||||
|
||||
Returns:
|
||||
Draft7Validator: The JSON schema validator instance.
|
||||
|
||||
"""
|
||||
if self._validator:
|
||||
return self._validator
|
||||
|
||||
try:
|
||||
contents: str = self._schema.read_text(encoding="utf-8")
|
||||
schema = json.loads(contents)
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to read task definition schema '{self._schema}': {e}")
|
||||
raise
|
||||
|
||||
try:
|
||||
self._validator = Draft7Validator(schema)
|
||||
except SchemaError as e:
|
||||
LOG.error(f"Invalid task definition schema '{self._schema}': {e}")
|
||||
raise
|
||||
|
||||
return self._validator
|
||||
|
||||
def validate(self, definition: dict[str, Any]) -> None:
|
||||
"""
|
||||
Validate a task definition against the JSON schema.
|
||||
|
||||
Args:
|
||||
definition (dict[str, Any]): The task definition to validate.
|
||||
|
||||
Raises:
|
||||
ValueError: If the task definition is invalid.
|
||||
|
||||
"""
|
||||
try:
|
||||
self._get_validator().validate(definition)
|
||||
except ValidationError as e:
|
||||
path: str = " ".join(str(part) for part in e.path)
|
||||
error_path: str = f" ({path})" if path else ""
|
||||
message: str = f"Task definition validation failed{error_path}: {e.message}"
|
||||
raise ValueError(message) from e
|
||||
|
||||
def load(self) -> "TaskDefinitions":
|
||||
"""
|
||||
Load all task definitions from the directory.
|
||||
|
||||
Returns:
|
||||
TaskDefinitions: The current instance for chaining.
|
||||
|
||||
"""
|
||||
self._items.clear()
|
||||
|
||||
if not self._directory.exists():
|
||||
return self
|
||||
|
||||
for file_path in sorted(self._directory.glob("*.json")):
|
||||
stem: str = file_path.stem
|
||||
|
||||
try:
|
||||
identifier_uuid = uuid.UUID(stem)
|
||||
except ValueError:
|
||||
LOG.warning(f"Skipping task definition with invalid UUID filename '{file_path.name}'.")
|
||||
continue
|
||||
|
||||
if 4 != identifier_uuid.version:
|
||||
LOG.warning(f"Skipping task definition '{file_path.name}', Name is not UUIDv4.")
|
||||
continue
|
||||
|
||||
try:
|
||||
contents: str = file_path.read_text(encoding="utf-8")
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to load task definition '{file_path}': {e!s}")
|
||||
continue
|
||||
|
||||
try:
|
||||
parsed = json.loads(contents)
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to parse task definition '{file_path}': {e!s}")
|
||||
continue
|
||||
|
||||
if not isinstance(parsed, dict):
|
||||
LOG.error(f"Invalid task definition file '{file_path}': must be a JSON object.")
|
||||
continue
|
||||
|
||||
data: dict[str, Any] = parsed
|
||||
|
||||
identifier: str = str(identifier_uuid)
|
||||
|
||||
name_value: str = str(data.get("name") or identifier)
|
||||
priority: int = self._normalize_priority(data.get("priority", 0))
|
||||
data["priority"] = priority
|
||||
|
||||
record = TaskDefinitionRecord(
|
||||
identifier=identifier,
|
||||
filename=file_path.name,
|
||||
name=name_value,
|
||||
priority=priority,
|
||||
path=file_path,
|
||||
data=data,
|
||||
updated_at=file_path.stat().st_mtime,
|
||||
)
|
||||
self._items[record.identifier] = record
|
||||
|
||||
return self
|
||||
|
||||
def list(self) -> list[TaskDefinitionRecord]:
|
||||
"""
|
||||
List all task definitions, sorted by priority and name.
|
||||
|
||||
Returns:
|
||||
list[TaskDefinitionRecord]: List of task definitions sorted by priority and name.
|
||||
|
||||
"""
|
||||
return sorted(
|
||||
self._items.values(),
|
||||
key=lambda record: (record.priority, record.name.lower()),
|
||||
)
|
||||
|
||||
def get(self, identifier: str) -> TaskDefinitionRecord | None:
|
||||
"""
|
||||
Get a task definition by its identifier.
|
||||
|
||||
Args:
|
||||
identifier (str): The UUID identifier of the task definition.
|
||||
|
||||
Returns:
|
||||
TaskDefinitionRecord | None: The task definition record, or None if not found.
|
||||
|
||||
"""
|
||||
return self._items.get(identifier)
|
||||
|
||||
def _path_for(self, identifier: str) -> Path:
|
||||
"""
|
||||
Get the file path for a given task definition identifier.
|
||||
|
||||
Args:
|
||||
identifier (str): The UUID identifier of the task definition.
|
||||
|
||||
Returns:
|
||||
Path: The file path for the task definition JSON file.
|
||||
|
||||
"""
|
||||
return self._directory / f"{identifier}.json"
|
||||
|
||||
def _write_file(self, path: Path, payload: dict[str, Any]) -> None:
|
||||
"""
|
||||
Write a task definition to a file.
|
||||
|
||||
Args:
|
||||
path (Path): The file path to write to.
|
||||
payload (dict[str, Any]): The task definition data to write.
|
||||
|
||||
Raises:
|
||||
Exception: If writing to the file fails.
|
||||
|
||||
"""
|
||||
path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||
try:
|
||||
path.chmod(0o600)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _normalize_priority(self, value: Any) -> int:
|
||||
"""
|
||||
Normalize the priority value to an integer.
|
||||
|
||||
Args:
|
||||
value (Any): The priority value to normalize.
|
||||
|
||||
Returns:
|
||||
int: The normalized priority value.
|
||||
|
||||
"""
|
||||
try:
|
||||
priority = int(value)
|
||||
except Exception:
|
||||
priority = 0
|
||||
|
||||
return priority
|
||||
|
||||
def _refresh_generic_handler(self) -> None:
|
||||
"""
|
||||
Refresh the generic task handler definitions.
|
||||
"""
|
||||
try:
|
||||
from .task_handlers.generic import GenericTaskHandler
|
||||
|
||||
GenericTaskHandler.refresh_definitions(force=True)
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to refresh generic task handler: {e}")
|
||||
|
||||
def create(self, definition: dict[str, Any]) -> TaskDefinitionRecord:
|
||||
"""
|
||||
Create a new task definition.
|
||||
|
||||
Args:
|
||||
definition (dict[str, Any]): The task definition data.
|
||||
|
||||
Returns:
|
||||
TaskDefinitionRecord: The created task definition record.
|
||||
|
||||
"""
|
||||
self.validate(definition)
|
||||
|
||||
identifier: str = str(uuid.uuid4())
|
||||
path: Path = self._path_for(identifier)
|
||||
|
||||
while path.exists():
|
||||
identifier = str(uuid.uuid4())
|
||||
path = self._path_for(identifier)
|
||||
|
||||
priority: int = self._normalize_priority(definition.get("priority", 0))
|
||||
definition["priority"] = priority
|
||||
|
||||
self._write_file(path, definition)
|
||||
|
||||
record = TaskDefinitionRecord(
|
||||
identifier=identifier,
|
||||
filename=path.name,
|
||||
name=str(definition.get("name", identifier)),
|
||||
priority=priority,
|
||||
path=path,
|
||||
data=definition,
|
||||
updated_at=path.stat().st_mtime,
|
||||
)
|
||||
|
||||
self._items[identifier] = record
|
||||
self._refresh_generic_handler()
|
||||
return record
|
||||
|
||||
def update(self, identifier: str, definition: dict[str, Any]) -> TaskDefinitionRecord:
|
||||
"""
|
||||
Update an existing task definition.
|
||||
|
||||
Args:
|
||||
identifier (str): The UUID identifier of the task definition to update.
|
||||
definition (dict[str, Any]): The updated task definition data.
|
||||
|
||||
Returns:
|
||||
TaskDefinitionRecord: The updated task definition record.
|
||||
|
||||
Raises:
|
||||
ValueError: If the task definition does not exist or is invalid.
|
||||
|
||||
"""
|
||||
record: TaskDefinitionRecord | None = self.get(identifier)
|
||||
if not record:
|
||||
message: str = f"Task definition '{identifier}' does not exist."
|
||||
raise ValueError(message)
|
||||
|
||||
self.validate(definition)
|
||||
priority: int = self._normalize_priority(definition.get("priority", record.priority))
|
||||
definition["priority"] = priority
|
||||
self._write_file(record.path, definition)
|
||||
|
||||
updated_record = TaskDefinitionRecord(
|
||||
identifier=identifier,
|
||||
filename=record.filename,
|
||||
name=str(definition.get("name", identifier)),
|
||||
priority=priority,
|
||||
path=record.path,
|
||||
data=definition,
|
||||
updated_at=record.path.stat().st_mtime,
|
||||
)
|
||||
|
||||
self._items[identifier] = updated_record
|
||||
self._refresh_generic_handler()
|
||||
return updated_record
|
||||
|
||||
def delete(self, identifier: str) -> None:
|
||||
"""
|
||||
Delete a task definition.
|
||||
|
||||
Args:
|
||||
identifier (str): The UUID identifier of the task definition to delete.
|
||||
|
||||
Raises:
|
||||
ValueError: If the task definition does not exist.
|
||||
|
||||
"""
|
||||
record: TaskDefinitionRecord | None = self.get(identifier)
|
||||
if not record:
|
||||
message: str = f"Task definition '{identifier}' does not exist."
|
||||
raise ValueError(message)
|
||||
|
||||
try:
|
||||
record.path.unlink(missing_ok=False)
|
||||
except FileNotFoundError:
|
||||
LOG.warning(f"Task definition file '{record.path}' already removed.")
|
||||
except Exception as exc:
|
||||
LOG.error(f"Failed to delete task definition '{identifier}': {exc}")
|
||||
raise
|
||||
|
||||
self._items.pop(identifier, None)
|
||||
self._refresh_generic_handler()
|
||||
|
|
@ -417,7 +417,7 @@ class Tasks(metaclass=Singleton):
|
|||
if not isinstance(task, dict):
|
||||
if not isinstance(task, Task):
|
||||
msg = "Invalid task type."
|
||||
raise ValueError(msg) # noqa: TRY004
|
||||
raise ValueError(msg)
|
||||
|
||||
task = task.serialize()
|
||||
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ class Conditions(metaclass=Singleton):
|
|||
if not isinstance(item, dict):
|
||||
if not isinstance(item, Condition):
|
||||
msg = f"Unexpected '{type(item).__name__}' item type."
|
||||
raise ValueError(msg) # noqa: TRY004
|
||||
raise ValueError(msg)
|
||||
|
||||
item = item.serialize()
|
||||
|
||||
|
|
@ -211,7 +211,7 @@ class Conditions(metaclass=Singleton):
|
|||
|
||||
if not isinstance(item.get("extras"), dict):
|
||||
msg = "Extras must be a dictionary."
|
||||
raise ValueError(msg) # noqa: TRY004
|
||||
raise ValueError(msg)
|
||||
|
||||
return True
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ class Config(metaclass=Singleton):
|
|||
app_env: str = "production"
|
||||
"""The application environment, can be 'production' or 'development'."""
|
||||
|
||||
app_path: str = "../../"
|
||||
"""The app path of the application."""
|
||||
|
||||
config_path: str = "."
|
||||
"""The path to the configuration directory."""
|
||||
|
||||
|
|
@ -194,6 +197,7 @@ class Config(metaclass=Singleton):
|
|||
"temp_path",
|
||||
"config_path",
|
||||
"download_path",
|
||||
"app_path",
|
||||
)
|
||||
"The variables that are set manually."
|
||||
|
||||
|
|
@ -289,6 +293,7 @@ class Config(metaclass=Singleton):
|
|||
self.download_path = os.environ.get("YTP_DOWNLOAD_PATH", None) or str(
|
||||
Path(baseDefaultPath) / "var" / "downloads"
|
||||
)
|
||||
self.app_path = Path(__file__).parent.parent.absolute()
|
||||
|
||||
envFile: str = Path(self.config_path) / ".env"
|
||||
|
||||
|
|
@ -428,6 +433,21 @@ class Config(metaclass=Singleton):
|
|||
if "dev-master" == self.app_version:
|
||||
self._version_via_git()
|
||||
|
||||
def set_app_path(self, path: Path|str) -> "Config":
|
||||
"""
|
||||
Set the root path of the application.
|
||||
|
||||
Args:
|
||||
path (str): The root path to set.
|
||||
|
||||
Returns:
|
||||
Config: The Config instance.
|
||||
|
||||
"""
|
||||
Config.app_path = str(path)
|
||||
|
||||
return self
|
||||
|
||||
def _get_attributes(self) -> dict:
|
||||
attrs: dict = {}
|
||||
vClass: str = self.__class__
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ class DLFields(metaclass=Singleton):
|
|||
if not isinstance(item, dict):
|
||||
if not isinstance(item, DLField):
|
||||
msg = f"Unexpected '{type(item).__name__}' type was given."
|
||||
raise ValueError(msg) # noqa: TRY004
|
||||
raise ValueError(msg)
|
||||
|
||||
item = item.serialize()
|
||||
|
||||
|
|
@ -255,7 +255,7 @@ class DLFields(metaclass=Singleton):
|
|||
|
||||
if not isinstance(item.get("extras", {}), dict):
|
||||
msg = "Extras must be a dictionary."
|
||||
raise ValueError(msg) # noqa: TRY004
|
||||
raise ValueError(msg)
|
||||
|
||||
if re.match(r"^--[a-zA-Z0-9\-]+$", item.get("field", "").strip()) is None:
|
||||
msg = "Invalid yt-dlp option field it must starts with '--' and contain only alphanumeric characters."
|
||||
|
|
|
|||
|
|
@ -607,6 +607,11 @@ class GenericTaskHandler(BaseHandler):
|
|||
cls._definitions = load_task_definitions()
|
||||
cls._sources_mtime = current
|
||||
|
||||
@classmethod
|
||||
def refresh_definitions(cls, force: bool = False) -> None:
|
||||
"""Public helper to refresh cached task definitions."""
|
||||
cls._refresh_definitions(force=force)
|
||||
|
||||
@classmethod
|
||||
def _find_definition(cls, url: str) -> TaskDefinition | None:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ from app.library.Notifications import Notification
|
|||
from app.library.Presets import Presets
|
||||
from app.library.Scheduler import Scheduler
|
||||
from app.library.Services import Services
|
||||
from app.library.TaskDefinitions import TaskDefinitions
|
||||
from app.library.Tasks import Tasks
|
||||
|
||||
LOG = logging.getLogger("app")
|
||||
|
|
@ -39,6 +40,7 @@ ROOT_PATH: Path = Path(__file__).parent.absolute()
|
|||
class Main:
|
||||
def __init__(self, is_native: bool = False):
|
||||
self._config: Config = Config.get_instance(is_native=is_native)
|
||||
self._config.set_app_path(str(ROOT_PATH))
|
||||
self._app = web.Application()
|
||||
self._app.on_shutdown.append(self.on_shutdown)
|
||||
self._background_worker = BackgroundWorker()
|
||||
|
|
@ -132,6 +134,7 @@ class Main:
|
|||
Notification.get_instance().attach(self._app)
|
||||
Conditions.get_instance().attach(self._app)
|
||||
DLFields.get_instance().attach(self._app)
|
||||
TaskDefinitions.get_instance().attach(self._app)
|
||||
self._background_worker.attach(self._app)
|
||||
|
||||
EventBus.get_instance().emit(
|
||||
|
|
|
|||
145
app/routes/api/task_definitions.py
Normal file
145
app/routes/api/task_definitions.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.router import route
|
||||
from app.library.TaskDefinitions import TaskDefinitionRecord, TaskDefinitions
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route("GET", "api/task_definitions/", "task_definitions")
|
||||
async def task_definitions_list(request: Request, encoder: Encoder, task_definitions: TaskDefinitions) -> Response:
|
||||
include: str | None = request.query.get("include")
|
||||
include_definition: bool = "definition" == include
|
||||
|
||||
return web.json_response(
|
||||
data=[item.serialize(include_definition=include_definition) for item in task_definitions.list()],
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("GET", "api/task_definitions/{identifier}", "task_definitions_get")
|
||||
async def task_definitions_get(request: Request, encoder: Encoder, task_definitions: TaskDefinitions) -> Response:
|
||||
identifier: str = request.match_info.get("identifier", "").strip()
|
||||
if not identifier:
|
||||
return web.json_response(
|
||||
data={"error": "Missing task definition identifier."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
record: TaskDefinitionRecord | None = task_definitions.get(identifier)
|
||||
if not record:
|
||||
return web.json_response(
|
||||
data={"error": f"Task definition '{identifier}' not found."},
|
||||
status=web.HTTPNotFound.status_code,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
data=record.serialize(include_definition=True),
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("POST", "api/task_definitions/", "task_definitions_create")
|
||||
async def task_definitions_create(request: Request, encoder: Encoder, task_definitions: TaskDefinitions) -> Response:
|
||||
try:
|
||||
payload: Any = await request.json()
|
||||
if not isinstance(payload, dict):
|
||||
return web.json_response(
|
||||
data={"error": "Invalid request body; expected JSON object."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if "definition" in payload:
|
||||
if not isinstance(payload["definition"], dict):
|
||||
return web.json_response(
|
||||
data={"error": "definition must be a JSON object when provided."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
payload = payload["definition"]
|
||||
|
||||
record: TaskDefinitionRecord = task_definitions.create(payload)
|
||||
except (ValueError, TypeError) as e:
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(
|
||||
data={"error": "Failed to create task definition."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
data=record.serialize(include_definition=True),
|
||||
status=web.HTTPCreated.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("PUT", "api/task_definitions/{identifier}", "task_definitions_update")
|
||||
async def task_definitions_update(request: Request, encoder: Encoder, task_definitions: TaskDefinitions) -> Response:
|
||||
identifier: str = request.match_info.get("identifier", "").strip()
|
||||
if not identifier:
|
||||
return web.json_response(
|
||||
data={"error": "Missing task definition identifier."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
payload: Any = await request.json()
|
||||
if not isinstance(payload, dict):
|
||||
return web.json_response(
|
||||
data={"error": "Invalid request body; expected JSON object."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if "definition" in payload:
|
||||
if not isinstance(payload["definition"], dict):
|
||||
return web.json_response(
|
||||
data={"error": "definition must be a JSON object when provided."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
payload = payload["definition"]
|
||||
|
||||
record: TaskDefinitionRecord = task_definitions.update(identifier, payload)
|
||||
except (ValueError, TypeError) as e:
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(
|
||||
data={"error": "Failed to update task definition."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
data=record.serialize(include_definition=True),
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("DELETE", "api/task_definitions/{identifier}", "task_definitions_delete")
|
||||
async def task_definitions_delete(request: Request, task_definitions: TaskDefinitions) -> Response:
|
||||
identifier: str = request.match_info.get("identifier", "").strip()
|
||||
if not identifier:
|
||||
return web.json_response(
|
||||
data={"error": "Missing task definition identifier."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
task_definitions.delete(identifier)
|
||||
except ValueError as e:
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(
|
||||
data={"error": "Failed to delete task definition."}, status=web.HTTPInternalServerError.status_code
|
||||
)
|
||||
|
||||
return web.json_response(data={"status": "deleted"}, status=web.HTTPOk.status_code)
|
||||
56
app/schema/conditions.json
Normal file
56
app/schema/conditions.json
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://ytptube.app/schemas/conditions.json",
|
||||
"title": "YTPTube Download Conditions",
|
||||
"description": "Schema describing conditional rules evaluated against yt-dlp info dicts.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"filter"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier for the condition (UUID)."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Human-readable name for the condition."
|
||||
},
|
||||
"filter": {
|
||||
"type": "string",
|
||||
"description": "Filter expression to evaluate against info dict."
|
||||
},
|
||||
"cli": {
|
||||
"type": "string",
|
||||
"description": "yt-dlp CLI fragment to append if matched.",
|
||||
"default": ""
|
||||
},
|
||||
"extras": {
|
||||
"type": "object",
|
||||
"description": "Any extra data to store with the condition.",
|
||||
"default": {}
|
||||
}
|
||||
},
|
||||
"examples": [
|
||||
{
|
||||
"id": "c1e2d3f4-5678-1234-9abc-def012345678",
|
||||
"name": "Audio Only",
|
||||
"filter": "type=audio",
|
||||
"cli": "--extract-audio",
|
||||
"extras": {
|
||||
"priority": "high"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a2b3c4d5-6789-2345-0bcd-ef1234567890",
|
||||
"name": "Long Videos",
|
||||
"filter": "duration>3600"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
78
app/schema/dl_fields.json
Normal file
78
app/schema/dl_fields.json
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
{
|
||||
"$id": "https://ytptube.app/schema/dl_fields.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Download Field Definition",
|
||||
"description": "Schema for custom yt-dlp field definitions used in YTPTube.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"field",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier for the field (UUID)."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Human-readable name for the field."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Description of the field."
|
||||
},
|
||||
"field": {
|
||||
"type": "string",
|
||||
"description": "yt-dlp field or CLI argument."
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"description": "Type of field (string, text, bool).",
|
||||
"enum": [
|
||||
"string",
|
||||
"text",
|
||||
"bool"
|
||||
]
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"description": "Font-awesome icon for the field.",
|
||||
"default": ""
|
||||
},
|
||||
"order": {
|
||||
"type": "integer",
|
||||
"description": "Order for sorting fields in the UI.",
|
||||
"default": 0
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "Default value for the field.",
|
||||
"default": ""
|
||||
},
|
||||
"extras": {
|
||||
"type": "object",
|
||||
"description": "Additional options for the field.",
|
||||
"default": {}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"examples": [
|
||||
{
|
||||
"id": "acffe9f6-993b-42ad-94ff-4646f48a83a9",
|
||||
"name": "Delete cache?",
|
||||
"description": "Delete cache",
|
||||
"field": "--no-continue",
|
||||
"kind": "bool",
|
||||
"icon": "fa-solid fa-trash-arrow-up",
|
||||
"order": 1,
|
||||
"value": "",
|
||||
"extras": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
175
app/schema/notifications.json
Normal file
175
app/schema/notifications.json
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://ytptube.app/schema/notifications.json",
|
||||
"title": "Notification Configuration",
|
||||
"type": "array",
|
||||
"description": "Schema describing webhook and Apprise notification targets stored in notifications.json.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/target"
|
||||
},
|
||||
"definitions": {
|
||||
"event": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"test",
|
||||
"item_added",
|
||||
"item_completed",
|
||||
"item_cancelled",
|
||||
"item_deleted",
|
||||
"item_paused",
|
||||
"item_resumed",
|
||||
"item_moved",
|
||||
"paused",
|
||||
"resumed",
|
||||
"log_info",
|
||||
"log_success",
|
||||
"log_warning",
|
||||
"log_error",
|
||||
"task_dispatched"
|
||||
],
|
||||
"description": "Event identifier emitted by the backend event bus."
|
||||
},
|
||||
"header": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"key",
|
||||
"value"
|
||||
],
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "HTTP header name."
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "HTTP header value."
|
||||
}
|
||||
}
|
||||
},
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"type",
|
||||
"method",
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"json",
|
||||
"form"
|
||||
],
|
||||
"default": "json",
|
||||
"description": "Payload encoding strategy for the outgoing request."
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"POST",
|
||||
"PUT"
|
||||
],
|
||||
"default": "POST",
|
||||
"description": "HTTP method used when delivering the notification payload."
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Destination endpoint or Apprise URL that receives the notification."
|
||||
},
|
||||
"data_key": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"default": "data",
|
||||
"description": "JSON/Form field name used to wrap the serialized event payload."
|
||||
},
|
||||
"headers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/header"
|
||||
},
|
||||
"description": "Optional static headers appended to each HTTP request.",
|
||||
"default": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"target": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"request"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Unique identifier for the notification target (UUIDv4)."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Label shown in the UI for this notification target."
|
||||
},
|
||||
"on": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/event"
|
||||
},
|
||||
"description": "Subset of events that trigger this target; leave empty to listen to every event.",
|
||||
"default": []
|
||||
},
|
||||
"presets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": "Restrict notifications to downloads created by these presets.",
|
||||
"default": []
|
||||
},
|
||||
"request": {
|
||||
"$ref": "#/definitions/request"
|
||||
}
|
||||
},
|
||||
"examples": [
|
||||
{
|
||||
"id": "d40d4b6e-45b8-47a1-b1b2-9cf7f8e6c67a",
|
||||
"name": "Webhook: Completed items",
|
||||
"on": [
|
||||
"item_completed"
|
||||
],
|
||||
"presets": [
|
||||
"default"
|
||||
],
|
||||
"request": {
|
||||
"type": "json",
|
||||
"method": "POST",
|
||||
"url": "https://hooks.example.com/ytptube",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer token123"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "9f783d0a-6e8b-4c1d-9089-5bb4ae0f2c47",
|
||||
"name": "Apprise Notifier",
|
||||
"on": [],
|
||||
"request": {
|
||||
"type": "json",
|
||||
"method": "POST",
|
||||
"url": "discord://webhook/token"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
67
app/schema/presets.json
Normal file
67
app/schema/presets.json
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
{
|
||||
"$id": "https://ytptube.app/schema/presets.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Preset Configuration",
|
||||
"description": "Schema for yt-dlp preset configurations used in YTPTube.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier for the preset (UUID or slug)."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Human-readable name for the preset.",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Optional description of the preset.",
|
||||
"default": ""
|
||||
},
|
||||
"folder": {
|
||||
"type": "string",
|
||||
"description": "Default download folder for this preset.",
|
||||
"default": ""
|
||||
},
|
||||
"template": {
|
||||
"type": "string",
|
||||
"description": "Default output template for this preset.",
|
||||
"default": ""
|
||||
},
|
||||
"cookies": {
|
||||
"type": "string",
|
||||
"description": "Default cookies for this preset.",
|
||||
"default": ""
|
||||
},
|
||||
"cli": {
|
||||
"type": "string",
|
||||
"description": "yt-dlp command line options for this preset.",
|
||||
"default": ""
|
||||
},
|
||||
"default": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this preset is a default preset.",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
],
|
||||
"examples": [
|
||||
{
|
||||
"id": "3e163c6c-64eb-4448-924f-814b629b3810",
|
||||
"name": "default",
|
||||
"default": true,
|
||||
"cli": "--socket-timeout 30 --download-archive %(config_path)s/archive.log",
|
||||
"description": "Default preset for yt-dlp. It will download whatever yt-dlp decides is the best quality for the video and audio."
|
||||
},
|
||||
{
|
||||
"id": "a6fd4b25-2b3e-458d-bb57-b75e41cc4330",
|
||||
"name": "Audio Only",
|
||||
"cli": "--extract-audio --add-chapters --embed-metadata --embed-thumbnail --format 'bestaudio/best'",
|
||||
"description": "This preset is designed to download only the audio of the video. It will extract the audio, add chapters, metadata, and thumbnail."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://ytptube.app/schemas/task-definition.json",
|
||||
"$id": "https://ytptube.app/schemas/task_definition.json",
|
||||
"title": "YTPTube Generic Task Definition",
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
@ -15,6 +15,12 @@
|
|||
"minLength": 1,
|
||||
"description": "Human-readable identifier for this definition."
|
||||
},
|
||||
"priority": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Optional ordering priority. Lower numbers are listed first.",
|
||||
"default": 0
|
||||
},
|
||||
"match": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
|
|
@ -226,6 +232,34 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"engine": {
|
||||
"properties": {
|
||||
"type": { "const": "selenium" }
|
||||
},
|
||||
"required": ["type"]
|
||||
}
|
||||
},
|
||||
"required": ["engine"]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"engine": {
|
||||
"properties": {
|
||||
"options": {
|
||||
"required": ["url"]
|
||||
}
|
||||
},
|
||||
"required": ["options"]
|
||||
}
|
||||
},
|
||||
"required": ["engine"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"definitions": {
|
||||
"stringMap": {
|
||||
"type": "object",
|
||||
84
app/schema/tasks.json
Normal file
84
app/schema/tasks.json
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://ytptube.app/schemas/tasks.json",
|
||||
"title": "YTPTube Scheduled Tasks",
|
||||
"description": "Schema describing recurring download tasks stored in tasks.json.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/task"
|
||||
},
|
||||
"definitions": {
|
||||
"task": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Unique identifier for the task (UUIDv4)."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Human-friendly task label used throughout the UI."
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"pattern": "^https?://",
|
||||
"description": "Playlist or channel URL monitored by the task."
|
||||
},
|
||||
"folder": {
|
||||
"type": "string",
|
||||
"description": "Optional download folder override for this task.",
|
||||
"default": ""
|
||||
},
|
||||
"preset": {
|
||||
"type": "string",
|
||||
"description": "Preset name to apply; falls back to the global default when omitted.",
|
||||
"default": ""
|
||||
},
|
||||
"timer": {
|
||||
"type": "string",
|
||||
"description": "Cron expression executed by the scheduler (five or six space-separated fields).",
|
||||
"default": ""
|
||||
},
|
||||
"template": {
|
||||
"type": "string",
|
||||
"description": "Output filename template override for downloads created by this task.",
|
||||
"default": ""
|
||||
},
|
||||
"cli": {
|
||||
"type": "string",
|
||||
"description": "Additional yt-dlp arguments appended to the download request.",
|
||||
"default": ""
|
||||
},
|
||||
"auto_start": {
|
||||
"type": "boolean",
|
||||
"description": "Automatically queue new items discovered by the task.",
|
||||
"default": true
|
||||
},
|
||||
"handler_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Controls whether the matched handler remains enabled for this task.",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"examples": [
|
||||
{
|
||||
"id": "4d9be675-ecb8-4b42-9a84-8cee71a7a2e2",
|
||||
"name": "Weekly playlist sync",
|
||||
"url": "https://www.youtube.com/playlist?list=PL12345",
|
||||
"preset": "default",
|
||||
"timer": "0 */6 * * *",
|
||||
"auto_start": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
315
app/tests/test_task_definitions.py
Normal file
315
app/tests/test_task_definitions.py
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request
|
||||
from jsonschema import Draft7Validator
|
||||
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.TaskDefinitions import TaskDefinitionRecord, TaskDefinitions
|
||||
from app.routes.api import task_definitions as api
|
||||
|
||||
|
||||
def _load_validator() -> Draft7Validator:
|
||||
schema_path = Path(__file__).resolve().parent.parent / "schema" / "task_definition.json"
|
||||
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||
return Draft7Validator(schema)
|
||||
|
||||
|
||||
def _sample_definition(name: str = "example", *, priority: int = 0) -> dict[str, Any]:
|
||||
return {
|
||||
"name": name,
|
||||
"match": ["https://example.com/*"],
|
||||
"priority": priority,
|
||||
"parse": {
|
||||
"items": {
|
||||
"type": "css",
|
||||
"selector": ".card",
|
||||
"fields": {
|
||||
"link": {"type": "css", "expression": "a", "attribute": "href"},
|
||||
"title": {"type": "css", "expression": "a", "attribute": "text"},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestTaskDefinitionsManager:
|
||||
def setup_method(self) -> None:
|
||||
TaskDefinitions._reset_singleton()
|
||||
|
||||
def teardown_method(self) -> None:
|
||||
TaskDefinitions._reset_singleton()
|
||||
|
||||
def test_load_populates_records(self, tmp_path: Path) -> None:
|
||||
validator = _load_validator()
|
||||
config = Mock(config_path=str(tmp_path), app_path=str(tmp_path))
|
||||
|
||||
first_identifier = "b5c6ad5f-4745-4c05-88c8-dde1deae3b51"
|
||||
second_identifier = "ae38a6b0-2c22-4763-ba60-801ae8ce1218"
|
||||
|
||||
first = tmp_path / f"{first_identifier}.json"
|
||||
second = tmp_path / f"{second_identifier}.json"
|
||||
|
||||
first.write_text(json.dumps(_sample_definition("First", priority=5)), encoding="utf-8")
|
||||
second.write_text(json.dumps(_sample_definition("Second", priority=1)), encoding="utf-8")
|
||||
|
||||
(tmp_path / "not-a-uuid.json").write_text(json.dumps(_sample_definition("Ignored")), encoding="utf-8")
|
||||
(tmp_path / "0f9184de-5d3c-2111-8c21-6d3f0be1bd3d.json").write_text(
|
||||
json.dumps(_sample_definition("Ignored2")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
manager = TaskDefinitions.get_instance(directory=tmp_path, config=config, validator=validator)
|
||||
|
||||
records = manager.list()
|
||||
assert len(records) == 2
|
||||
assert [record.name for record in records] == ["Second", "First"]
|
||||
assert [record.priority for record in records] == [1, 5]
|
||||
|
||||
def test_create_writes_file_and_refreshes(self, tmp_path: Path) -> None:
|
||||
validator = _load_validator()
|
||||
config = Mock(config_path=str(tmp_path), app_path=str(tmp_path))
|
||||
manager = TaskDefinitions.get_instance(directory=tmp_path, config=config, validator=validator)
|
||||
|
||||
with patch("app.library.task_handlers.generic.GenericTaskHandler.refresh_definitions") as refresh:
|
||||
definition = _sample_definition("My Definition")
|
||||
record = manager.create(definition)
|
||||
|
||||
refresh.assert_called_once_with(force=True)
|
||||
identifier_uuid = uuid.UUID(record.identifier)
|
||||
assert 4 == identifier_uuid.version
|
||||
expected_filename = f"{record.identifier}.json"
|
||||
saved_path = tmp_path / expected_filename
|
||||
assert saved_path.exists()
|
||||
saved_content = json.loads(saved_path.read_text(encoding="utf-8"))
|
||||
assert saved_content["name"] == "My Definition"
|
||||
assert saved_content["priority"] == 0
|
||||
|
||||
def test_update_missing_definition_raises(self, tmp_path: Path) -> None:
|
||||
validator = _load_validator()
|
||||
config = Mock(config_path=str(tmp_path), app_path=str(tmp_path))
|
||||
manager = TaskDefinitions.get_instance(directory=tmp_path, config=config, validator=validator)
|
||||
|
||||
with pytest.raises(ValueError, match="does not exist"):
|
||||
manager.update("missing", _sample_definition("Updated"))
|
||||
|
||||
def test_update_overwrites_file(self, tmp_path: Path) -> None:
|
||||
validator = _load_validator()
|
||||
config = Mock(config_path=str(tmp_path), app_path=str(tmp_path))
|
||||
|
||||
identifier = "c59ec7cf-6291-4f0f-86f8-d8cb12c325a4"
|
||||
initial = _sample_definition("Original", priority=4)
|
||||
path = tmp_path / f"{identifier}.json"
|
||||
path.write_text(json.dumps(initial), encoding="utf-8")
|
||||
|
||||
manager = TaskDefinitions.get_instance(directory=tmp_path, config=config, validator=validator)
|
||||
|
||||
with patch("app.library.task_handlers.generic.GenericTaskHandler.refresh_definitions") as refresh:
|
||||
updated_record = manager.update(identifier, _sample_definition("Updated", priority=2))
|
||||
|
||||
refresh.assert_called_once_with(force=True)
|
||||
assert updated_record.name == "Updated"
|
||||
assert updated_record.priority == 2
|
||||
saved = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert saved["name"] == "Updated"
|
||||
assert saved["priority"] == 2
|
||||
|
||||
def test_delete_removes_file(self, tmp_path: Path) -> None:
|
||||
validator = _load_validator()
|
||||
config = Mock(config_path=str(tmp_path), app_path=str(tmp_path))
|
||||
|
||||
identifier = "f0b71f47-6b65-4b6d-89fd-6b87ce47d3bc"
|
||||
definition_path = tmp_path / f"{identifier}.json"
|
||||
definition_path.write_text(json.dumps(_sample_definition("Delete")), encoding="utf-8")
|
||||
|
||||
manager = TaskDefinitions.get_instance(directory=tmp_path, config=config, validator=validator)
|
||||
|
||||
with patch("app.library.task_handlers.generic.GenericTaskHandler.refresh_definitions") as refresh:
|
||||
manager.delete(identifier)
|
||||
|
||||
refresh.assert_called_once_with(force=True)
|
||||
assert not definition_path.exists()
|
||||
assert manager.get(identifier) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestTaskDefinitionRoutes:
|
||||
def setup_method(self) -> None:
|
||||
TaskDefinitions._reset_singleton()
|
||||
|
||||
def teardown_method(self) -> None:
|
||||
TaskDefinitions._reset_singleton()
|
||||
|
||||
async def test_list_definitions(self) -> None:
|
||||
request = MagicMock(spec=Request)
|
||||
request.query = {}
|
||||
|
||||
identifier = "9af7018f-8659-4d2a-a42b-b5d2c5f0a6e2"
|
||||
record = TaskDefinitionRecord(
|
||||
identifier=identifier,
|
||||
filename=f"{identifier}.json",
|
||||
name="Sample",
|
||||
priority=0,
|
||||
path=Path("/tmp/sample.json"),
|
||||
data=_sample_definition("Sample"),
|
||||
updated_at=123.0,
|
||||
)
|
||||
|
||||
task_definitions = MagicMock(spec=TaskDefinitions)
|
||||
task_definitions.list.return_value = [record]
|
||||
|
||||
response = await api.task_definitions_list(request, Encoder(), task_definitions)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPOk.status_code
|
||||
assert payload == [record.serialize()]
|
||||
assert "filename" not in payload[0]
|
||||
|
||||
async def test_list_definitions_includes_definition(self) -> None:
|
||||
request = MagicMock(spec=Request)
|
||||
request.query = {"include": "definition"}
|
||||
|
||||
identifier = "f5b5e88d-5c6b-4a27-8453-1b6a4fb8a8d1"
|
||||
record = TaskDefinitionRecord(
|
||||
identifier=identifier,
|
||||
filename=f"{identifier}.json",
|
||||
name="Sample",
|
||||
priority=0,
|
||||
path=Path("/tmp/sample.json"),
|
||||
data=_sample_definition("Sample"),
|
||||
updated_at=123.0,
|
||||
)
|
||||
|
||||
task_definitions = MagicMock(spec=TaskDefinitions)
|
||||
task_definitions.list.return_value = [record]
|
||||
|
||||
response = await api.task_definitions_list(request, Encoder(), task_definitions)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert payload[0]["definition"]["name"] == "Sample"
|
||||
|
||||
async def test_get_definition_not_found(self) -> None:
|
||||
request = MagicMock(spec=Request)
|
||||
request.match_info = {"identifier": "unknown"}
|
||||
|
||||
task_definitions = MagicMock(spec=TaskDefinitions)
|
||||
task_definitions.get.return_value = None
|
||||
|
||||
response = await api.task_definitions_get(request, Encoder(), task_definitions)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPNotFound.status_code
|
||||
assert "error" in payload
|
||||
|
||||
async def test_create_definition_success(self) -> None:
|
||||
payload_definition = _sample_definition("New", priority=1)
|
||||
payload = {"definition": payload_definition}
|
||||
|
||||
request = MagicMock(spec=Request)
|
||||
request.json = AsyncMock(return_value=payload)
|
||||
|
||||
identifier = "4f08b8af-b87a-4d6e-9289-39e5172898aa"
|
||||
record = TaskDefinitionRecord(
|
||||
identifier=identifier,
|
||||
filename=f"{identifier}.json",
|
||||
name="New",
|
||||
priority=1,
|
||||
path=Path("/tmp/new.json"),
|
||||
data=payload["definition"],
|
||||
updated_at=123.0,
|
||||
)
|
||||
|
||||
task_definitions = MagicMock(spec=TaskDefinitions)
|
||||
task_definitions.create.return_value = record
|
||||
|
||||
response = await api.task_definitions_create(request, Encoder(), task_definitions)
|
||||
body = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPCreated.status_code
|
||||
assert body["id"] == identifier
|
||||
assert body["priority"] == 1
|
||||
assert "filename" not in body
|
||||
task_definitions.create.assert_called_once_with(payload_definition)
|
||||
|
||||
async def test_create_definition_invalid_payload(self) -> None:
|
||||
request = MagicMock(spec=Request)
|
||||
request.json = AsyncMock(return_value=[]) # type: ignore[arg-type]
|
||||
|
||||
task_definitions = MagicMock(spec=TaskDefinitions)
|
||||
|
||||
response = await api.task_definitions_create(request, Encoder(), task_definitions)
|
||||
body = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPBadRequest.status_code
|
||||
assert "error" in body
|
||||
|
||||
async def test_update_definition_success(self) -> None:
|
||||
request = MagicMock(spec=Request)
|
||||
identifier = "6d8d5719-95ae-4478-bb05-986f5b72b6c1"
|
||||
request.match_info = {"identifier": identifier}
|
||||
definition = _sample_definition("Updated", priority=4)
|
||||
request.json = AsyncMock(return_value={"definition": definition})
|
||||
record = TaskDefinitionRecord(
|
||||
identifier=identifier,
|
||||
filename=f"{identifier}.json",
|
||||
name="Updated",
|
||||
priority=4,
|
||||
path=Path("/tmp/existing.json"),
|
||||
data=definition,
|
||||
updated_at=456.0,
|
||||
)
|
||||
|
||||
task_definitions = MagicMock(spec=TaskDefinitions)
|
||||
task_definitions.update.return_value = record
|
||||
|
||||
response = await api.task_definitions_update(request, Encoder(), task_definitions)
|
||||
body = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPOk.status_code
|
||||
assert body["name"] == "Updated"
|
||||
assert body["priority"] == 4
|
||||
task_definitions.update.assert_called_once_with(identifier, definition)
|
||||
|
||||
async def test_update_definition_missing_identifier(self) -> None:
|
||||
request = MagicMock(spec=Request)
|
||||
request.match_info = {"identifier": ""}
|
||||
request.json = AsyncMock(return_value={})
|
||||
|
||||
task_definitions = MagicMock(spec=TaskDefinitions)
|
||||
|
||||
response = await api.task_definitions_update(request, Encoder(), task_definitions)
|
||||
body = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPBadRequest.status_code
|
||||
assert "error" in body
|
||||
|
||||
async def test_delete_definition_success(self) -> None:
|
||||
request = MagicMock(spec=Request)
|
||||
identifier = "c9f4ac6c-a4ab-4d1a-8d25-764dc0c8a3f0"
|
||||
request.match_info = {"identifier": identifier}
|
||||
|
||||
task_definitions = MagicMock(spec=TaskDefinitions)
|
||||
|
||||
response = await api.task_definitions_delete(request, task_definitions)
|
||||
body = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPOk.status_code
|
||||
assert body["status"] == "deleted"
|
||||
task_definitions.delete.assert_called_once_with(identifier)
|
||||
|
||||
async def test_delete_definition_missing_identifier(self) -> None:
|
||||
request = MagicMock(spec=Request)
|
||||
request.match_info = {"identifier": ""}
|
||||
|
||||
task_definitions = MagicMock(spec=TaskDefinitions)
|
||||
|
||||
response = await api.task_definitions_delete(request, task_definitions)
|
||||
body = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPBadRequest.status_code
|
||||
assert "error" in body
|
||||
|
|
@ -43,6 +43,7 @@ dependencies = [
|
|||
"selenium>=4.35.0",
|
||||
"parsel>=1.10.0",
|
||||
"jmespath>=1.0.1",
|
||||
"jsonschema>=4.23.0",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
|
|
@ -164,6 +165,8 @@ ignore = [
|
|||
"PLC0415",
|
||||
"S603",
|
||||
"D203",
|
||||
"TRY004", # Like it's our choice to use ValuesError :|
|
||||
"PT011",
|
||||
]
|
||||
|
||||
# Allow fix for all enabled rules (when `--fix`) is provided.
|
||||
|
|
|
|||
870
ui/app/components/TaskDefinitionEditor.vue
Normal file
870
ui/app/components/TaskDefinitionEditor.vue
Normal file
|
|
@ -0,0 +1,870 @@
|
|||
<template>
|
||||
<section class="card">
|
||||
<header class="card-header">
|
||||
<p class="card-header-title">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-pen-ruler" /></span>
|
||||
<span>{{ headerTitle }}</span>
|
||||
</span>
|
||||
</p>
|
||||
<div class="card-header-icon is-flex is-align-items-center">
|
||||
<button type="button" class="button is-small" @click="() => showImport = !showImport" :disabled="isBusy">
|
||||
<span class="icon"><i class="fa-solid"
|
||||
:class="{ 'fa-arrow-down': !showImport, 'fa-arrow-up': showImport }" /></span>
|
||||
<span>{{ showImport ? 'Hide import' : 'Show import' }}</span>
|
||||
</button>
|
||||
<div class="buttons has-addons ml-2">
|
||||
<button type="button" class="button is-small"
|
||||
:class="{ 'is-primary': 'gui' === mode, 'is-light': 'gui' !== mode }" @click="() => switchMode('gui')"
|
||||
:disabled="!guiSupported || isBusy">
|
||||
<span class="icon"><i class="fa-solid fa-sliders" /></span>
|
||||
<span>GUI</span>
|
||||
</button>
|
||||
<button type="button" class="button is-small"
|
||||
:class="{ 'is-primary': 'advanced' === mode, 'is-light': 'advanced' !== mode }"
|
||||
@click="() => switchMode('advanced')" :disabled="isBusy">
|
||||
<span class="icon"><i class="fa-solid fa-code" /></span>
|
||||
<span>Advanced</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="card-content">
|
||||
<div class="columns is-multiline" v-if="showImport">
|
||||
<div class="column is-12" v-if="availableDefinitions.length">
|
||||
<div class="field">
|
||||
<label class="label is-inline">
|
||||
<span class="icon"><i class="fa-solid fa-diagram-project" /></span>
|
||||
Import from existing
|
||||
</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select v-model="selectedExisting" :disabled="isBusy" @change="importExisting">
|
||||
<option value="">Select a definition</option>
|
||||
<option v-for="item in availableDefinitions" :key="item.id" :value="item.id">
|
||||
{{ item.name || item.id }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help is-bold">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Loads an existing definition into the editor. Changes are not saved until you submit.</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12">
|
||||
<div class="field">
|
||||
<label class="label is-inline">
|
||||
<span class="icon"><i class="fa-solid fa-file-import" /></span>
|
||||
Import string
|
||||
</label>
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded">
|
||||
<input class="input" type="text" v-model="importString" :disabled="isBusy" autocomplete="off">
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="button" @click="importFromString"
|
||||
:disabled="isBusy || !importString.trim()">
|
||||
<span class="icon"><i class="fa-solid fa-file-import" /></span>
|
||||
<span>Import</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help is-bold">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Pastes a shared task definition string. Importing replaces the current editor contents.</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Message v-if="!guiSupported" message_class="is-warning">
|
||||
<p>
|
||||
<span>
|
||||
<span class="icon"><i class="fa-solid fa-triangle-exclamation" /></span>
|
||||
<span>This task definition uses features that cannot be represented with the visual editor. You can still
|
||||
update it
|
||||
via the advanced view.</span>
|
||||
</span>
|
||||
</p>
|
||||
</Message>
|
||||
|
||||
<div v-if="'gui' === mode">
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-6">
|
||||
<div class="field">
|
||||
<label class="label is-inline">
|
||||
<span class="icon"><i class="fa-solid fa-heading" /></span>
|
||||
Name
|
||||
</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" v-model="guiState.name" :disabled="isBusy">
|
||||
</div>
|
||||
<p class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Human readable label for this definition.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-3">
|
||||
<div class="field">
|
||||
<label class="label is-inline">
|
||||
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
|
||||
Priority
|
||||
</label>
|
||||
<div class="control">
|
||||
<input class="input" type="number" min="0" v-model.number="guiState.priority" :disabled="isBusy">
|
||||
</div>
|
||||
<p class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Lower values are evaluated first.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12">
|
||||
<div class="field">
|
||||
<label class="label is-inline">
|
||||
<span class="icon"><i class="fa-solid fa-filter" /></span>
|
||||
Match patterns
|
||||
</label>
|
||||
<div class="control">
|
||||
<textarea class="textarea" rows="3" v-model="guiState.matchText" :disabled="isBusy"
|
||||
placeholder="https://example.com/* https://example.org/channel/*" />
|
||||
</div>
|
||||
<p class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>One glob per line. Regex or object based rules are only supported in advanced mode.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="field">
|
||||
<label class="label is-inline">
|
||||
<span class="icon"><i class="fa-solid fa-gears" /></span>
|
||||
Engine
|
||||
</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select v-model="guiState.engineType" :disabled="isBusy">
|
||||
<option value="httpx">HTTPX</option>
|
||||
<option value="selenium">Selenium</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Choose the fetch engine. You should use HTTPX when possible.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6" v-if="'selenium' === guiState.engineType">
|
||||
<div class="field">
|
||||
<label class="label is-inline">
|
||||
<span class="icon"><i class="fa-solid fa-plug" /></span>
|
||||
Selenium Hub URL (Required)
|
||||
</label>
|
||||
<div class="control">
|
||||
<input class="input" type="url" v-model="guiState.engineUrl" :disabled="isBusy"
|
||||
placeholder="http://selenium:4444/wd/hub">
|
||||
</div>
|
||||
<p class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Remote webdriver endpoint.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="field">
|
||||
<label class="label is-inline">
|
||||
<span class="icon"><i class="fa-solid fa-server" /></span>
|
||||
Request Method
|
||||
</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select v-model="guiState.requestMethod" :disabled="isBusy">
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>HTTP method to use when fetching the page.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="field">
|
||||
<label class="label is-inline">
|
||||
<span class="icon"><i class="fa-solid fa-link" /></span>
|
||||
Request URL (optional)
|
||||
</label>
|
||||
<div class="control">
|
||||
<input class="input" type="url" v-model="guiState.requestUrl" :disabled="isBusy"
|
||||
placeholder="https://example.com/feed">
|
||||
</div>
|
||||
<p class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Overrides the URL used to fetch the page. Useful for sites with separate feed URLs.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12">
|
||||
<article class="message is-info" v-if="guiLimitations">
|
||||
<div class="message-body">
|
||||
{{ guiLimitations }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="column is-12">
|
||||
<div>
|
||||
<h4 class="title is-6">Container selector</h4>
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-4">
|
||||
<div class="field">
|
||||
<label class="label is-inline">
|
||||
<span class="icon"><i class="fa-solid fa-list" /></span>
|
||||
Type
|
||||
</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select v-model="guiState.containerType" :disabled="isBusy">
|
||||
<option value="css">CSS</option>
|
||||
<option value="xpath">XPath</option>
|
||||
<option value="jsonpath">JSONPath</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-8">
|
||||
<div class="field">
|
||||
<label class="label is-inline">
|
||||
<span class="icon"><i class="fa-solid fa-crosshairs" /></span>
|
||||
Selector / Expression
|
||||
</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" v-model="guiState.containerSelector" :disabled="isBusy"
|
||||
placeholder="div.card">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="title is-6 mt-4">Extracted fields</h4>
|
||||
<div class="field">
|
||||
<button class="button is-small is-primary" type="button" @click="addField" :disabled="isBusy">
|
||||
<span class="icon"><i class="fa-solid fa-plus" /></span>
|
||||
<span>Add field</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="table is-fullwidth is-hoverable is-striped">
|
||||
<thead>
|
||||
<tr class="is-unselectable">
|
||||
<th>Key</th>
|
||||
<th>Type</th>
|
||||
<th>Expression</th>
|
||||
<th>Attribute</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="!guiState.fields.length">
|
||||
<td colspan="5" class="has-text-centered">No extractor fields configured.</td>
|
||||
</tr>
|
||||
<tr v-for="(field, index) in guiState.fields" :key="field.key">
|
||||
<td>
|
||||
<input class="input" type="text" v-model="field.key" :disabled="isBusy">
|
||||
</td>
|
||||
<td>
|
||||
<div class="select is-fullwidth">
|
||||
<select v-model="field.type" :disabled="isBusy">
|
||||
<option value="css">CSS</option>
|
||||
<option value="xpath">XPath</option>
|
||||
<option value="regex">Regex</option>
|
||||
<option value="jsonpath">JSONPath</option>
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<input class="input" type="text" v-model="field.expression" :disabled="isBusy">
|
||||
</td>
|
||||
<td>
|
||||
<input class="input" type="text" v-model="field.attribute" :disabled="isBusy"
|
||||
placeholder="Optional">
|
||||
</td>
|
||||
<td class="has-text-right">
|
||||
<button class="button is-small is-danger" type="button" @click="removeField(index)"
|
||||
:disabled="isBusy">
|
||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="guiError" class="help is-danger">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-circle-exclamation" /></span>
|
||||
<span>{{ guiError }}</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<textarea class="textarea is-family-monospace" rows="18" v-model="jsonText" :readonly="submitting"
|
||||
spellcheck="false" />
|
||||
<p v-if="errorMessage" class="help is-danger mt-2">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-circle-exclamation" /></span>
|
||||
<span>{{ errorMessage }}</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="card-footer p-4">
|
||||
<div class="buttons">
|
||||
<button class="button is-primary" type="button" @click="submit" :disabled="isBusy">
|
||||
<span class="icon"><i class="fa-solid fa-floppy-disk" /></span>
|
||||
<span>Save</span>
|
||||
</button>
|
||||
<button class="button is-danger" type="button" @click="cancel" :disabled="submitting">
|
||||
<span class="icon"><i class="fa-solid fa-xmark" /></span>
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
<button class="button is-link" type="button" v-if="'advanced' === mode" @click="beautify" :disabled="isBusy">
|
||||
<span class="icon"><i class="fa-solid fa-wand-magic-sparkles" /></span>
|
||||
<span>Format</span>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
import Message from '~/components/Message.vue'
|
||||
import { decode } from '~/utils'
|
||||
import type { TaskDefinitionDocument, TaskDefinitionSummary } from '~/types/task_definitions'
|
||||
|
||||
type EditorMode = 'gui' | 'advanced'
|
||||
|
||||
type GuiField = {
|
||||
key: string
|
||||
type: string
|
||||
expression: string
|
||||
attribute: string
|
||||
}
|
||||
|
||||
type GuiState = {
|
||||
name: string
|
||||
priority: number
|
||||
matchText: string
|
||||
engineType: 'httpx' | 'selenium'
|
||||
engineUrl: string
|
||||
requestMethod: string
|
||||
requestUrl: string
|
||||
containerType: 'css' | 'xpath' | 'jsonpath'
|
||||
containerSelector: string
|
||||
fields: GuiField[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
document: TaskDefinitionDocument | null
|
||||
loading?: boolean
|
||||
submitting?: boolean
|
||||
availableDefinitions?: readonly TaskDefinitionSummary[]
|
||||
initialShowImport?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'submit', payload: TaskDefinitionDocument): void
|
||||
(e: 'cancel'): void
|
||||
(e: 'import-existing', id: string): void
|
||||
}>()
|
||||
|
||||
const jsonText = ref<string>('')
|
||||
const errorMessage = ref<string | null>(null)
|
||||
const guiError = ref<string | null>(null)
|
||||
const guiSupported = ref<boolean>(true)
|
||||
const mode = ref<EditorMode>('gui')
|
||||
const showImport = ref<boolean>(false)
|
||||
const importString = ref<string>('')
|
||||
const selectedExisting = ref<string>('')
|
||||
|
||||
const availableDefinitions = computed<readonly TaskDefinitionSummary[]>(() => props.availableDefinitions ?? [])
|
||||
|
||||
const guiState = reactive<GuiState>({
|
||||
name: '',
|
||||
priority: 0,
|
||||
matchText: '',
|
||||
engineType: 'httpx',
|
||||
engineUrl: '',
|
||||
requestMethod: 'GET',
|
||||
requestUrl: '',
|
||||
containerType: 'css',
|
||||
containerSelector: '',
|
||||
fields: [],
|
||||
})
|
||||
|
||||
const loading = computed<boolean>(() => props.loading ?? false)
|
||||
const submitting = computed<boolean>(() => props.submitting ?? false)
|
||||
const isBusy = computed<boolean>(() => loading.value || submitting.value)
|
||||
const headerTitle = computed<string>(() => props.title)
|
||||
|
||||
const guiLimitations = 'Only simple match globs, a single container selector and per-field extractors are exposed. ' +
|
||||
'More advanced constructs require editing the JSON.'
|
||||
|
||||
const resetGuiState = (state: GuiState): void => {
|
||||
guiState.name = state.name
|
||||
guiState.priority = state.priority
|
||||
guiState.matchText = state.matchText
|
||||
guiState.engineType = state.engineType
|
||||
guiState.engineUrl = state.engineUrl
|
||||
guiState.requestMethod = state.requestMethod
|
||||
guiState.requestUrl = state.requestUrl
|
||||
guiState.containerType = state.containerType
|
||||
guiState.containerSelector = state.containerSelector
|
||||
guiState.fields = state.fields.map(field => ({ ...field }))
|
||||
}
|
||||
|
||||
const defaultField = (): GuiField => ({ key: '', type: 'css', expression: '', attribute: '' })
|
||||
|
||||
const addField = (): void => {
|
||||
guiState.fields.push(defaultField())
|
||||
}
|
||||
|
||||
const removeField = (index: number): void => {
|
||||
guiState.fields.splice(index, 1)
|
||||
}
|
||||
|
||||
const splitMatches = (text: string): string[] => {
|
||||
return text.split(/\r?\n/).map(item => item.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
const toGui = (document: TaskDefinitionDocument): GuiState | null => {
|
||||
if (!document || Array.isArray(document) || 'object' !== typeof document) {
|
||||
return null
|
||||
}
|
||||
|
||||
const entry = document as Record<string, unknown>
|
||||
const match = entry.match
|
||||
if (!Array.isArray(match) || match.some(item => 'string' !== typeof item)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parse = entry.parse
|
||||
if (!parse || Array.isArray(parse) || 'object' !== typeof parse) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parseRecord = parse as Record<string, unknown>
|
||||
const items = parseRecord.items
|
||||
if (!items || Array.isArray(items) || 'object' !== typeof items) {
|
||||
return null
|
||||
}
|
||||
|
||||
const itemRecord = items as Record<string, unknown>
|
||||
const fields = itemRecord.fields
|
||||
if (!fields || Array.isArray(fields) || 'object' !== typeof fields) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fieldRecord = fields as Record<string, unknown>
|
||||
const guiFields: GuiField[] = []
|
||||
for (const [key, value] of Object.entries(fieldRecord)) {
|
||||
if (!value || Array.isArray(value) || 'object' !== typeof value) {
|
||||
return null
|
||||
}
|
||||
const rule = value as Record<string, unknown>
|
||||
if ('string' !== typeof rule.type || 'string' !== typeof rule.expression) {
|
||||
return null
|
||||
}
|
||||
if (Object.keys(rule).some(prop => !['type', 'expression', 'attribute'].includes(prop))) {
|
||||
return null
|
||||
}
|
||||
guiFields.push({
|
||||
key,
|
||||
type: String(rule.type),
|
||||
expression: String(rule.expression),
|
||||
attribute: 'string' === typeof rule.attribute ? String(rule.attribute) : '',
|
||||
})
|
||||
}
|
||||
|
||||
const engine = entry.engine as Record<string, unknown> | undefined
|
||||
const engineType = (engine?.type === 'selenium') ? 'selenium' : 'httpx'
|
||||
const engineUrl = 'string' === typeof engine?.options && engineType === 'selenium'
|
||||
? ''
|
||||
: (engine?.options as Record<string, unknown> | undefined)?.url as string | undefined
|
||||
|
||||
if (engineUrl && engineType === 'selenium' && 'string' !== typeof engineUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
const request = entry.request as Record<string, unknown> | undefined
|
||||
|
||||
const selectorType = String(itemRecord.type ?? 'css') as GuiState['containerType']
|
||||
const selectorSource = (itemRecord.selector ?? itemRecord.expression) as string | undefined
|
||||
if (!selectorSource || 'string' !== typeof selectorSource) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'string' === typeof entry.name ? entry.name : '',
|
||||
priority: Number(entry.priority ?? 0) || 0,
|
||||
matchText: match.join('\n'),
|
||||
engineType,
|
||||
engineUrl: engineType === 'selenium' ? String(engineUrl ?? '') : '',
|
||||
requestMethod: 'string' === typeof request?.method ? String(request?.method) : 'GET',
|
||||
requestUrl: 'string' === typeof request?.url ? String(request?.url) : '',
|
||||
containerType: selectorType,
|
||||
containerSelector: selectorSource,
|
||||
fields: guiFields.length ? guiFields : [defaultField()],
|
||||
}
|
||||
}
|
||||
|
||||
const fromGui = (state: GuiState): TaskDefinitionDocument => {
|
||||
if (!state.name.trim()) {
|
||||
throw new Error('Name is required.')
|
||||
}
|
||||
|
||||
const matches = splitMatches(state.matchText)
|
||||
if (!matches.length) {
|
||||
throw new Error('At least one match pattern is required.')
|
||||
}
|
||||
|
||||
if (!state.containerSelector.trim()) {
|
||||
throw new Error('Container selector is required.')
|
||||
}
|
||||
|
||||
const formattedFields: Record<string, Record<string, string>> = {}
|
||||
state.fields.forEach(field => {
|
||||
if (!field.key.trim()) {
|
||||
return
|
||||
}
|
||||
if (!field.expression.trim()) {
|
||||
throw new Error(`Expression is required for field "${field.key}".`)
|
||||
}
|
||||
formattedFields[field.key.trim()] = {
|
||||
type: field.type || 'css',
|
||||
expression: field.expression,
|
||||
...(field.attribute ? { attribute: field.attribute } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
if (!Object.keys(formattedFields).length) {
|
||||
throw new Error('Configure at least one extractor field.')
|
||||
}
|
||||
|
||||
const doc: Record<string, unknown> = {
|
||||
name: state.name.trim(),
|
||||
priority: Number(state.priority) || 0,
|
||||
match: matches,
|
||||
parse: {
|
||||
items: {
|
||||
type: state.containerType,
|
||||
selector: state.containerType === 'jsonpath' ? undefined : state.containerSelector,
|
||||
expression: state.containerType === 'jsonpath' ? state.containerSelector : undefined,
|
||||
fields: formattedFields,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if ('httpx' !== state.engineType || state.engineUrl) {
|
||||
doc.engine = {
|
||||
type: state.engineType,
|
||||
...(state.engineType === 'selenium' && state.engineUrl
|
||||
? { options: { url: state.engineUrl } }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
const request: Record<string, string> = {}
|
||||
if (state.requestMethod && 'GET' !== state.requestMethod) {
|
||||
request.method = state.requestMethod
|
||||
}
|
||||
if (state.requestUrl) {
|
||||
request.url = state.requestUrl
|
||||
}
|
||||
if (Object.keys(request).length) {
|
||||
doc.request = request
|
||||
}
|
||||
|
||||
return doc as TaskDefinitionDocument
|
||||
}
|
||||
|
||||
const parseImportedDocument = (payload: unknown): TaskDefinitionDocument => {
|
||||
if (!payload || Array.isArray(payload) || 'object' !== typeof payload) {
|
||||
throw new Error('Import payload is not a task definition object.')
|
||||
}
|
||||
|
||||
const record = payload as Record<string, unknown>
|
||||
const candidate = record.definition
|
||||
if ('_type' in record && record._type !== undefined && record._type !== 'task_definition') {
|
||||
throw new Error('Import string is not a task definition export.')
|
||||
}
|
||||
let base: TaskDefinitionDocument
|
||||
|
||||
if (candidate && !Array.isArray(candidate) && 'object' === typeof candidate) {
|
||||
base = candidate as TaskDefinitionDocument
|
||||
}
|
||||
else {
|
||||
base = payload as TaskDefinitionDocument
|
||||
}
|
||||
|
||||
const clone = JSON.parse(JSON.stringify(base)) as TaskDefinitionDocument
|
||||
const cloneRecord = clone as Record<string, unknown>
|
||||
|
||||
if ('name' in record && 'string' === typeof record.name) {
|
||||
cloneRecord.name = record.name
|
||||
}
|
||||
|
||||
if ('priority' in record && record.priority !== undefined) {
|
||||
cloneRecord.priority = Number(record.priority) || 0
|
||||
}
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
const parseDocument = (): TaskDefinitionDocument | null => {
|
||||
try {
|
||||
if (!jsonText.value.trim()) {
|
||||
throw new Error('Definition cannot be empty.')
|
||||
}
|
||||
const parsed = JSON.parse(jsonText.value) as unknown
|
||||
if (!parsed || Array.isArray(parsed) || 'object' !== typeof parsed) {
|
||||
throw new Error('Definition must be a JSON object.')
|
||||
}
|
||||
errorMessage.value = null
|
||||
return parsed as TaskDefinitionDocument
|
||||
}
|
||||
catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : 'Invalid JSON document.'
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const applyDocument = (document: TaskDefinitionDocument | null): void => {
|
||||
const shouldShowImport = props.initialShowImport ?? !document
|
||||
showImport.value = shouldShowImport
|
||||
importString.value = ''
|
||||
selectedExisting.value = ''
|
||||
|
||||
if (!document) {
|
||||
jsonText.value = ''
|
||||
guiSupported.value = true
|
||||
resetGuiState({
|
||||
name: '',
|
||||
priority: 0,
|
||||
matchText: '',
|
||||
engineType: 'httpx',
|
||||
engineUrl: '',
|
||||
requestMethod: 'GET',
|
||||
requestUrl: '',
|
||||
containerType: 'css',
|
||||
containerSelector: '',
|
||||
fields: [defaultField()],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
jsonText.value = JSON.stringify(document, null, 2)
|
||||
const gui = toGui(document)
|
||||
if (gui) {
|
||||
guiSupported.value = true
|
||||
resetGuiState(gui)
|
||||
guiError.value = null
|
||||
if ('gui' !== mode.value) {
|
||||
mode.value = 'gui'
|
||||
}
|
||||
}
|
||||
else {
|
||||
guiSupported.value = false
|
||||
mode.value = 'advanced'
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to prepare definition for editing.', error)
|
||||
jsonText.value = ''
|
||||
guiSupported.value = false
|
||||
mode.value = 'advanced'
|
||||
errorMessage.value = 'Failed to prepare definition for editing.'
|
||||
}
|
||||
}
|
||||
|
||||
const importFromString = (): void => {
|
||||
if (isBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!importString.value.trim()) {
|
||||
guiError.value = 'Import string cannot be empty.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = decode(importString.value.trim())
|
||||
const document = parseImportedDocument(decoded)
|
||||
applyDocument(document)
|
||||
guiError.value = null
|
||||
errorMessage.value = null
|
||||
importString.value = ''
|
||||
showImport.value = false
|
||||
}
|
||||
catch (error) {
|
||||
guiError.value = error instanceof Error ? error.message : 'Unable to import definition.'
|
||||
}
|
||||
}
|
||||
|
||||
const importExisting = (): void => {
|
||||
if (!selectedExisting.value || isBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('import-existing', selectedExisting.value)
|
||||
selectedExisting.value = ''
|
||||
}
|
||||
|
||||
watch(() => props.document, (doc) => applyDocument(doc), { immediate: true })
|
||||
|
||||
const switchMode = (next: EditorMode): void => {
|
||||
if (isBusy.value || next === mode.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if ('gui' === next) {
|
||||
if (!guiSupported.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = parseDocument()
|
||||
if (!parsed) {
|
||||
return
|
||||
}
|
||||
|
||||
const gui = toGui(parsed)
|
||||
if (!gui) {
|
||||
guiSupported.value = false
|
||||
return
|
||||
}
|
||||
resetGuiState(gui)
|
||||
guiSupported.value = true
|
||||
}
|
||||
|
||||
if ('advanced' === next) {
|
||||
try {
|
||||
const doc = fromGui(guiState)
|
||||
jsonText.value = JSON.stringify(doc, null, 2)
|
||||
errorMessage.value = null
|
||||
guiError.value = null
|
||||
}
|
||||
catch (error) {
|
||||
guiError.value = error instanceof Error ? error.message : 'Failed to serialize GUI changes.'
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
mode.value = next
|
||||
}
|
||||
|
||||
const submit = (): void => {
|
||||
if (isBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if ('gui' === mode.value) {
|
||||
try {
|
||||
const doc = fromGui(guiState)
|
||||
emit('submit', doc)
|
||||
guiError.value = null
|
||||
}
|
||||
catch (error) {
|
||||
guiError.value = error instanceof Error ? error.message : 'Unable to build definition.'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = parseDocument()
|
||||
if (!parsed) {
|
||||
return
|
||||
}
|
||||
emit('submit', parsed)
|
||||
}
|
||||
|
||||
const beautify = (): void => {
|
||||
if ('advanced' !== mode.value) {
|
||||
return
|
||||
}
|
||||
const parsed = parseDocument()
|
||||
if (!parsed) {
|
||||
return
|
||||
}
|
||||
jsonText.value = JSON.stringify(parsed, null, 2)
|
||||
errorMessage.value = null
|
||||
}
|
||||
|
||||
const cancel = (): void => {
|
||||
if (submitting.value) {
|
||||
return
|
||||
}
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
defineExpose({ submit, beautify })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.textarea {
|
||||
min-height: 16rem;
|
||||
font-family: var(--font-monospace, 'JetBrains Mono', monospace);
|
||||
}
|
||||
|
||||
.card-footer .buttons {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.buttons.has-addons .button {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.buttons.has-addons .button:first-child {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.buttons.has-addons .button:last-child {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.table td,
|
||||
.table th {
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -82,7 +82,7 @@ function notify(type: notificationType, message: string, opts?: notificationOpti
|
|||
}
|
||||
}
|
||||
|
||||
export default function useNotification() {
|
||||
export const useNotification = () => {
|
||||
return {
|
||||
info: (message: string, opts?: notificationOptions) => notify('info', message, opts),
|
||||
success: (message: string, opts?: notificationOptions) => notify('success', message, opts),
|
||||
|
|
|
|||
309
ui/app/composables/useTaskDefinitions.ts
Normal file
309
ui/app/composables/useTaskDefinitions.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import { ref, readonly } from 'vue'
|
||||
|
||||
import { useNotification } from '~/composables/useNotification'
|
||||
import { request } from '~/utils'
|
||||
import type {
|
||||
TaskDefinitionDetailed,
|
||||
TaskDefinitionDocument,
|
||||
TaskDefinitionErrorResponse,
|
||||
TaskDefinitionSummary,
|
||||
} from '~/types/task_definitions'
|
||||
|
||||
/**
|
||||
* Reactive list of all task definition summaries, sorted by priority and name.
|
||||
*/
|
||||
const definitions = ref<Array<TaskDefinitionSummary>>([])
|
||||
/**
|
||||
* Indicates if a request is in progress.
|
||||
*/
|
||||
const isLoading = ref<boolean>(false)
|
||||
/**
|
||||
* Stores the last error message, if any.
|
||||
*/
|
||||
const lastError = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* If true, methods will throw errors instead of returning null/false (for testing)
|
||||
*/
|
||||
const throwInstead = ref(false)
|
||||
|
||||
/**
|
||||
* Notification composable for showing success/error messages.
|
||||
*/
|
||||
const notify = useNotification()
|
||||
|
||||
/**
|
||||
* Sorts task definition summaries by priority (ascending), then name (A-Z).
|
||||
* @param items Array of TaskDefinitionSummary
|
||||
* @returns Sorted array of TaskDefinitionSummary
|
||||
*/
|
||||
const sortSummaries = (items: Array<TaskDefinitionSummary>): Array<TaskDefinitionSummary> => {
|
||||
return [...items].sort((a, b) => {
|
||||
if (a.priority === b.priority) {
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
|
||||
return a.priority - b.priority
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely reads JSON from a Response, returns null on error.
|
||||
* @param response Fetch Response object
|
||||
* @returns Parsed JSON or null
|
||||
*/
|
||||
const readJson = async (response: Response): Promise<unknown> => {
|
||||
try {
|
||||
const clone = response.clone()
|
||||
return await clone.json()
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws an error if the response is not OK, using API error message if available.
|
||||
* @param response Fetch Response object
|
||||
* @throws Error with message from API or status code
|
||||
*/
|
||||
const ensureSuccess = async (response: Response): Promise<void> => {
|
||||
if (response.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = await readJson(response)
|
||||
|
||||
let message = `Request failed with status ${response.status}`
|
||||
if (payload && typeof payload === 'object' && 'error' in payload) {
|
||||
const errorPayload = payload as TaskDefinitionErrorResponse
|
||||
if (typeof errorPayload.error === 'string' && errorPayload.error.length > 0) {
|
||||
message = errorPayload.error
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles errors by updating lastError and showing a notification.
|
||||
* @param error Error object or unknown
|
||||
*/
|
||||
const handleError = (error: unknown): void => {
|
||||
const message = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
lastError.value = message
|
||||
notify.error(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates or adds a summary in the definitions list, keeping sort order.
|
||||
* @param summary TaskDefinitionSummary to update/add
|
||||
*/
|
||||
const updateSummaries = (summary: TaskDefinitionSummary): void => {
|
||||
definitions.value = sortSummaries([
|
||||
...definitions.value.filter(item => item.id !== summary.id),
|
||||
summary,
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a summary from the definitions list by ID.
|
||||
* @param id Task definition ID
|
||||
*/
|
||||
const removeSummary = (id: string) => definitions.value = definitions.value.filter(item => item.id !== id)
|
||||
|
||||
/**
|
||||
* Loads all task definition summaries from the API.
|
||||
* Updates definitions and lastError.
|
||||
*/
|
||||
const loadDefinitions = async (): Promise<void> => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await request('/api/task_definitions/')
|
||||
await ensureSuccess(response)
|
||||
|
||||
const payload = await response.json() as unknown
|
||||
if (!Array.isArray(payload)) {
|
||||
throw new Error('Unexpected response while loading task definitions.')
|
||||
}
|
||||
|
||||
const summaries: Array<TaskDefinitionSummary> = payload.map(item => {
|
||||
if (!item || 'object' !== typeof item) {
|
||||
throw new Error('Encountered malformed task definition entry.')
|
||||
}
|
||||
|
||||
const entry = item as Record<string, unknown>
|
||||
return {
|
||||
id: String(entry.id ?? ''),
|
||||
name: String(entry.name ?? ''),
|
||||
priority: Number(entry.priority ?? 0),
|
||||
updated_at: Number(entry.updated_at ?? 0),
|
||||
}
|
||||
})
|
||||
|
||||
definitions.value = sortSummaries(summaries)
|
||||
lastError.value = null
|
||||
}
|
||||
catch (error) {
|
||||
handleError(error)
|
||||
if (throwInstead.value) throw error
|
||||
}
|
||||
finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a detailed task definition by ID from the API.
|
||||
* @param id Task definition ID
|
||||
* @returns TaskDefinitionDetailed or null on error
|
||||
*/
|
||||
const getDefinition = async (id: string): Promise<TaskDefinitionDetailed | null> => {
|
||||
try {
|
||||
const response = await request(`/api/task_definitions/${id}`)
|
||||
await ensureSuccess(response)
|
||||
|
||||
const payload = await response.json() as unknown
|
||||
if (!payload || 'object' !== typeof payload) {
|
||||
throw new Error('Unexpected response while retrieving task definition.')
|
||||
}
|
||||
|
||||
const entry = payload as Record<string, unknown>
|
||||
if (!('definition' in entry) || 'object' !== typeof entry.definition) {
|
||||
throw new Error('Task definition response is missing definition payload.')
|
||||
}
|
||||
|
||||
const detailed: TaskDefinitionDetailed = {
|
||||
id: String(entry.id ?? ''),
|
||||
name: String(entry.name ?? ''),
|
||||
priority: Number(entry.priority ?? 0),
|
||||
updated_at: Number(entry.updated_at ?? 0),
|
||||
definition: entry.definition as TaskDefinitionDocument,
|
||||
}
|
||||
|
||||
lastError.value = null
|
||||
return detailed
|
||||
}
|
||||
catch (error) {
|
||||
handleError(error)
|
||||
if (throwInstead.value) throw error
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new task definition via API.
|
||||
* @param definition TaskDefinitionDocument to create
|
||||
* @returns Created TaskDefinitionDetailed or null on error
|
||||
*/
|
||||
const createDefinition = async (definition: TaskDefinitionDocument): Promise<TaskDefinitionDetailed | null> => {
|
||||
try {
|
||||
const response = await request('/api/task_definitions/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ definition }),
|
||||
})
|
||||
|
||||
await ensureSuccess(response)
|
||||
|
||||
const payload = await response.json() as TaskDefinitionDetailed
|
||||
|
||||
updateSummaries({
|
||||
id: payload.id,
|
||||
name: payload.name,
|
||||
priority: payload.priority,
|
||||
updated_at: payload.updated_at,
|
||||
})
|
||||
|
||||
notify.success('Task definition created.')
|
||||
lastError.value = null
|
||||
return payload
|
||||
}
|
||||
catch (error) {
|
||||
handleError(error)
|
||||
if (throwInstead.value) throw error
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing task definition via API.
|
||||
* @param id Task definition ID
|
||||
* @param definition Updated TaskDefinitionDocument
|
||||
* @returns Updated TaskDefinitionDetailed or null on error
|
||||
*/
|
||||
const updateDefinition = async (id: string, definition: TaskDefinitionDocument): Promise<TaskDefinitionDetailed | null> => {
|
||||
try {
|
||||
const response = await request(`/api/task_definitions/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ definition }),
|
||||
})
|
||||
|
||||
await ensureSuccess(response)
|
||||
|
||||
const payload = await response.json() as TaskDefinitionDetailed
|
||||
|
||||
updateSummaries({
|
||||
id: payload.id,
|
||||
name: payload.name,
|
||||
priority: payload.priority,
|
||||
updated_at: payload.updated_at,
|
||||
})
|
||||
|
||||
notify.success('Task definition updated.')
|
||||
lastError.value = null
|
||||
return payload
|
||||
}
|
||||
catch (error) {
|
||||
handleError(error)
|
||||
if (throwInstead.value) throw error
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a task definition by ID via API.
|
||||
* @param id Task definition ID
|
||||
* @returns true if deleted, false on error
|
||||
*/
|
||||
const deleteDefinition = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await request(`/api/task_definitions/${id}`, { method: 'DELETE' })
|
||||
await ensureSuccess(response)
|
||||
|
||||
removeSummary(id)
|
||||
notify.success('Task definition deleted.')
|
||||
lastError.value = null
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
handleError(error)
|
||||
if (throwInstead.value) throw error
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the last error message.
|
||||
*/
|
||||
const clearError = () => lastError.value = null
|
||||
|
||||
/**
|
||||
* useTaskDefinitions composable
|
||||
*
|
||||
* Returns reactive state and CRUD methods for task definitions.
|
||||
* @returns Object with state and API methods
|
||||
*/
|
||||
export const useTaskDefinitions = () => ({
|
||||
definitions: readonly(definitions),
|
||||
isLoading: readonly(isLoading),
|
||||
lastError: readonly(lastError),
|
||||
loadDefinitions,
|
||||
getDefinition,
|
||||
createDefinition,
|
||||
updateDefinition,
|
||||
deleteDefinition,
|
||||
clearError,
|
||||
throwInstead,
|
||||
})
|
||||
|
||||
export default useTaskDefinitions
|
||||
|
|
@ -34,11 +34,23 @@
|
|||
<span class="icon"><i class="fa-solid fa-sliders" /></span>
|
||||
<span>Presets</span>
|
||||
</NuxtLink>
|
||||
<div class="navbar-item has-dropdown">
|
||||
<a class="navbar-link" @click="(e: MouseEvent) => openMenu(e)">
|
||||
<span class="icon"><i class="fas fa-tasks" /></span>
|
||||
<span>Tasks</span>
|
||||
</a>
|
||||
<div class="navbar-dropdown">
|
||||
<NuxtLink class="navbar-item" to="/tasks" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
||||
<span class="icon"><i class="fa-solid fa-tasks" /></span>
|
||||
<span>List</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="navbar-item" to="/tasks" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
||||
<span class="icon"><i class="fa-solid fa-tasks" /></span>
|
||||
<span>Tasks</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink class="navbar-item" to="/task_definitions" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
||||
<span class="icon"><i class="fa-solid fa-diagram-project" /></span>
|
||||
<span>Definitions</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NuxtLink class="navbar-item" to="/notifications" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
||||
<span class="icon-text">
|
||||
|
|
|
|||
392
ui/app/pages/task_definitions.vue
Normal file
392
ui/app/pages/task_definitions.vue
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
<template>
|
||||
<main>
|
||||
<div class="mt-1 columns is-multiline">
|
||||
<div class="column is-12 is-clearfix is-unselectable">
|
||||
<span class="title is-4">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-diagram-project" /></span>
|
||||
<span>Task Definitions</span>
|
||||
</span>
|
||||
</span>
|
||||
<div class="is-pulled-right">
|
||||
<div class="field is-grouped">
|
||||
|
||||
<p class="control">
|
||||
<button class="button is-primary" @click="isEditorOpen ? closeEditor() : openCreate()"
|
||||
v-tooltip.bottom="'Toggle Form'">
|
||||
<span class="icon"><i class="fa-solid fa-add" /></span>
|
||||
<span v-if="!isMobile">New Definition</span>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<p class="control">
|
||||
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom"
|
||||
@click="() => display_style = display_style === 'list' ? 'grid' : 'list'">
|
||||
<span class="icon">
|
||||
<i class="fa-solid"
|
||||
:class="{ 'fa-table': display_style !== 'list', 'fa-table-list': display_style === 'list' }" /></span>
|
||||
<span v-if="!isMobile">
|
||||
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
||||
</span>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<p class="control">
|
||||
<button class="button is-info" @click="async () => await loadDefinitions()"
|
||||
:class="{ 'is-loading': isLoading }">
|
||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
||||
<span v-if="!isMobile">Reload</span>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="is-hidden-mobile">
|
||||
<span class="subtitle">
|
||||
Create definitions to turn any website into a downloadable feed of links.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns" v-if="'list' === display_style && definitions.length > 0 && !isEditorOpen">
|
||||
<div class="column is-12">
|
||||
<div class="table-container">
|
||||
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||
style="min-width: 850px; table-layout: fixed;">
|
||||
<thead>
|
||||
<tr class="has-text-centered is-unselectable">
|
||||
<th width="40%">Name</th>
|
||||
<th width="20%">Priority</th>
|
||||
<th width="20%">Updated</th>
|
||||
<th width="20%">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="definition in definitions" :key="definition.id">
|
||||
<td class="is-vcentered is-text-overflow">
|
||||
{{ definition.name || '(Unnamed definition)' }}
|
||||
</td>
|
||||
<td class="is-vcentered has-text-centered">{{ definition.priority }}</td>
|
||||
<td class="is-vcentered has-text-centered">
|
||||
<span class="user-hint" :date-datetime="moment.unix(definition.updated_at).format('YYYY-M-DD H:mm Z')"
|
||||
v-tooltip="moment.unix(definition.updated_at).format('YYYY-M-DD H:mm Z')"
|
||||
v-rtime="definition.updated_at" />
|
||||
</td>
|
||||
<td class="is-vcentered is-items-center">
|
||||
<div class="field is-grouped is-grouped-centered">
|
||||
<div class="control">
|
||||
<button class="button is-small is-info" type="button" @click="exportDefinition(definition)">
|
||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||
<span v-if="!isMobile">Export</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button is-small is-warning" type="button" @click="openEdit(definition)">
|
||||
<span class="icon"><i class="fa-solid fa-cog" /></span>
|
||||
<span v-if="!isMobile">Edit</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button is-small is-danger" type="button" @click="remove(definition)">
|
||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
||||
<span v-if="!isMobile">Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline" v-if="'grid' === display_style && definitions.length > 0 && !isEditorOpen">
|
||||
<div class="column is-6" v-for="definition in definitions" :key="definition.id">
|
||||
<div class="card">
|
||||
<header class="card-header">
|
||||
<div class="card-header-title is-text-overflow is-block">
|
||||
{{ definition.name || '(Unnamed definition)' }}
|
||||
</div>
|
||||
<div class="card-header-icon">
|
||||
<button class="has-text-info" v-tooltip="'Export'" @click="exportDefinition(definition)">
|
||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="card-content">
|
||||
<div class="content">
|
||||
<p>
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
|
||||
<span>Priority: {{ definition.priority }}</span>
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-clock" /></span>
|
||||
<span>Updated: <span class="user-hint"
|
||||
:date-datetime="moment.unix(definition.updated_at).format('YYYY-M-DD H:mm Z')"
|
||||
v-tooltip="moment.unix(definition.updated_at).format('YYYY-M-DD H:mm Z')"
|
||||
v-rtime="definition.updated_at" />
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="card-footer mt-auto">
|
||||
<div class="card-footer-item">
|
||||
<button class="button is-warning is-fullwidth" @click="openEdit(definition)">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-pen-to-square" /></span>
|
||||
<span>Edit</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-footer-item">
|
||||
<button class="button is-danger is-fullwidth" @click="remove(definition)">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
||||
<span>Delete</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-12" v-if="isLoading">
|
||||
<div class="box has-text-centered">
|
||||
<span class="icon"><i class="fa-solid fa-circle-notch fa-spin" /></span>
|
||||
<span class="ml-2">Loading definitions…</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-12" v-else-if="!definitions.length">
|
||||
<div class="box has-text-centered">
|
||||
<span>No task definitions are configured yet. Create one to get started.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns" v-if="isEditorOpen">
|
||||
<div class="column is-12">
|
||||
<TaskDefinitionEditor :title="editorTitle" :document="workingDefinition"
|
||||
:initial-show-import="'create' === editorMode" :available-definitions="definitions" :loading="editorLoading"
|
||||
:submitting="editorSubmitting" @submit="submitDefinition" @cancel="closeEditor"
|
||||
@import-existing="importExistingDefinition" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import moment from 'moment'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
|
||||
import TaskDefinitionEditor from '~/components/TaskDefinitionEditor.vue'
|
||||
import useTaskDefinitionsComposable from '~/composables/useTaskDefinitions'
|
||||
import { useDialog } from '~/composables/useDialog'
|
||||
import { useNotification } from '~/composables/useNotification'
|
||||
import { copyText, encode } from '~/utils'
|
||||
import { useMediaQuery } from '~/composables/useMediaQuery'
|
||||
|
||||
import type {
|
||||
TaskDefinitionDetailed,
|
||||
TaskDefinitionDocument,
|
||||
TaskDefinitionSummary,
|
||||
} from '~/types/task_definitions'
|
||||
|
||||
const DEFAULT_DEFINITION: TaskDefinitionDocument = {
|
||||
name: 'New Definition',
|
||||
priority: 0,
|
||||
match: ['https://example.com/*'],
|
||||
parse: {
|
||||
items: {
|
||||
type: 'css',
|
||||
selector: 'body',
|
||||
fields: {
|
||||
link: { type: 'css', expression: 'a', attribute: 'href' },
|
||||
title: { type: 'css', expression: 'a', attribute: 'text' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
||||
|
||||
const taskDefs = useTaskDefinitionsComposable()
|
||||
const definitionsRef = taskDefs.definitions
|
||||
const isLoading = taskDefs.isLoading
|
||||
const loadDefinitions = taskDefs.loadDefinitions
|
||||
const getDefinition = taskDefs.getDefinition
|
||||
const createDefinition = taskDefs.createDefinition
|
||||
const updateDefinition = taskDefs.updateDefinition
|
||||
const deleteDefinition = taskDefs.deleteDefinition
|
||||
|
||||
const definitions = computed(() => definitionsRef.value)
|
||||
|
||||
const { confirmDialog } = useDialog()
|
||||
const toast = useNotification()
|
||||
|
||||
const isEditorOpen = ref<boolean>(false)
|
||||
const editorMode = ref<'create' | 'edit'>('create')
|
||||
const editorLoading = ref<boolean>(false)
|
||||
const editorSubmitting = ref<boolean>(false)
|
||||
const workingDefinition = ref<TaskDefinitionDocument | null>(null)
|
||||
const workingId = ref<string | null>(null)
|
||||
const display_style = useStorage<'list' | 'grid'>('task-definitions:display', 'grid')
|
||||
|
||||
const currentSummary = computed<TaskDefinitionSummary | undefined>(() => {
|
||||
if ('edit' !== editorMode.value || !workingId.value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return definitions.value.find(item => item.id === workingId.value)
|
||||
})
|
||||
|
||||
const editorTitle = computed<string>(() => {
|
||||
return 'create' === editorMode.value
|
||||
? 'Create Task Definition'
|
||||
: `Edit ${currentSummary.value?.name || 'Task Definition'}`
|
||||
})
|
||||
|
||||
const cloneDocument = (document: TaskDefinitionDocument): TaskDefinitionDocument => {
|
||||
return JSON.parse(JSON.stringify(document)) as TaskDefinitionDocument
|
||||
}
|
||||
|
||||
const openCreate = (): void => {
|
||||
editorMode.value = 'create'
|
||||
workingId.value = null
|
||||
workingDefinition.value = cloneDocument(DEFAULT_DEFINITION)
|
||||
isEditorOpen.value = true
|
||||
editorLoading.value = false
|
||||
editorSubmitting.value = false
|
||||
}
|
||||
|
||||
const openEdit = async (summary: TaskDefinitionSummary): Promise<void> => {
|
||||
editorMode.value = 'edit'
|
||||
workingId.value = summary.id
|
||||
workingDefinition.value = null
|
||||
editorLoading.value = true
|
||||
editorSubmitting.value = false
|
||||
isEditorOpen.value = true
|
||||
|
||||
const detailed: TaskDefinitionDetailed | null = await getDefinition(summary.id)
|
||||
if (!detailed) {
|
||||
isEditorOpen.value = false
|
||||
editorLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const document = cloneDocument(detailed.definition)
|
||||
const docRecord = document
|
||||
if ('priority' in docRecord) {
|
||||
docRecord.priority = Number(docRecord.priority)
|
||||
}
|
||||
else {
|
||||
docRecord.priority = detailed.priority
|
||||
}
|
||||
|
||||
workingDefinition.value = document
|
||||
editorLoading.value = false
|
||||
}
|
||||
|
||||
const importExistingDefinition = async (id: string): Promise<void> => {
|
||||
const detailed = await getDefinition(id)
|
||||
if (!detailed) {
|
||||
toast.error('Failed to load task definition for import.')
|
||||
return
|
||||
}
|
||||
|
||||
const document = cloneDocument(detailed.definition)
|
||||
const docRecord = document
|
||||
if ('priority' in docRecord) {
|
||||
docRecord.priority = Number(docRecord.priority)
|
||||
}
|
||||
else {
|
||||
docRecord.priority = detailed.priority
|
||||
}
|
||||
|
||||
workingDefinition.value = document
|
||||
if ('create' === editorMode.value) {
|
||||
workingId.value = null
|
||||
}
|
||||
editorLoading.value = false
|
||||
}
|
||||
|
||||
const closeEditor = (): void => {
|
||||
if (editorSubmitting.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isEditorOpen.value = false
|
||||
workingDefinition.value = null
|
||||
workingId.value = null
|
||||
editorLoading.value = false
|
||||
}
|
||||
|
||||
const submitDefinition = async (definition: TaskDefinitionDocument): Promise<void> => {
|
||||
editorSubmitting.value = true
|
||||
try {
|
||||
if ('create' === editorMode.value) {
|
||||
const created = await createDefinition(definition)
|
||||
if (created) {
|
||||
isEditorOpen.value = false
|
||||
workingDefinition.value = null
|
||||
workingId.value = null
|
||||
}
|
||||
}
|
||||
else if (workingId.value) {
|
||||
const updated = await updateDefinition(workingId.value, definition)
|
||||
if (updated) {
|
||||
isEditorOpen.value = false
|
||||
workingDefinition.value = null
|
||||
workingId.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
editorSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (summary: TaskDefinitionSummary): Promise<void> => {
|
||||
const result = await confirmDialog({
|
||||
title: 'Delete Task Definition',
|
||||
message: `Are you sure you want to delete “${summary.name || summary.id}”?`,
|
||||
confirmColor: 'is-danger',
|
||||
})
|
||||
|
||||
if (!result.status) {
|
||||
return
|
||||
}
|
||||
|
||||
await deleteDefinition(summary.id)
|
||||
}
|
||||
|
||||
const exportDefinition = async (summary: TaskDefinitionSummary): Promise<void> => {
|
||||
const detailed = await getDefinition(summary.id)
|
||||
if (!detailed) {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
_type: 'task_definition',
|
||||
_version: '1.0',
|
||||
name: detailed.name,
|
||||
priority: detailed.priority,
|
||||
definition: detailed.definition,
|
||||
}
|
||||
|
||||
return copyText(encode(payload))
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!definitions.value.length) {
|
||||
await loadDefinitions()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
@ -29,7 +29,14 @@ export default defineNuxtPlugin(nuxtApp => {
|
|||
nuxtApp.vueApp.directive('rtime', {
|
||||
mounted(el: RTimeElement, binding) {
|
||||
const intervalMs = parseInterval(binding.arg)
|
||||
const update = () => el.textContent = moment(binding.value).fromNow()
|
||||
const update = () => {
|
||||
const val = binding.value
|
||||
if (Number.isFinite(val)) {
|
||||
el.textContent = moment.unix(val as number).fromNow()
|
||||
return
|
||||
}
|
||||
el.textContent = moment(val).fromNow()
|
||||
}
|
||||
|
||||
update()
|
||||
el._next_timer = window.setInterval(update, intervalMs)
|
||||
|
|
@ -39,7 +46,14 @@ export default defineNuxtPlugin(nuxtApp => {
|
|||
if (null != el._next_timer) clearInterval(el._next_timer)
|
||||
|
||||
const intervalMs = parseInterval(binding.arg)
|
||||
const update = () => el.textContent = moment(binding.value).fromNow()
|
||||
const update = () => {
|
||||
const val = binding.value
|
||||
if (Number.isFinite(val)) {
|
||||
el.textContent = moment.unix(val as number).fromNow()
|
||||
return
|
||||
}
|
||||
el.textContent = moment(val).fromNow()
|
||||
}
|
||||
|
||||
update()
|
||||
el._next_timer = window.setInterval(update, intervalMs)
|
||||
|
|
|
|||
109
ui/app/types/task_definitions.ts
Normal file
109
ui/app/types/task_definitions.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
// --- Task Definition Schema Types ---
|
||||
|
||||
export type TaskMatchPattern = | string | { regex?: string; glob?: string }
|
||||
|
||||
export type EngineType = 'httpx' | 'selenium'
|
||||
|
||||
export interface EngineOptions {
|
||||
url?: string
|
||||
browser?: 'chrome'
|
||||
arguments?: Array<string> | string
|
||||
wait_for?: WaitForSelector
|
||||
wait_timeout?: number
|
||||
page_load_timeout?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface EngineConfig {
|
||||
type?: EngineType
|
||||
options?: EngineOptions
|
||||
}
|
||||
|
||||
export type HttpMethod = 'GET' | 'POST'
|
||||
|
||||
export type StringMap = Record<string, string | number | boolean | null>
|
||||
|
||||
export interface RequestConfig {
|
||||
method?: HttpMethod
|
||||
url?: string
|
||||
headers?: StringMap
|
||||
params?: StringMap
|
||||
data?: StringMap | string | null
|
||||
json?: object | Array<unknown> | string | number | boolean | null
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export type ResponseType = 'html' | 'json'
|
||||
|
||||
export interface ResponseConfig {
|
||||
type?: ResponseType
|
||||
}
|
||||
|
||||
export type ExtractionType = 'css' | 'xpath' | 'regex' | 'jsonpath'
|
||||
|
||||
export interface PostFilter {
|
||||
filter: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
export interface ExtractionRule {
|
||||
type: ExtractionType
|
||||
expression: string
|
||||
attribute?: string
|
||||
post_filter?: PostFilter
|
||||
}
|
||||
|
||||
export interface ContainerFields {
|
||||
link: ExtractionRule
|
||||
[field: string]: ExtractionRule
|
||||
}
|
||||
|
||||
export type ContainerSelectorType = 'css' | 'xpath' | 'jsonpath'
|
||||
|
||||
export interface Container {
|
||||
type?: ContainerSelectorType
|
||||
selector?: string
|
||||
expression?: string
|
||||
fields: ContainerFields
|
||||
}
|
||||
|
||||
export interface WaitForSelector {
|
||||
type?: 'css' | 'xpath'
|
||||
expression?: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
export interface ParseConfig {
|
||||
items?: Container
|
||||
link?: ExtractionRule
|
||||
[field: string]: ExtractionRule | Container | undefined
|
||||
}
|
||||
|
||||
export interface TaskDefinitionDocument {
|
||||
name: string
|
||||
match: Array<TaskMatchPattern>
|
||||
parse: ParseConfig
|
||||
priority?: number
|
||||
engine?: EngineConfig
|
||||
request?: RequestConfig
|
||||
response?: ResponseConfig
|
||||
}
|
||||
|
||||
// --- Summaries and Error Types ---
|
||||
|
||||
export type TaskDefinitionSummary = {
|
||||
id: string,
|
||||
name: string,
|
||||
priority: number,
|
||||
updated_at: number,
|
||||
}
|
||||
|
||||
export type TaskDefinitionDetailed = TaskDefinitionSummary & {
|
||||
definition: TaskDefinitionDocument
|
||||
}
|
||||
|
||||
export type TaskDefinitionErrorResponse = {
|
||||
error: string,
|
||||
}
|
||||
|
||||
|
||||
190
ui/tests/composables/useTaskDefinitions.test.ts
Normal file
190
ui/tests/composables/useTaskDefinitions.test.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import * as utils from '../../app/utils/index'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { useTaskDefinitions } from '~/composables/useTaskDefinitions'
|
||||
import type { TaskDefinitionSummary } from '~/types/task_definitions'
|
||||
|
||||
vi.mock('~/composables/useNotification', () => {
|
||||
const success = vi.fn()
|
||||
const error = vi.fn()
|
||||
return {
|
||||
useNotification: () => ({ success, error }),
|
||||
default: () => ({ success, error })
|
||||
}
|
||||
})
|
||||
|
||||
// Sample data
|
||||
const summary: TaskDefinitionSummary = {
|
||||
id: 'abc',
|
||||
name: 'Test',
|
||||
priority: 1,
|
||||
updated_at: 123456,
|
||||
}
|
||||
|
||||
|
||||
// Helper to create a mock Response object
|
||||
function createMockResponse({ ok, status, jsonData }: { ok: boolean, status: number, jsonData: any }) {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
headers: new Headers(),
|
||||
redirected: false,
|
||||
statusText: '',
|
||||
type: 'basic',
|
||||
url: '',
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
clone() { return this },
|
||||
async json() { return jsonData },
|
||||
text: async () => JSON.stringify(jsonData),
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
blob: async () => new Blob(),
|
||||
formData: async () => new FormData(),
|
||||
} as Response
|
||||
}
|
||||
|
||||
describe('useTaskDefinitions', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('sorts definitions by priority then name', async () => {
|
||||
const items = [
|
||||
{ id: '1', name: 'B', priority: 2, updated_at: 1 },
|
||||
{ id: '2', name: 'A', priority: 2, updated_at: 2 },
|
||||
{ id: '3', name: 'C', priority: 1, updated_at: 3 },
|
||||
]
|
||||
vi.spyOn(utils, 'request').mockResolvedValueOnce(createMockResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
jsonData: items,
|
||||
}))
|
||||
const defs = useTaskDefinitions()
|
||||
await defs.loadDefinitions()
|
||||
expect(defs.definitions.value.map(d => d.id)).toEqual(['3', '2', '1'])
|
||||
})
|
||||
|
||||
it('handles empty payload', async () => {
|
||||
vi.spyOn(utils, 'request').mockResolvedValueOnce(createMockResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
jsonData: [],
|
||||
}))
|
||||
const defs = useTaskDefinitions()
|
||||
await defs.loadDefinitions()
|
||||
expect(defs.definitions.value).toEqual([])
|
||||
expect(defs.lastError.value).toBeNull()
|
||||
})
|
||||
|
||||
it('handles malformed payload', async () => {
|
||||
vi.spyOn(utils, 'request').mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ jsonData: [{}] })
|
||||
})
|
||||
const defs = useTaskDefinitions()
|
||||
await expect(defs.loadDefinitions()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('handles malformed payload (throws when throwInstead is true)', async () => {
|
||||
vi.spyOn(utils, 'request').mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ jsonData: [{}] })
|
||||
})
|
||||
const defs = useTaskDefinitions()
|
||||
defs.throwInstead.value = true
|
||||
await expect(defs.loadDefinitions()).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('handles duplicate IDs (no deduplication, both present)', async () => {
|
||||
vi.spyOn(utils, 'request').mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{ id: 'dup', name: 'A', priority: 1 },
|
||||
{ id: 'dup', name: 'B', priority: 2 },
|
||||
]
|
||||
})
|
||||
const defs = useTaskDefinitions()
|
||||
await defs.loadDefinitions()
|
||||
expect(defs.definitions.value.length).toBe(2)
|
||||
expect(defs.definitions.value[0].name).toBe('A')
|
||||
expect(defs.definitions.value[1].name).toBe('B')
|
||||
})
|
||||
|
||||
it('handles error on getDefinition', async () => {
|
||||
vi.spyOn(utils, 'request').mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
json: async () => ({})
|
||||
})
|
||||
const defs = useTaskDefinitions()
|
||||
await expect(defs.getDefinition('notfound')).rejects.toThrow('Request failed with status 404')
|
||||
})
|
||||
|
||||
it('handles malformed getDefinition response', async () => {
|
||||
vi.spyOn(utils, 'request').mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({})
|
||||
})
|
||||
const defs = useTaskDefinitions()
|
||||
await expect(defs.getDefinition('bad')).rejects.toThrow('Task definition response is missing definition payload.')
|
||||
})
|
||||
|
||||
it('handles error on createDefinition', async () => {
|
||||
vi.spyOn(utils, 'request').mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
json: async () => ({})
|
||||
})
|
||||
const defs = useTaskDefinitions()
|
||||
await expect(defs.createDefinition({ id: 'fail', name: 'Fail', priority: 1 })).rejects.toThrow('Request failed with status 400')
|
||||
})
|
||||
|
||||
it('handles error on updateDefinition', async () => {
|
||||
vi.spyOn(utils, 'request').mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
json: async () => ({})
|
||||
})
|
||||
const defs = useTaskDefinitions()
|
||||
await expect(defs.updateDefinition({ id: 'fail', name: 'Fail', priority: 1 })).rejects.toThrow('Request failed with status 400')
|
||||
})
|
||||
|
||||
it('handles error on deleteDefinition', async () => {
|
||||
vi.spyOn(utils, 'request').mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
json: async () => ({})
|
||||
})
|
||||
const defs = useTaskDefinitions()
|
||||
await expect(defs.deleteDefinition('fail')).rejects.toThrow('Request failed with status 400')
|
||||
})
|
||||
|
||||
it('loads definitions successfully (duplicate test)', async () => {
|
||||
vi.spyOn(utils, 'request').mockResolvedValueOnce(createMockResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
jsonData: [summary],
|
||||
}))
|
||||
const defs = useTaskDefinitions()
|
||||
await defs.loadDefinitions()
|
||||
expect(defs.definitions.value).toEqual([summary])
|
||||
expect(defs.lastError.value).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
it('calls success notification on createDefinition', async () => {
|
||||
vi.spyOn(utils, 'request').mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({})
|
||||
})
|
||||
const defs = useTaskDefinitions()
|
||||
await defs.createDefinition({ id: 'new', name: 'New', priority: 1 })
|
||||
// Access the spy directly from the mock
|
||||
const notify = (await import('~/composables/useNotification')).useNotification()
|
||||
expect(notify.success).toHaveBeenCalledWith('Task definition created.')
|
||||
})
|
||||
});
|
||||
108
uv.lock
108
uv.lock
|
|
@ -523,6 +523,33 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.25.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "jsonschema-specifications" },
|
||||
{ name = "referencing" },
|
||||
{ name = "rpds-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-specifications"
|
||||
version = "2025.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "referencing" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "6.0.1"
|
||||
|
|
@ -1021,6 +1048,19 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.36.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "rpds-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "2025.9.18"
|
||||
|
|
@ -1113,6 +1153,72 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpds-py"
|
||||
version = "0.27.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/dd/2c0cbe774744272b0ae725f44032c77bdcab6e8bcf544bffa3b6e70c8dba/rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8", size = 27479 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/77/610aeee8d41e39080c7e14afa5387138e3c9fa9756ab893d09d99e7d8e98/rpds_py-0.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e4b9fcfbc021633863a37e92571d6f91851fa656f0180246e84cbd8b3f6b329b", size = 361741 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/fc/c43765f201c6a1c60be2043cbdb664013def52460a4c7adace89d6682bf4/rpds_py-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1441811a96eadca93c517d08df75de45e5ffe68aa3089924f963c782c4b898cf", size = 345574 },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/42/ee2b2ca114294cd9847d0ef9c26d2b0851b2e7e00bf14cc4c0b581df0fc3/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55266dafa22e672f5a4f65019015f90336ed31c6383bd53f5e7826d21a0e0b83", size = 385051 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/e8/1e430fe311e4799e02e2d1af7c765f024e95e17d651612425b226705f910/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78827d7ac08627ea2c8e02c9e5b41180ea5ea1f747e9db0915e3adf36b62dcf", size = 398395 },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/95/9dc227d441ff2670651c27a739acb2535ccaf8b351a88d78c088965e5996/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae92443798a40a92dc5f0b01d8a7c93adde0c4dc965310a29ae7c64d72b9fad2", size = 524334 },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/01/a670c232f401d9ad461d9a332aa4080cd3cb1d1df18213dbd0d2a6a7ab51/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c46c9dd2403b66a2a3b9720ec4b74d4ab49d4fabf9f03dfdce2d42af913fe8d0", size = 407691 },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/36/0a14aebbaa26fe7fab4780c76f2239e76cc95a0090bdb25e31d95c492fcd/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2efe4eb1d01b7f5f1939f4ef30ecea6c6b3521eec451fb93191bf84b2a522418", size = 386868 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/03/8c897fb8b5347ff6c1cc31239b9611c5bf79d78c984430887a353e1409a1/rpds_py-0.27.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:15d3b4d83582d10c601f481eca29c3f138d44c92187d197aff663a269197c02d", size = 405469 },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/07/88c60edc2df74850d496d78a1fdcdc7b54360a7f610a4d50008309d41b94/rpds_py-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ed2e16abbc982a169d30d1a420274a709949e2cbdef119fe2ec9d870b42f274", size = 422125 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/86/5f4c707603e41b05f191a749984f390dabcbc467cf833769b47bf14ba04f/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a75f305c9b013289121ec0f1181931975df78738cdf650093e6b86d74aa7d8dd", size = 562341 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/92/3c0cb2492094e3cd9baf9e49bbb7befeceb584ea0c1a8b5939dca4da12e5/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:67ce7620704745881a3d4b0ada80ab4d99df390838839921f99e63c474f82cf2", size = 592511 },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/bb/82e64fbb0047c46a168faa28d0d45a7851cd0582f850b966811d30f67ad8/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d992ac10eb86d9b6f369647b6a3f412fc0075cfd5d799530e84d335e440a002", size = 557736 },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/95/3c863973d409210da7fb41958172c6b7dbe7fc34e04d3cc1f10bb85e979f/rpds_py-0.27.1-cp313-cp313-win32.whl", hash = "sha256:4f75e4bd8ab8db624e02c8e2fc4063021b58becdbe6df793a8111d9343aec1e3", size = 221462 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/2c/5867b14a81dc217b56d95a9f2a40fdbc56a1ab0181b80132beeecbd4b2d6/rpds_py-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:f9025faafc62ed0b75a53e541895ca272815bec18abe2249ff6501c8f2e12b83", size = 232034 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/78/3958f3f018c01923823f1e47f1cc338e398814b92d83cd278364446fac66/rpds_py-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:ed10dc32829e7d222b7d3b93136d25a406ba9788f6a7ebf6809092da1f4d279d", size = 222392 },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/76/1cdf1f91aed5c3a7bf2eba1f1c4e4d6f57832d73003919a20118870ea659/rpds_py-0.27.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:92022bbbad0d4426e616815b16bc4127f83c9a74940e1ccf3cfe0b387aba0228", size = 358355 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/6f/bf142541229374287604caf3bb2a4ae17f0a580798fd72d3b009b532db4e/rpds_py-0.27.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47162fdab9407ec3f160805ac3e154df042e577dd53341745fc7fb3f625e6d92", size = 342138 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/77/355b1c041d6be40886c44ff5e798b4e2769e497b790f0f7fd1e78d17e9a8/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb89bec23fddc489e5d78b550a7b773557c9ab58b7946154a10a6f7a214a48b2", size = 380247 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/a4/d9cef5c3946ea271ce2243c51481971cd6e34f21925af2783dd17b26e815/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e48af21883ded2b3e9eb48cb7880ad8598b31ab752ff3be6457001d78f416723", size = 390699 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/06/005106a7b8c6c1a7e91b73169e49870f4af5256119d34a361ae5240a0c1d/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f5b7bd8e219ed50299e58551a410b64daafb5017d54bbe822e003856f06a802", size = 521852 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/3e/50fb1dac0948e17a02eb05c24510a8fe12d5ce8561c6b7b7d1339ab7ab9c/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08f1e20bccf73b08d12d804d6e1c22ca5530e71659e6673bce31a6bb71c1e73f", size = 402582 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b0/f4e224090dc5b0ec15f31a02d746ab24101dd430847c4d99123798661bfc/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dc5dceeaefcc96dc192e3a80bbe1d6c410c469e97bdd47494a7d930987f18b2", size = 384126 },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/77/ac339d5f82b6afff1df8f0fe0d2145cc827992cb5f8eeb90fc9f31ef7a63/rpds_py-0.27.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d76f9cc8665acdc0c9177043746775aa7babbf479b5520b78ae4002d889f5c21", size = 399486 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/29/3e1c255eee6ac358c056a57d6d6869baa00a62fa32eea5ee0632039c50a3/rpds_py-0.27.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:134fae0e36022edad8290a6661edf40c023562964efea0cc0ec7f5d392d2aaef", size = 414832 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/db/6d498b844342deb3fa1d030598db93937a9964fcf5cb4da4feb5f17be34b/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb11a4f1b2b63337cfd3b4d110af778a59aae51c81d195768e353d8b52f88081", size = 557249 },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/f3/690dd38e2310b6f68858a331399b4d6dbb9132c3e8ef8b4333b96caf403d/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13e608ac9f50a0ed4faec0e90ece76ae33b34c0e8656e3dceb9a7db994c692cd", size = 587356 },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/e3/84507781cccd0145f35b1dc32c72675200c5ce8d5b30f813e49424ef68fc/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd2135527aa40f061350c3f8f89da2644de26cd73e4de458e79606384f4f68e7", size = 555300 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/ee/375469849e6b429b3516206b4580a79e9ef3eb12920ddbd4492b56eaacbe/rpds_py-0.27.1-cp313-cp313t-win32.whl", hash = "sha256:3020724ade63fe320a972e2ffd93b5623227e684315adce194941167fee02688", size = 216714 },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/87/3fc94e47c9bd0742660e84706c311a860dcae4374cf4a03c477e23ce605a/rpds_py-0.27.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8ee50c3e41739886606388ba3ab3ee2aae9f35fb23f833091833255a31740797", size = 228943 },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/36/b6e6066520a07cf029d385de869729a895917b411e777ab1cde878100a1d/rpds_py-0.27.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:acb9aafccaae278f449d9c713b64a9e68662e7799dbd5859e2c6b3c67b56d334", size = 362472 },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/07/b4646032e0dcec0df9c73a3bd52f63bc6c5f9cda992f06bd0e73fe3fbebd/rpds_py-0.27.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b7fb801aa7f845ddf601c49630deeeccde7ce10065561d92729bfe81bd21fb33", size = 345676 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/16/2f1003ee5d0af4bcb13c0cf894957984c32a6751ed7206db2aee7379a55e/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe0dd05afb46597b9a2e11c351e5e4283c741237e7f617ffb3252780cca9336a", size = 385313 },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/cd/7eb6dd7b232e7f2654d03fa07f1414d7dfc980e82ba71e40a7c46fd95484/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6dfb0e058adb12d8b1d1b25f686e94ffa65d9995a5157afe99743bf7369d62b", size = 399080 },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/51/5829afd5000ec1cb60f304711f02572d619040aa3ec033d8226817d1e571/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed090ccd235f6fa8bb5861684567f0a83e04f52dfc2e5c05f2e4b1309fcf85e7", size = 523868 },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/2c/30eebca20d5db95720ab4d2faec1b5e4c1025c473f703738c371241476a2/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf876e79763eecf3e7356f157540d6a093cef395b65514f17a356f62af6cc136", size = 408750 },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/1a/cdb5083f043597c4d4276eae4e4c70c55ab5accec078da8611f24575a367/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12ed005216a51b1d6e2b02a7bd31885fe317e45897de81d86dcce7d74618ffff", size = 387688 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/92/cf786a15320e173f945d205ab31585cc43969743bb1a48b6888f7a2b0a2d/rpds_py-0.27.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ee4308f409a40e50593c7e3bb8cbe0b4d4c66d1674a316324f0c2f5383b486f9", size = 407225 },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/5c/85ee16df5b65063ef26017bef33096557a4c83fbe56218ac7cd8c235f16d/rpds_py-0.27.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b08d152555acf1f455154d498ca855618c1378ec810646fcd7c76416ac6dc60", size = 423361 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/8e/1c2741307fcabd1a334ecf008e92c4f47bb6f848712cf15c923becfe82bb/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dce51c828941973a5684d458214d3a36fcd28da3e1875d659388f4f9f12cc33e", size = 562493 },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/03/5159321baae9b2222442a70c1f988cbbd66b9be0675dd3936461269be360/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c1476d6f29eb81aa4151c9a31219b03f1f798dc43d8af1250a870735516a1212", size = 592623 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/39/c09fd1ad28b85bc1d4554a8710233c9f4cefd03d7717a1b8fbfd171d1167/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ce0cac322b0d69b63c9cdb895ee1b65805ec9ffad37639f291dd79467bee675", size = 558800 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/d6/99228e6bbcf4baa764b18258f519a9035131d91b538d4e0e294313462a98/rpds_py-0.27.1-cp314-cp314-win32.whl", hash = "sha256:dfbfac137d2a3d0725758cd141f878bf4329ba25e34979797c89474a89a8a3a3", size = 221943 },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/07/c802bc6b8e95be83b79bdf23d1aa61d68324cb1006e245d6c58e959e314d/rpds_py-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:a6e57b0abfe7cc513450fcf529eb486b6e4d3f8aee83e92eb5f1ef848218d456", size = 233739 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/89/3e1b1c16d4c2d547c5717377a8df99aee8099ff050f87c45cb4d5fa70891/rpds_py-0.27.1-cp314-cp314-win_arm64.whl", hash = "sha256:faf8d146f3d476abfee026c4ae3bdd9ca14236ae4e4c310cbd1cf75ba33d24a3", size = 223120 },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/7e/dc7931dc2fa4a6e46b2a4fa744a9fe5c548efd70e0ba74f40b39fa4a8c10/rpds_py-0.27.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ba81d2b56b6d4911ce735aad0a1d4495e808b8ee4dc58715998741a26874e7c2", size = 358944 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/22/4af76ac4e9f336bfb1a5f240d18a33c6b2fcaadb7472ac7680576512b49a/rpds_py-0.27.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84f7d509870098de0e864cad0102711c1e24e9b1a50ee713b65928adb22269e4", size = 342283 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/15/2a7c619b3c2272ea9feb9ade67a45c40b3eeb500d503ad4c28c395dc51b4/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e960fc78fecd1100539f14132425e1d5fe44ecb9239f8f27f079962021523e", size = 380320 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/7d/4c6d243ba4a3057e994bb5bedd01b5c963c12fe38dde707a52acdb3849e7/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62f85b665cedab1a503747617393573995dac4600ff51869d69ad2f39eb5e817", size = 391760 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/71/b19401a909b83bcd67f90221330bc1ef11bc486fe4e04c24388d28a618ae/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fed467af29776f6556250c9ed85ea5a4dd121ab56a5f8b206e3e7a4c551e48ec", size = 522476 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/44/1a3b9715c0455d2e2f0f6df5ee6d6f5afdc423d0773a8a682ed2b43c566c/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2729615f9d430af0ae6b36cf042cb55c0936408d543fb691e1a9e36648fd35a", size = 403418 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/4b/fb6c4f14984eb56673bc868a66536f53417ddb13ed44b391998100a06a96/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b207d881a9aef7ba753d69c123a35d96ca7cb808056998f6b9e8747321f03b8", size = 384771 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/56/d5265d2d28b7420d7b4d4d85cad8ef891760f5135102e60d5c970b976e41/rpds_py-0.27.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:639fd5efec029f99b79ae47e5d7e00ad8a773da899b6309f6786ecaf22948c48", size = 400022 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/e9/9f5fc70164a569bdd6ed9046486c3568d6926e3a49bdefeeccfb18655875/rpds_py-0.27.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fecc80cb2a90e28af8a9b366edacf33d7a91cbfe4c2c4544ea1246e949cfebeb", size = 416787 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/64/56dd03430ba491db943a81dcdef115a985aac5f44f565cd39a00c766d45c/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42a89282d711711d0a62d6f57d81aa43a1368686c45bc1c46b7f079d55692734", size = 557538 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/36/92cc885a3129993b1d963a2a42ecf64e6a8e129d2c7cc980dbeba84e55fb/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:cf9931f14223de59551ab9d38ed18d92f14f055a5f78c1d8ad6493f735021bbb", size = 588512 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/10/6b283707780a81919f71625351182b4f98932ac89a09023cb61865136244/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f39f58a27cc6e59f432b568ed8429c7e1641324fbe38131de852cd77b2d534b0", size = 555813 },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/2e/30b5ea18c01379da6272a92825dd7e53dc9d15c88a19e97932d35d430ef7/rpds_py-0.27.1-cp314-cp314t-win32.whl", hash = "sha256:d5fa0ee122dc09e23607a28e6d7b150da16c662e66409bbe85230e4c85bb528a", size = 217385 },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/7d/97119da51cb1dd3f2f3c0805f155a3aa4a95fa44fe7d78ae15e69edf4f34/rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772", size = 230097 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.13.1"
|
||||
|
|
@ -1388,6 +1494,7 @@ dependencies = [
|
|||
{ name = "httpx" },
|
||||
{ name = "httpx-curl-cffi" },
|
||||
{ name = "jmespath" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "multidict" },
|
||||
{ name = "mutagen" },
|
||||
{ name = "parsel" },
|
||||
|
|
@ -1438,6 +1545,7 @@ requires-dist = [
|
|||
{ name = "httpx" },
|
||||
{ name = "httpx-curl-cffi", specifier = ">=0.1.4" },
|
||||
{ name = "jmespath", specifier = ">=1.0.1" },
|
||||
{ name = "jsonschema", specifier = ">=4.23.0" },
|
||||
{ name = "multidict", specifier = "==6.5.1" },
|
||||
{ name = "mutagen" },
|
||||
{ name = "parsel", specifier = ">=1.10.0" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue