Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
50 changes: 49 additions & 1 deletion src/borgitory/api/schedules.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
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

router = APIRouter()

Expand Down Expand Up @@ -242,7 +243,10 @@ 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 [""]
if not source_paths:
source_paths = [""]
context = {**form_data, "schedule": schedule, "is_edit_mode": True, "source_paths": source_paths}

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


# Source Paths API endpoints
@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."""
form_data = await request.form()
current_paths = list(form_data.getlist("source_paths"))
current_paths.append("")

return templates.TemplateResponse(
request,
"partials/schedules/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."""
form_data = await request.form()
current_paths = list(form_data.getlist("source_paths"))

try:
remove_index = int(str(form_data.get("remove_index", "0")))
if 0 <= remove_index < len(current_paths):
current_paths.pop(remove_index)
except (ValueError, TypeError):
pass

if not current_paths:
current_paths = [""]

return templates.TemplateResponse(
request,
"partials/schedules/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
42 changes: 39 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 @@ -314,6 +315,18 @@ 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]:
if isinstance(data, dict) and "source_paths" in data:
paths = data.get("source_paths")
if isinstance(paths, list):
filtered = [p for p in paths if isinstance(p, str) and p.strip()]
if filtered:
data["source_path"] = serialize_source_paths(filtered)
Comment thread
mlapaglia marked this conversation as resolved.
Outdated
data.pop("source_paths", None)
return data

@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 +411,18 @@ 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]:
if isinstance(data, dict) and "source_paths" in data:
paths = data.get("source_paths")
if isinstance(paths, list):
filtered = [p for p in paths if isinstance(p, str) and p.strip()]
if filtered:
data["source_path"] = serialize_source_paths(filtered)
Comment thread
mlapaglia marked this conversation as resolved.
Outdated
data.pop("source_paths", None)
return data

@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 +511,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 +631,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="Absolute path(s) to source directory, stored as JSON array string",
Comment thread
mlapaglia marked this conversation as resolved.
Outdated
)
compression: CompressionType = CompressionType.ZSTD
dry_run: bool = False
Expand All @@ -620,6 +644,18 @@ 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]:
if isinstance(data, dict) and "source_paths" in data:
paths = data.get("source_paths")
if isinstance(paths, list):
filtered = [p for p in paths if isinstance(p, str) and p.strip()]
if filtered:
data["source_path"] = serialize_source_paths(filtered)
Comment thread
mlapaglia marked this conversation as resolved.
Outdated
data.pop("source_paths", None)
return data

@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
10 changes: 9 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,19 @@ 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 for p in source_paths if isinstance(p, str) and p.strip()]
source_path_value = serialize_source_paths(filtered) if filtered else ""
Comment thread
mlapaglia marked this conversation as resolved.
Outdated
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
11 changes: 5 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,12 @@ <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" %}
<div id="source-paths-container">
{% set source_paths = source_paths if source_paths is defined and source_paths else [""] %}
{% include "partials/schedules/source_paths_container.html" %}
Comment thread
mlapaglia marked this conversation as resolved.
Outdated
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-900 dark:text-gray-100">
Expand Down
11 changes: 5 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,12 @@ <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" %}
<div id="source-paths-container">
{% set source_paths = source_paths if source_paths is defined and source_paths else [""] %}
{% include "partials/schedules/source_paths_container.html" %}
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-900 dark:text-gray-100">
Expand Down
12 changes: 5 additions & 7 deletions src/borgitory/templates/partials/schedules/edit_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,12 @@ <h3 class="font-medium text-gray-900 dark:text-gray-100 mb-3">Edit Schedule</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 = "schedule-edit-source-path" %}
{% set input_name = "source_path" %}
{% set input_value = schedule.source_path %}
{% set placeholder = "Enter path for scheduled backups" %}
{% set required = false %}
{% include "partials/shared/path_autocomplete.html" %}
<div id="source-paths-container">
{% set source_paths = source_paths if source_paths is defined and source_paths else [""] %}
{% include "partials/schedules/source_paths_container.html" %}
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-900 dark:text-gray-100">
Expand Down
26 changes: 26 additions & 0 deletions src/borgitory/templates/partials/schedules/source_path_field.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<!-- Individual Source Path Field -->
<div class="flex items-start gap-2 source-path-entry">
<div class="flex-1">
{% set input_id = "source-path-" ~ index %}
{% set input_name = "source_paths" %}
{% set input_value = path_value if path_value is defined else "" %}
{% set placeholder = "/path/to/data" %}
{% set required = false %}
{% include "partials/shared/path_autocomplete.html" %}
</div>
{% if show_remove %}
<button type="button"
class="mt-1 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 p-2 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors flex-shrink-0"
hx-post="/api/schedules/source-paths/remove-field"
hx-include="closest form"
hx-vals='{"remove_index": {{ index }}}'
hx-target="#source-paths-container"
hx-swap="innerHTML"
title="Remove source path">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16">
</path>
</svg>
</button>
{% endif %}
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<!-- Source Paths Container -->
<div class="space-y-2" id="source-paths-fields">
{% for path_value in source_paths %}
{% set index = loop.index0 %}
{% set show_remove = source_paths | length > 1 %}
{% include "partials/schedules/source_path_field.html" with context %}
{% endfor %}
</div>
<button type="button"
class="mt-2 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200 font-medium flex items-center gap-1"
hx-post="/api/schedules/source-paths/add-field"
hx-include="closest form"
hx-target="#source-paths-container"
hx-swap="innerHTML">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
Add Source Path
</button>
33 changes: 33 additions & 0 deletions src/borgitory/utils/source_paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Utilities for handling multiple source paths stored as JSON array strings."""

import json
from typing import List


def parse_source_paths(source_path: str) -> List[str]:
"""Parse a source_path value into a list of paths.

