Skip to content
Open
Show file tree
Hide file tree
Changes from 15 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Rename source_path to source_paths and convert data to JSON arrays

Revision ID: a1b2c3d4e5f6
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 identifiers, used by Alembic.
revision: str = "a1b2c3d4e5f6"
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 upgrade() -> None:
"""Rename source_path to source_paths and convert plain strings to JSON arrays."""
with op.batch_alter_table("schedules", schema=None) as batch_op:
batch_op.alter_column("source_path", new_column_name="source_paths")

connection = op.get_bind()
rows = connection.execute(
sa.text("SELECT id, source_paths FROM schedules")
).fetchall()

for row in rows:
row_id, value = row[0], row[1]
if value is None or not value.strip():
new_value = "[]"
elif value.strip().startswith("["):
new_value = value
else:
new_value = json.dumps([value.strip()])

connection.execute(
sa.text("UPDATE schedules SET source_paths = :val WHERE id = :id"),
{"val": new_value, "id": row_id},
)


def downgrade() -> None:
"""Rename source_paths back to source_path and convert JSON arrays to plain strings."""
connection = op.get_bind()
rows = connection.execute(
sa.text("SELECT id, source_paths FROM schedules")
).fetchall()

for row in rows:
row_id, value = row[0], row[1]
if value and value.strip().startswith("["):
try:
paths = json.loads(value)
new_value = paths[0] if paths else "/data"
except json.JSONDecodeError, IndexError:
Comment thread
mlapaglia marked this conversation as resolved.
Outdated
new_value = "/data"
else:
new_value = value or "/data"

connection.execute(
sa.text("UPDATE schedules SET source_paths = :val WHERE id = :id"),
{"val": new_value, "id": row_id},
)

with op.batch_alter_table("schedules", schema=None) as batch_op:
batch_op.alter_column("source_paths", new_column_name="source_path")
2 changes: 1 addition & 1 deletion src/borgitory/api/repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
143 changes: 141 additions & 2 deletions src/borgitory/api/schedules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = (
parse_source_paths(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": schedule.source_paths or "[]",
}

return templates.TemplateResponse(
request, "partials/schedules/edit_form.html", context
Expand Down Expand Up @@ -603,6 +616,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_paths_value = str(json_data.get("source_paths", "[]"))
except ValueError, TypeError, KeyError:
Comment thread
mlapaglia marked this conversation as resolved.
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='<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
2 changes: 1 addition & 1 deletion src/borgitory/models/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ 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[str] = mapped_column(String, nullable=False, default="[]")
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
Comment thread
mlapaglia marked this conversation as resolved.
Outdated
last_run: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
next_run: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
Expand Down
Loading
Loading