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 %} + +