Handles both legacy single-path strings and JSON array strings.
"""
if not source_path or not source_path.strip():
return []

stripped = source_path.strip()
if stripped.startswith("["):
try:
parsed = json.loads(stripped)
if isinstance(parsed, list):
return [p for p in parsed if isinstance(p, str) and p.strip()]
return [stripped]
except (json.JSONDecodeError, ValueError):
return [stripped]

return [stripped]
Comment thread
mlapaglia marked this conversation as resolved.
Outdated


def serialize_source_paths(paths: List[str]) -> str:
"""Convert a list of paths to a JSON array string for storage."""
cleaned = [p.strip() for p in paths if p and p.strip()]
if not cleaned:
return "[]"
return json.dumps(cleaned)
72 changes: 72 additions & 0 deletions tests/utils/test_source_paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Tests for source_paths utility functions."""

import json
from borgitory.utils.source_paths import parse_source_paths, serialize_source_paths


class TestParseSourcePaths:
def test_single_plain_path(self):
assert parse_source_paths("/data") == ["/data"]

def test_single_path_json_array(self):
assert parse_source_paths('["/data"]') == ["/data"]

def test_multiple_paths_json_array(self):
result = parse_source_paths('["/home/user/src", "/home/user/Documents"]')
assert result == ["/home/user/src", "/home/user/Documents"]

def test_empty_string(self):
assert parse_source_paths("") == []

def test_whitespace_only(self):
assert parse_source_paths(" ") == []

def test_empty_json_array(self):
assert parse_source_paths("[]") == []

def test_strips_whitespace_from_input(self):
assert parse_source_paths(" /data ") == ["/data"]

def test_filters_empty_strings_in_array(self):
assert parse_source_paths('["/data", "", " "]') == ["/data"]

def test_invalid_json_starting_with_bracket(self):
assert parse_source_paths("[not json") == ["[not json"]

def test_json_array_with_non_string_elements(self):
assert parse_source_paths('["/data", 123]') == ["/data"]

def test_legacy_path_with_spaces(self):
assert parse_source_paths("/path/with spaces/data") == ["/path/with spaces/data"]

def test_three_paths(self):
paths = '["/appdata/app1", "/appdata/app2", "/appdata/app3"]'
result = parse_source_paths(paths)
assert result == ["/appdata/app1", "/appdata/app2", "/appdata/app3"]


class TestSerializeSourcePaths:
def test_single_path(self):
result = serialize_source_paths(["/data"])
assert json.loads(result) == ["/data"]

def test_multiple_paths(self):
result = serialize_source_paths(["/src", "/Documents"])
assert json.loads(result) == ["/src", "/Documents"]

def test_empty_list(self):
assert serialize_source_paths([]) == "[]"

def test_filters_empty_strings(self):
result = serialize_source_paths(["/data", "", " "])
assert json.loads(result) == ["/data"]

def test_strips_whitespace(self):
result = serialize_source_paths([" /data "])
assert json.loads(result) == ["/data"]

def test_roundtrip(self):
original = ["/home/user/src", "/home/user/Documents", "/backups"]
serialized = serialize_source_paths(original)
parsed = parse_source_paths(serialized)
assert parsed == original
Loading