feat(13-05): implement keep_both_name() for D-03/D-05 collision naming

- keep_both_name(filename, counter) inserts counter before first extension
- Handles compound extensions (archive.tar.gz → archive (1).tar.gz)
- Handles extensionless files (README → README (1))
- Lives in services/cloud_operations as shared upload-queue helper
- All 23 upload contract + keep-both naming tests pass
This commit is contained in:
curo1305
2026-06-22 19:17:59 +02:00
parent f47e36d93d
commit e1ce4cbe14
+48 -1
View File
@@ -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"