Added Web UI for task definitions

This commit is contained in:
arabcoders 2025-09-22 20:01:46 +03:00
parent 7b645ad2ad
commit 09c0267404
29 changed files with 3475 additions and 17 deletions

38
.vscode/settings.json vendored
View file

@ -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
View file

@ -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?

View file

@ -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()

View 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()

View file

@ -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()

View file

@ -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

View file

@ -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__

View file

@ -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."

View file

@ -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:
"""

View file

@ -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(

View 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)

View 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
View 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": {}
}
]
}
}

View 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
View 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."
}
]
}

View file

@ -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
View 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
}
]
}
}
}

View 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

View file

@ -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.

View 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/*&#10;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>

View file

@ -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),

View 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

View file

@ -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">

View 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>

View file

@ -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)

View 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,
}

View 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
View file

@ -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" },