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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user