Skip to content
Open
Show file tree
Hide file tree
Changes from 19 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,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:
Comment thread
mlapaglia marked this conversation as resolved.
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},
)
Comment on lines +48 to +53
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This migration stores conn_val as a JSON-encoded string (via json.dumps) into a sa.JSON() column. That will persist a JSON string instead of a JSON array in some DBs/drivers, causing schedule.source_paths to come back as a string (breaking code that expects a list, e.g. list(schedule.source_paths) in the edit form context). Write the Python list directly to the JSON column (bind param should be the list), not a pre-dumped string.

Copilot uses AI. Check for mistakes.

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")
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 = 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
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
8 changes: 7 additions & 1 deletion src/borgitory/models/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading