Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 142 additions & 1 deletion src/borgitory/api/schedules.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@
from borgitory.services.scheduling.hook_service import HookService
from borgitory.api.auth import get_current_user
from borgitory.models.database import User
from borgitory.utils.source_paths import (
parse_source_paths,
parse_source_paths_raw,
serialize_source_paths,
)

router = APIRouter()

Expand Down Expand Up @@ -242,7 +247,17 @@ async def get_schedule_edit_form(
raise HTTPException(status_code=404, detail="Schedule not found")

form_data = await config_service.get_schedule_form_data(db)
context = {**form_data, "schedule": schedule, "is_edit_mode": True}
source_paths = (
parse_source_paths(schedule.source_path) if schedule.source_path else []
)
source_path_json = serialize_source_paths(source_paths)
context = {
**form_data,
"schedule": schedule,
"is_edit_mode": True,
"source_paths": source_paths,
"source_path_json": source_path_json,
}

return templates.TemplateResponse(
request, "partials/schedules/edit_form.html", context
Expand Down Expand Up @@ -603,6 +618,132 @@ async def close_modal() -> HTMLResponse:
return HTMLResponse(content='<div id="modal-container"></div>', status_code=200)


# Source Paths API endpoints


def _extract_source_paths_from_json(data: Dict[str, Any]) -> list[str]:
"""Extract source_paths from JSON request data.

json-enc sends multiple inputs with the same name as an array,
or a single input as a bare string.
"""
raw = data.get("source_paths", [])
if isinstance(raw, list):
return [str(p) for p in raw]
if isinstance(raw, str):
return [raw]
return [""]


@router.post("/source-paths/source-paths-modal", response_class=HTMLResponse)
async def get_source_paths_modal(
request: Request,
templates: TemplatesDep,
) -> HTMLResponse:
"""Open source paths configuration modal with current path data from parent."""
try:
json_data = await request.json()
source_path_value = str(json_data.get("source_path", "[]"))
except ValueError, TypeError, KeyError:
Comment thread
mlapaglia marked this conversation as resolved.
source_path_value = "[]"

source_paths = parse_source_paths_raw(source_path_value)
if not source_paths:
source_paths = [""]

return templates.TemplateResponse(
request,
"partials/shared/source_paths_modal.html",
{"source_paths": source_paths},
)


@router.post("/source-paths/save-source-paths", response_class=HTMLResponse)
async def save_source_paths(
request: Request,
templates: TemplatesDep,
) -> HTMLResponse:
"""Save source paths and update parent form via OOB swap."""
json_data = await request.json()
raw_paths = _extract_source_paths_from_json(json_data)
filtered = [p.strip() for p in raw_paths if p.strip()]

non_absolute = [p for p in filtered if not p.startswith("/")]
if non_absolute:
return templates.TemplateResponse(
request,
"partials/shared/source_paths_validation_error.html",
{
"error_message": (
f"All source paths must be absolute (start with /). "
f"Invalid: {', '.join(non_absolute)}"
)
},
status_code=400,
)

source_paths_json = serialize_source_paths(filtered)
total_count = len(filtered)

return templates.TemplateResponse(
request,
"partials/shared/source_paths_save_response.html",
{
"source_paths_json": source_paths_json,
"total_count": total_count,
},
)


@router.get("/source-paths/close-modal", response_class=HTMLResponse)
async def close_source_paths_modal() -> HTMLResponse:
"""Close source paths modal without saving."""
return HTMLResponse(content='<div id="modal-container"></div>', status_code=200)


@router.post("/source-paths/add-field", response_class=HTMLResponse)
async def add_source_path_field(
request: Request,
templates: TemplatesDep,
) -> HTMLResponse:
"""Add a new source path field row via HTMX."""
json_data = await request.json()
current_paths = _extract_source_paths_from_json(json_data)
current_paths.append("")

return templates.TemplateResponse(
request,
"partials/shared/source_paths_container.html",
{"source_paths": current_paths},
)
Comment thread
mlapaglia marked this conversation as resolved.


@router.post("/source-paths/remove-field", response_class=HTMLResponse)
async def remove_source_path_field(
request: Request,
templates: TemplatesDep,
) -> HTMLResponse:
"""Remove a source path field row via HTMX."""
json_data = await request.json()
current_paths = _extract_source_paths_from_json(json_data)

try:
remove_index = int(str(json_data.get("remove_index", 0)))
if 0 <= remove_index < len(current_paths):
current_paths.pop(remove_index)
except ValueError, TypeError:
Comment thread
mlapaglia marked this conversation as resolved.
pass

if not current_paths:
current_paths = [""]

return templates.TemplateResponse(
request,
"partials/shared/source_paths_container.html",
{"source_paths": current_paths},
)
Comment thread
mlapaglia marked this conversation as resolved.


# Pattern API endpoints
@router.post("/patterns/add-pattern-field", response_class=HTMLResponse)
async def add_pattern_field(
Expand Down
80 changes: 77 additions & 3 deletions src/borgitory/models/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from borgitory.custom_types import ConfigDict
from borgitory.services.hooks.hook_config import validate_hooks_json
from borgitory.models.enums import EncryptionType
from borgitory.utils.source_paths import serialize_source_paths


def validate_patterns_json(patterns_json: str) -> tuple[bool, Optional[str]]:
Expand Down Expand Up @@ -91,6 +92,50 @@ def validate_patterns_json(patterns_json: str) -> tuple[bool, Optional[str]]:
ABSOLUTE_PATH_PATTERN = r"^/.*"


def _validate_and_merge_source_paths(data: Dict[str, object]) -> Dict[str, object]:
"""Shared validator for source_paths -> source_path conversion.

Filters blanks, enforces absolute paths, and serializes the result.
"""
if not isinstance(data, dict) or "source_paths" not in data:
return data

paths = data.get("source_paths")
if isinstance(paths, str):
paths = [paths]
if isinstance(paths, list):
filtered = [p.strip() for p in paths if isinstance(p, str) and p.strip()]
non_absolute = [p for p in filtered if not p.startswith("/")]
if non_absolute:
raise ValueError(
f"All source paths must be absolute (start with /). "
f"Invalid: {', '.join(non_absolute)}"
)
if filtered:
data["source_path"] = serialize_source_paths(filtered)
data.pop("source_paths", None)
return data


def _validate_source_path_field(v: Optional[str]) -> Optional[str]:
"""Validate that every path inside a source_path value is absolute.

Handles both legacy plain strings and JSON array strings.
"""
if v is None or not v.strip():
return v
from borgitory.utils.source_paths import parse_source_paths_raw

paths = parse_source_paths_raw(v)
Comment thread
mlapaglia marked this conversation as resolved.
Outdated
non_absolute = [p for p in paths if not p.startswith("/")]
if non_absolute:
raise ValueError(
f"All source paths must be absolute (start with /). "
f"Invalid: {', '.join(non_absolute)}"
)
return v


# Enums for type safety and validation
class JobStatus(str, Enum):
PENDING = "pending"
Expand Down Expand Up @@ -314,6 +359,16 @@ class ScheduleCreate(ScheduleBase):
post_job_hooks: Optional[str] = None
patterns: Optional[str] = None

@model_validator(mode="before")
@classmethod
def merge_source_paths(cls, data: Dict[str, object]) -> Dict[str, object]:
return _validate_and_merge_source_paths(data)

@field_validator("source_path", mode="after")
@classmethod
def validate_source_path(cls, v: Optional[str]) -> Optional[str]:
return _validate_source_path_field(v)

@field_validator("cloud_sync_config_id", mode="before")
@classmethod
def validate_cloud_sync_config_id(cls, v: Union[str, int, None]) -> Optional[int]:
Expand Down Expand Up @@ -398,6 +453,16 @@ class ScheduleUpdate(BaseModel):
post_job_hooks: Optional[str] = None
patterns: Optional[str] = None

@model_validator(mode="before")
@classmethod
def merge_source_paths(cls, data: Dict[str, object]) -> Dict[str, object]:
return _validate_and_merge_source_paths(data)

@field_validator("source_path", mode="after")
@classmethod
def validate_source_path(cls, v: Optional[str]) -> Optional[str]:
return _validate_source_path_field(v)

@field_validator("pre_job_hooks", mode="before")
@classmethod
def validate_pre_job_hooks_update(cls, v: Optional[str]) -> Optional[str]:
Expand Down Expand Up @@ -486,7 +551,7 @@ def validate_notification_config_id(cls, v: Union[str, int, None]) -> Optional[i
class Schedule(ScheduleBase):
id: int = Field(gt=0)
repository_id: int = Field(gt=0)
source_path: str = Field(default="/", pattern=ABSOLUTE_PATH_PATTERN)
source_path: str = Field(default="/")
Comment thread
mlapaglia marked this conversation as resolved.
Outdated
enabled: bool
last_run: Optional[datetime] = None
next_run: Optional[datetime] = None
Expand Down Expand Up @@ -606,8 +671,7 @@ class BackupRequest(BaseModel):
repository_id: int = Field(gt=0)
source_path: str = Field(
default="/",
pattern=ABSOLUTE_PATH_PATTERN,
description="Absolute path to source directory",
description="Source path(s) to backup. Can be a single path string (legacy) or JSON array string of multiple paths.",
)
compression: CompressionType = CompressionType.ZSTD
dry_run: bool = False
Expand All @@ -620,6 +684,16 @@ class BackupRequest(BaseModel):
post_job_hooks: Optional[str] = None
patterns: Optional[str] = None

@model_validator(mode="before")
@classmethod
def merge_source_paths(cls, data: Dict[str, object]) -> Dict[str, object]:
return _validate_and_merge_source_paths(data)

@field_validator("source_path", mode="after")
@classmethod
def validate_source_path(cls, v: str) -> str:
return _validate_source_path_field(v) or v

@field_validator("dry_run", mode="before")
@classmethod
def validate_dry_run(cls, v: Union[str, bool, int]) -> bool:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,9 @@ def task_output_callback(line: str) -> None:
additional_args.append(f"{repository_path}::{archive_name}")

if source_path:
additional_args.append(str(source_path))
from borgitory.utils.source_paths import parse_source_paths
for path in parse_source_paths(str(source_path)):
additional_args.append(path)
Comment thread
mlapaglia marked this conversation as resolved.
Outdated

logger.info(f"Final additional_args for Borg command: {additional_args}")

Expand Down
23 changes: 22 additions & 1 deletion src/borgitory/services/scheduling/schedule_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,11 +423,32 @@ def safe_json_string(value: Any) -> Optional[str]:
return None
return str(value).strip()

source_paths = json_data.get("source_paths")
if isinstance(source_paths, list):
from borgitory.utils.source_paths import serialize_source_paths

filtered = [
p.strip() for p in source_paths if isinstance(p, str) and p.strip()
]
if not filtered:
return False, {}, "At least one source path is required"
non_absolute = [p for p in filtered if not p.startswith("/")]
if non_absolute:
return (
False,
{},
f"All source paths must be absolute (start with /). "
f"Invalid: {', '.join(non_absolute)}",
)
source_path_value = serialize_source_paths(filtered)
else:
source_path_value = json_data.get("source_path", "")

Comment thread
mlapaglia marked this conversation as resolved.
Outdated
processed_data = {
"name": name,
"repository_id": repository_id,
"cron_expression": cron_expression,
"source_path": json_data.get("source_path", ""),
"source_path": source_path_value,
"cloud_sync_config_id": safe_int(json_data.get("cloud_sync_config_id")),
"prune_config_id": safe_int(json_data.get("prune_config_id")),
"notification_config_id": safe_int(
Expand Down
18 changes: 12 additions & 6 deletions src/borgitory/templates/partials/backups/manual_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,18 @@ <h3 class="font-medium text-gray-900 dark:text-gray-100 mb-3">Create Backup</h3>
</div>
<div>
<label class="block text-sm font-medium text-gray-900 dark:text-gray-100">
Source Path
Source Paths
</label>
{% set input_id = "backup-source-path" %}
{% set input_name = "source_path" %}
{% set placeholder = "/pictures/" %}
{% set required = false %}
{% include "partials/shared/path_autocomplete.html" %}
<button type="button"
class="flex items-stretch w-full text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 focus:ring-2 focus:ring-blue-500 transition-colors overflow-hidden mt-1"
hx-post="/api/schedules/source-paths/source-paths-modal"
hx-include="#source-paths-data"
hx-target="#modal-container">
<span class="flex-1 px-4 py-2 text-left" id="source-paths-button-text">Source Paths</span>
<span class="flex items-center justify-center px-3 bg-gray-100 dark:bg-gray-700 border-l border-gray-300 dark:border-gray-600 text-xs font-medium min-w-[2rem]"
id="source-paths-count-badge">0</span>
</button>
<input type="hidden" name="source_path" id="source-paths-data" value="[]">
Comment thread
mlapaglia marked this conversation as resolved.
Outdated
</div>
<div>
<label class="block text-sm font-medium text-gray-900 dark:text-gray-100">
Expand Down Expand Up @@ -121,3 +126,4 @@ <h3 class="font-medium text-gray-900 dark:text-gray-100 mb-3">Create Backup</h3>
</form>
<div id="backup-status" class="mt-4"></div>
</div>
<div id="modal-container"></div>
17 changes: 11 additions & 6 deletions src/borgitory/templates/partials/schedules/create_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,18 @@ <h3 class="font-medium text-gray-900 dark:text-gray-100 mb-3">Create Schedule</h
</div>
<div>
<label class="block text-sm font-medium text-gray-900 dark:text-gray-100">
Source Path
Source Paths
</label>
{% set input_id = "schedule-source-path" %}
{% set input_name = "source_path" %}
{% set placeholder = "/path/to/data" %}
{% set required = false %}
{% include "partials/shared/path_autocomplete.html" %}
<button type="button"
class="flex items-stretch w-full text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 focus:ring-2 focus:ring-blue-500 transition-colors overflow-hidden mt-1"
hx-post="/api/schedules/source-paths/source-paths-modal"
hx-include="#source-paths-data"
hx-target="#modal-container">
<span class="flex-1 px-4 py-2 text-left" id="source-paths-button-text">Source Paths</span>
<span class="flex items-center justify-center px-3 bg-gray-100 dark:bg-gray-700 border-l border-gray-300 dark:border-gray-600 text-xs font-medium min-w-[2rem]"
id="source-paths-count-badge">0</span>
</button>
<input type="hidden" name="source_path" id="source-paths-data" value="[]">
Comment thread
mlapaglia marked this conversation as resolved.
Outdated
</div>
<div>
<label class="block text-sm font-medium text-gray-900 dark:text-gray-100">
Expand Down
Loading
Loading