diff --git a/src/borgitory/alembic/versions/b2c3d4e5f6a7_add_source_paths_json_column.py b/src/borgitory/alembic/versions/b2c3d4e5f6a7_add_source_paths_json_column.py
new file mode 100644
index 00000000..6d0817e9
--- /dev/null
+++ b/src/borgitory/alembic/versions/b2c3d4e5f6a7_add_source_paths_json_column.py
@@ -0,0 +1,65 @@
+"""Add source_paths_json column; backfill from source_path (string). source_path kept as deprecated.
+
+Revision ID: b2c3d4e5f6a7
+Revises: 2628b151e709
+Create Date: 2026-03-07 00:00:00.000000
+
+"""
+
+import json
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = "b2c3d4e5f6a7"
+down_revision: Union[str, Sequence[str], None] = "2628b151e709"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def _string_to_paths_list(value: str | None) -> list[str]:
+ if value is None or not value or not str(value).strip():
+ return []
+ s = str(value).strip()
+ if s.startswith("["):
+ try:
+ parsed = json.loads(s)
+ if isinstance(parsed, list):
+ return [p for p in parsed if isinstance(p, str) and p.strip()]
+ return []
+ except json.JSONDecodeError, ValueError:
+ return []
+ return [s]
+
+
+def upgrade() -> None:
+ with op.batch_alter_table("schedules", schema=None) as batch_op:
+ batch_op.add_column(sa.Column("source_paths_json", sa.JSON(), nullable=True))
+
+ connection = op.get_bind()
+ rows = connection.execute(
+ sa.text("SELECT id, source_path FROM schedules")
+ ).fetchall()
+
+ for row in rows:
+ row_id, value = row[0], row[1]
+ paths = _string_to_paths_list(value)
+ conn_val = json.dumps(paths) if paths else "[]"
+ connection.execute(
+ sa.text("UPDATE schedules SET source_paths_json = :val WHERE id = :id"),
+ {"val": conn_val, "id": row_id},
+ )
+
+ with op.batch_alter_table("schedules", schema=None) as batch_op:
+ batch_op.alter_column(
+ "source_paths_json",
+ existing_type=sa.JSON(),
+ nullable=False,
+ )
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("schedules", schema=None) as batch_op:
+ batch_op.drop_column("source_paths_json")
diff --git a/src/borgitory/api/repositories.py b/src/borgitory/api/repositories.py
index 1303c600..aef7c44e 100644
--- a/src/borgitory/api/repositories.py
+++ b/src/borgitory/api/repositories.py
@@ -133,7 +133,7 @@ async def list_directories_autocomplete(
for param_name in form_data.keys():
if param_name in [
"path",
- "source_path",
+ "source_paths",
"create-path",
"import-path",
"backup-source-path",
diff --git a/src/borgitory/api/schedules.py b/src/borgitory/api/schedules.py
index b4e1469e..f4df0090 100644
--- a/src/borgitory/api/schedules.py
+++ b/src/borgitory/api/schedules.py
@@ -23,6 +23,10 @@
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,
+ serialize_source_paths,
+)
router = APIRouter()
@@ -93,7 +97,7 @@ async def create_schedule(
name=schedule.name,
repository_id=schedule.repository_id,
cron_expression=schedule.cron_expression,
- source_path=schedule.source_path or "",
+ source_paths=schedule.source_paths,
cloud_sync_config_id=schedule.cloud_sync_config_id,
prune_config_id=schedule.prune_config_id,
notification_config_id=schedule.notification_config_id,
@@ -242,7 +246,16 @@ 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_list = list(schedule.source_paths) if schedule.source_paths else []
+ context = {
+ **form_data,
+ "schedule": schedule,
+ "is_edit_mode": True,
+ "source_paths": source_paths_list,
+ "source_paths_json": json.dumps(schedule.source_paths)
+ if schedule.source_paths
+ else "[]",
+ }
return templates.TemplateResponse(
request, "partials/schedules/edit_form.html", context
@@ -603,6 +616,132 @@ async def close_modal() -> HTMLResponse:
return HTMLResponse(content='
', 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_paths_value = str(json_data.get("source_paths", "[]"))
+ except ValueError, TypeError, KeyError:
+ source_paths_value = "[]"
+
+ paths = parse_source_paths(source_paths_value)
+ if not paths:
+ paths = [""]
+
+ return templates.TemplateResponse(
+ request,
+ "partials/shared/source_paths_modal.html",
+ {"source_paths": 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='', 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},
+ )
+
+
+@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:
+ pass
+
+ if not current_paths:
+ current_paths = [""]
+
+ return templates.TemplateResponse(
+ request,
+ "partials/shared/source_paths_container.html",
+ {"source_paths": current_paths},
+ )
+
+
# Pattern API endpoints
@router.post("/patterns/add-pattern-field", response_class=HTMLResponse)
async def add_pattern_field(
diff --git a/src/borgitory/models/database.py b/src/borgitory/models/database.py
index d8ef6312..f65dbde0 100644
--- a/src/borgitory/models/database.py
+++ b/src/borgitory/models/database.py
@@ -28,6 +28,7 @@
Text,
ForeignKey,
Uuid,
+ JSON,
)
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import Mapped, declarative_base, mapped_column
@@ -232,7 +233,12 @@ class Schedule(Base):
)
name: Mapped[str] = mapped_column(String, nullable=False)
cron_expression: Mapped[str] = mapped_column(String, nullable=False)
- source_path: Mapped[str] = mapped_column(String, nullable=False, default="/data")
+ source_paths: Mapped[List[str]] = mapped_column(
+ "source_paths_json", JSON, nullable=False, default=lambda: []
+ )
+ source_paths_legacy: Mapped[str] = mapped_column(
+ "source_path", String, nullable=False, default="[]"
+ ) # deprecated: use source_paths (JSON) instead
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
last_run: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
next_run: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
diff --git a/src/borgitory/models/schemas.py b/src/borgitory/models/schemas.py
index 9e34b23a..86da5c23 100644
--- a/src/borgitory/models/schemas.py
+++ b/src/borgitory/models/schemas.py
@@ -8,6 +8,9 @@
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 (
+ parse_source_paths,
+)
def validate_patterns_json(patterns_json: str) -> tuple[bool, Optional[str]]:
@@ -91,6 +94,31 @@ def validate_patterns_json(patterns_json: str) -> tuple[bool, Optional[str]]:
ABSOLUTE_PATH_PATTERN = r"^/.*"
+def _coerce_source_paths_to_list(v: object) -> list[str]:
+ """Coerce source_paths input to a list of path strings.
+
+ Accepts a JSON array string, a plain list of strings, None, or empty string.
+ """
+ if v is None or (isinstance(v, str) and not v.strip()):
+ return []
+ if isinstance(v, list):
+ return [p.strip() for p in v if isinstance(p, str) and p.strip()]
+ if isinstance(v, str):
+ return parse_source_paths(v)
+ return []
+
+
+def _validate_source_paths_absolute_list(paths: list[str]) -> list[str]:
+ """Validate that every path is absolute."""
+ 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 paths
+
+
# Enums for type safety and validation
class JobStatus(str, Enum):
PENDING = "pending"
@@ -305,7 +333,10 @@ def validate_cron_expression(cls, v: str) -> str:
class ScheduleCreate(ScheduleBase):
repository_id: int
- source_path: Optional[str] = "/data"
+ source_paths: list[str] = Field(
+ default_factory=list,
+ description="List of absolute source paths",
+ )
cloud_sync_config_id: Optional[int] = None
prune_config_id: Optional[int] = None
check_config_id: Optional[int] = None
@@ -314,6 +345,16 @@ class ScheduleCreate(ScheduleBase):
post_job_hooks: Optional[str] = None
patterns: Optional[str] = None
+ @field_validator("source_paths", mode="before")
+ @classmethod
+ def coerce_source_paths(cls, v: object) -> list[str]:
+ return _coerce_source_paths_to_list(v)
+
+ @field_validator("source_paths", mode="after")
+ @classmethod
+ def validate_source_paths(cls, v: list[str]) -> list[str]:
+ return _validate_source_paths_absolute_list(v)
+
@field_validator("cloud_sync_config_id", mode="before")
@classmethod
def validate_cloud_sync_config_id(cls, v: Union[str, int, None]) -> Optional[int]:
@@ -388,7 +429,7 @@ class ScheduleUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=128)
cron_expression: Optional[str] = Field(None, min_length=5)
repository_id: Optional[int] = None
- source_path: Optional[str] = None
+ source_paths: Optional[list[str]] = None
cloud_sync_config_id: Optional[int] = None
prune_config_id: Optional[int] = None
check_config_id: Optional[int] = None
@@ -398,6 +439,20 @@ class ScheduleUpdate(BaseModel):
post_job_hooks: Optional[str] = None
patterns: Optional[str] = None
+ @field_validator("source_paths", mode="before")
+ @classmethod
+ def coerce_source_paths(cls, v: object) -> Optional[list[str]]:
+ if v is None:
+ return None
+ return _coerce_source_paths_to_list(v)
+
+ @field_validator("source_paths", mode="after")
+ @classmethod
+ def validate_source_paths(cls, v: Optional[list[str]]) -> Optional[list[str]]:
+ if v is None:
+ return None
+ return _validate_source_paths_absolute_list(v)
+
@field_validator("pre_job_hooks", mode="before")
@classmethod
def validate_pre_job_hooks_update(cls, v: Optional[str]) -> Optional[str]:
@@ -486,7 +541,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_paths: list[str] = Field(default_factory=list)
enabled: bool
last_run: Optional[datetime] = None
next_run: Optional[datetime] = None
@@ -604,10 +659,9 @@ class NotificationConfig(NotificationConfigBase):
class BackupRequest(BaseModel):
repository_id: int = Field(gt=0)
- source_path: str = Field(
- default="/",
- pattern=ABSOLUTE_PATH_PATTERN,
- description="Absolute path to source directory",
+ source_paths: list[str] = Field(
+ default_factory=list,
+ description="List of absolute source paths to backup.",
)
compression: CompressionType = CompressionType.ZSTD
dry_run: bool = False
@@ -620,6 +674,16 @@ class BackupRequest(BaseModel):
post_job_hooks: Optional[str] = None
patterns: Optional[str] = None
+ @field_validator("source_paths", mode="before")
+ @classmethod
+ def coerce_source_paths(cls, v: object) -> list[str]:
+ return _coerce_source_paths_to_list(v)
+
+ @field_validator("source_paths", mode="after")
+ @classmethod
+ def validate_source_paths(cls, v: list[str]) -> list[str]:
+ return _validate_source_paths_absolute_list(v)
+
@field_validator("dry_run", mode="before")
@classmethod
def validate_dry_run(cls, v: Union[str, bool, int]) -> bool:
diff --git a/src/borgitory/services/jobs/job_service.py b/src/borgitory/services/jobs/job_service.py
index be359f00..6bc22229 100644
--- a/src/borgitory/services/jobs/job_service.py
+++ b/src/borgitory/services/jobs/job_service.py
@@ -37,7 +37,7 @@
class BackupParams:
"""Parameters for backup tasks."""
- source_path: str
+ source_paths: list[str]
compression: str
dry_run: bool
ignore_lock: bool
@@ -124,7 +124,7 @@ async def create_backup_job(
logger.warning(f"Failed to parse patterns: {str(e)}")
backup_params: ConfigDict = {
- "source_path": backup_request.source_path,
+ "source_paths": backup_request.source_paths,
"compression": backup_request.compression,
"dry_run": backup_request.dry_run,
"ignore_lock": backup_request.ignore_lock,
diff --git a/src/borgitory/services/jobs/task_executors/backup_task_executor.py b/src/borgitory/services/jobs/task_executors/backup_task_executor.py
index 40dcd2b6..fad27919 100644
--- a/src/borgitory/services/jobs/task_executors/backup_task_executor.py
+++ b/src/borgitory/services/jobs/task_executors/backup_task_executor.py
@@ -82,14 +82,13 @@ def task_output_callback(line: str) -> None:
},
)
- # Build backup command
- source_path = params.get("source_path")
+ source_paths = params.get("source_paths")
archive_name = params.get(
"archive_name", f"backup-{now_utc().strftime('%Y%m%d-%H%M%S')}"
)
logger.info(
- f"Backup task parameters - source_path: {source_path}, archive_name: {archive_name}"
+ f"Backup task parameters - source_paths: {source_paths}, archive_name: {archive_name}"
)
logger.info(f"All task parameters: {params}")
@@ -111,8 +110,10 @@ def task_output_callback(line: str) -> None:
additional_args.append(f"{repository_path}::{archive_name}")
- if source_path:
- additional_args.append(str(source_path))
+ paths = source_paths if isinstance(source_paths, list) else []
+ for path in paths:
+ if path and str(path).strip():
+ additional_args.append(str(path).strip())
logger.info(f"Final additional_args for Borg command: {additional_args}")
diff --git a/src/borgitory/services/scheduling/schedule_service.py b/src/borgitory/services/scheduling/schedule_service.py
index a79bbca1..b3224717 100644
--- a/src/borgitory/services/scheduling/schedule_service.py
+++ b/src/borgitory/services/scheduling/schedule_service.py
@@ -138,7 +138,7 @@ async def create_schedule(
name: str,
repository_id: int,
cron_expression: str,
- source_path: str,
+ source_paths: list[str],
cloud_sync_config_id: Optional[int] = None,
prune_config_id: Optional[int] = None,
notification_config_id: Optional[int] = None,
@@ -175,7 +175,7 @@ async def create_schedule(
db_schedule.name = name
db_schedule.repository_id = repository_id
db_schedule.cron_expression = cron_expression
- db_schedule.source_path = source_path
+ db_schedule.source_paths = source_paths
db_schedule.enabled = True
db_schedule.cloud_sync_config_id = cloud_sync_config_id
db_schedule.prune_config_id = prune_config_id
@@ -427,7 +427,7 @@ def safe_json_string(value: Any) -> Optional[str]:
"name": name,
"repository_id": repository_id,
"cron_expression": cron_expression,
- "source_path": json_data.get("source_path", ""),
+ "source_paths": json_data.get("source_paths", []),
"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(
diff --git a/src/borgitory/services/scheduling/scheduler_service.py b/src/borgitory/services/scheduling/scheduler_service.py
index 31bdf89b..73d48c35 100644
--- a/src/borgitory/services/scheduling/scheduler_service.py
+++ b/src/borgitory/services/scheduling/scheduler_service.py
@@ -79,12 +79,12 @@ async def execute_scheduled_backup(schedule_id: int) -> None:
logger.info("SCHEDULER: Creating scheduled backup via JobService")
logger.info(f" - repository: {repository.name}")
logger.info(f" - schedule: {schedule.name}")
- logger.info(f" - source_path: {schedule.source_path}")
+ logger.info(f" - source_paths: {schedule.source_paths}")
logger.info(f" - cloud_sync_config_id: {schedule.cloud_sync_config_id}")
backup_request = BackupRequest(
repository_id=repository.id,
- source_path=schedule.source_path,
+ source_paths=schedule.source_paths,
compression=CompressionType.ZSTD,
dry_run=False,
prune_config_id=schedule.prune_config_id,
diff --git a/src/borgitory/services/task_definition_builder.py b/src/borgitory/services/task_definition_builder.py
index f18b4538..05d06bca 100644
--- a/src/borgitory/services/task_definition_builder.py
+++ b/src/borgitory/services/task_definition_builder.py
@@ -38,7 +38,7 @@ def __init__(self) -> None:
def build_backup_task(
self,
repository_name: str,
- source_path: str = "/data",
+ source_paths: list[str] | None = None,
compression: str = "zstd",
dry_run: bool = False,
ignore_lock: bool = False,
@@ -49,7 +49,7 @@ def build_backup_task(
Args:
repository_name: Name of the repository for display
- source_path: Path to backup from
+ source_paths: List of paths to backup
compression: Compression algorithm to use
dry_run: Whether this is a dry run
ignore_lock: Whether to run 'borg break-lock' before backup
@@ -58,7 +58,7 @@ def build_backup_task(
Task definition dictionary
"""
parameters: ConfigDict = {
- "source_path": source_path,
+ "source_paths": source_paths or [],
"compression": compression,
"dry_run": dry_run,
"ignore_lock": ignore_lock,
@@ -419,7 +419,11 @@ async def build_task_list(
if include_backup:
if backup_params:
- source_path = str(backup_params.get("source_path", "/data"))
+ source_paths_value = backup_params.get("source_paths") or []
+ if isinstance(source_paths_value, list):
+ source_paths = source_paths_value
+ else:
+ source_paths = []
compression = str(backup_params.get("compression", "zstd"))
dry_run = bool(backup_params.get("dry_run", False))
ignore_lock = bool(backup_params.get("ignore_lock", False))
@@ -431,8 +435,7 @@ async def build_task_list(
else:
patterns = []
else:
- # Use defaults when no backup_params provided
- source_path = "/data"
+ source_paths = []
compression = "zstd"
dry_run = False
ignore_lock = False
@@ -441,7 +444,7 @@ async def build_task_list(
tasks.append(
self.build_backup_task(
repository_name,
- source_path,
+ source_paths,
compression,
dry_run,
ignore_lock,
diff --git a/src/borgitory/templates/partials/backups/manual_form.html b/src/borgitory/templates/partials/backups/manual_form.html
index ddbdf24c..60a4bc1a 100644
--- a/src/borgitory/templates/partials/backups/manual_form.html
+++ b/src/borgitory/templates/partials/backups/manual_form.html
@@ -19,13 +19,18 @@ Create Backup
- {% set input_id = "backup-source-path" %}
- {% set input_name = "source_path" %}
- {% set placeholder = "/pictures/" %}
- {% set required = false %}
- {% include "partials/shared/path_autocomplete.html" %}
+
+
+
diff --git a/src/borgitory/templates/partials/schedules/create_form.html b/src/borgitory/templates/partials/schedules/create_form.html
index 92abd75c..2352da44 100644
--- a/src/borgitory/templates/partials/schedules/create_form.html
+++ b/src/borgitory/templates/partials/schedules/create_form.html
@@ -26,13 +26,18 @@ Create Schedule
- {% 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" %}
+
+
-
+
+
diff --git a/src/borgitory/templates/partials/schedules/edit_form.html b/src/borgitory/templates/partials/schedules/edit_form.html
index a26aec3f..a4a2f4d4 100644
--- a/src/borgitory/templates/partials/schedules/edit_form.html
+++ b/src/borgitory/templates/partials/schedules/edit_form.html
@@ -29,14 +29,27 @@ Edit Schedule
- {% 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" %}
+ {% set sp_count = source_paths | length if source_paths is defined and source_paths else 0 %}
+
+