From a750d87ae43161301773f9b033c22923f5684b1d Mon Sep 17 00:00:00 2001 From: Harry Date: Thu, 5 Feb 2026 14:42:42 +0800 Subject: [PATCH] feat: ensure unique names for asset nodes during creation and batch upload --- api/core/app/entities/app_asset_entities.py | 38 +++++++++++++-- api/services/app_asset_service.py | 51 ++++++++++++++++++--- 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/api/core/app/entities/app_asset_entities.py b/api/core/app/entities/app_asset_entities.py index 32376fd2c0..b18f0cfb9f 100644 --- a/api/core/app/entities/app_asset_entities.py +++ b/api/core/app/entities/app_asset_entities.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from collections import defaultdict from collections.abc import Generator from enum import StrEnum @@ -59,12 +60,12 @@ class BatchUploadNode(BaseModel): def to_app_asset_nodes(self, parent_id: str | None = None) -> list[AppAssetNode]: """ - Generate IDs and convert to AppAssetNode list. - Mutates self to set id field. + Generate IDs when missing and convert to AppAssetNode list. + Mutates self to set id field when it is not set. """ from uuid import uuid4 - self.id = str(uuid4()) + self.id = self.id or str(uuid4()) nodes: list[AppAssetNode] = [] if self.node_type == AssetNodeType.FOLDER: @@ -114,6 +115,37 @@ class AppAssetFileTree(BaseModel): nodes: list[AppAssetNode] = Field(default_factory=list, description="Flat list of all nodes in the tree") + def ensure_unique_name( + self, + parent_id: str | None, + name: str, + *, + is_file: bool, + extra_taken: set[str] | None = None, + ) -> str: + """ + Return a sibling-unique name by appending numeric suffixes when needed. + + The suffix format is " " (e.g. "report 1", "report 2"). For files, + the suffix is inserted before the extension. + """ + taken = extra_taken or set() + if not self.has_child_named(parent_id, name) and name not in taken: + return name + suffix_index = 1 + while True: + candidate = self._apply_name_suffix(name, suffix_index, is_file=is_file) + if not self.has_child_named(parent_id, candidate) and candidate not in taken: + return candidate + suffix_index += 1 + + @staticmethod + def _apply_name_suffix(name: str, suffix_index: int, *, is_file: bool) -> str: + if not is_file: + return f"{name} {suffix_index}" + stem, extension = os.path.splitext(name) + return f"{stem} {suffix_index}{extension}" + def get(self, node_id: str) -> AppAssetNode | None: return next((n for n in self.nodes if n.id == node_id), None) diff --git a/api/services/app_asset_service.py b/api/services/app_asset_service.py index 35daafe0ae..9f1b237f7a 100644 --- a/api/services/app_asset_service.py +++ b/api/services/app_asset_service.py @@ -187,7 +187,12 @@ class AppAssetService: assets = AppAssetService.get_or_create_assets(session, app_model, account_id) tree = assets.asset_tree - node = AppAssetNode.create_folder(str(uuid4()), name, parent_id) + unique_name = tree.ensure_unique_name( + parent_id, + name, + is_file=False, + ) + node = AppAssetNode.create_folder(str(uuid4()), unique_name, parent_id) try: tree.add(node) @@ -408,6 +413,9 @@ class AppAssetService: The file metadata is saved immediately. If the user doesn't upload, the download will fail when the file is accessed. + If a sibling with the same name exists, a numeric suffix is appended + to make the name unique (e.g. "report 1.txt"). + Returns: tuple of (node, upload_url) """ @@ -416,8 +424,13 @@ class AppAssetService: assets = AppAssetService.get_or_create_assets(session, app_model, account_id) tree = assets.asset_tree + unique_name = tree.ensure_unique_name( + parent_id, + name, + is_file=True, + ) node_id = str(uuid4()) - node = AppAssetNode.create_file(node_id, name, parent_id, size) + node = AppAssetNode.create_file(node_id, unique_name, parent_id, size) try: tree.add(node) @@ -452,15 +465,41 @@ class AppAssetService: if not input_children: return [] - new_nodes: list[AppAssetNode] = [] - for child in input_children: - new_nodes.extend(child.to_app_asset_nodes(None)) - with AppAssetService._lock(app_model.id): with Session(db.engine, expire_on_commit=False) as session: assets = AppAssetService.get_or_create_assets(session, app_model, account_id) tree = assets.asset_tree + def assign_ids_and_unique_names( + nodes: list[BatchUploadNode], + parent_id: str | None, + taken_by_parent: dict[str | None, set[str]], + ) -> None: + for node in nodes: + if node.id is None: + node.id = str(uuid4()) + if parent_id not in taken_by_parent: + taken_by_parent[parent_id] = { + child.name for child in tree.get_children(parent_id) + } + taken = taken_by_parent[parent_id] + unique_name = tree.ensure_unique_name( + parent_id, + node.name, + is_file=node.node_type == AssetNodeType.FILE, + extra_taken=taken, + ) + node.name = unique_name + taken.add(unique_name) + if node.node_type == AssetNodeType.FOLDER: + assign_ids_and_unique_names(node.children, node.id, taken_by_parent) + + assign_ids_and_unique_names(input_children, None, {}) + + new_nodes: list[AppAssetNode] = [] + for child in input_children: + new_nodes.extend(child.to_app_asset_nodes(None)) + try: for node in new_nodes: tree.add(node)