diff --git a/backend/services/cloud_operations.py b/backend/services/cloud_operations.py index 27ac520..7b95453 100644 --- a/backend/services/cloud_operations.py +++ b/backend/services/cloud_operations.py @@ -2,7 +2,10 @@ Cloud operations orchestration service — Phase 13. This module is the single service-layer seam for all Phase 13 cloud mutation and -connection-lifecycle operations. It: +connection-lifecycle operations. It also exposes shared naming utilities used by +the upload route for keep-both collision resolution (D-03, D-05). + +It: - Resolves owned connections via services.cloud_items.resolve_owned_connection - Decrypts credentials only at the provider boundary (T-13-11) @@ -24,6 +27,7 @@ Domain exceptions raised here: """ from __future__ import annotations +import pathlib import uuid from typing import Optional @@ -34,6 +38,49 @@ from db.models import CloudConnection, CloudItem, CloudFolderState from services.cloud_items import resolve_owned_connection, ConnectionNotFound # noqa: F401 +# ── Keep-both naming helper ─────────────────────────────────────────────────── + + +def keep_both_name(filename: str, counter: int) -> str: + """Insert a collision counter before the file extension. + + D-03 / D-05: When the user chooses "Keep both" for a same-name upload + conflict, DocuVault renames the incoming file by inserting ` (N)` before + the first file extension (or at the end if no extension is present). + + Examples:: + + keep_both_name("Report.pdf", 1) → "Report (1).pdf" + keep_both_name("Report.pdf", 2) → "Report (2).pdf" + keep_both_name("README", 1) → "README (1)" + keep_both_name("archive.tar.gz", 1) → "archive (1).tar.gz" + keep_both_name("My Document.docx", 1) → "My Document (1).docx" + + The counter is always placed before the first dot in the filename so that + compound extensions (e.g. ``.tar.gz``) are preserved intact. + + Args: + filename: The original filename (basename only — no path components). + counter: Positive integer counter to insert (1-based by convention). + + Returns: + The renamed filename with the counter inserted before the extension. + """ + p = pathlib.PurePosixPath(filename) + # Use the stem from PurePosixPath but handle the suffix correctly: + # PurePosixPath("archive.tar.gz").suffix == ".gz" — we want all suffixes. + stem = p.name + suffix = "" + + # Find the first dot that separates name from extension + dot_pos = stem.find(".") + if dot_pos > 0: + suffix = stem[dot_pos:] # everything from the first dot onward + stem = stem[:dot_pos] # everything before the first dot + + return f"{stem} ({counter}){suffix}" + + # ── Connection status constants ─────────────────────────────────────────────── STATUS_ACTIVE = "ACTIVE"