fix(servers): number existing duplicate names

This commit is contained in:
Qiu Yuzhou
2026-10-06 13:42:15 +08:00
parent 0dc8775641
commit ac476d3764
3 changed files with 33 additions and 2 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ _避免用词_:导出(导出指配置组或诊断报告等文件快照)。
把剪贴板文本或文件中的服务器配置引入配置目录、形成独立手动子树的操作。来源资料中的标识只用于装配其内部结构,导入产生的每个节点都获得全新的本地持久身份;同一资料重复导入产生相互独立的服务器配置,不与既有节点合并或去重。
**节点复制(Node duplication)**:
在原节点之后的同级位置创建独立手动副本。组复制包含整棵子树,保留层级、顺序、连接字段和空组;所有节点身份及凭据引用重新生成。支持手动服务器、手动组和根层订阅组,不支持订阅服务器或嵌套订阅子组。订阅副本不携带订阅来源资料;顶层副本名称加本地化后缀并在同级重名时追加序号。目录和凭据作为一次逻辑变更提交,失败不发布部分子树,凭据回滚不完整时必须报告。复制不切换活动目标;在活动组内新增的副本按普通目录变更进入运行时收敛。
在原节点之后的同级位置创建独立手动副本。组复制包含整棵子树,保留层级、顺序、连接字段和空组;所有节点身份及凭据引用重新生成。支持手动服务器、手动组和根层订阅组,不支持订阅服务器或嵌套订阅子组。订阅副本不携带订阅来源资料;顶层副本名称加本地化后缀并在同级重名时追加序号;名称末尾已有“副本”或“Copy”(含编号)时沿用该后缀,以同级未占用的编号命名,避免重复追加后缀。目录和凭据作为一次逻辑变更提交,失败不发布部分子树,凭据回滚不完整时必须报告。复制不切换活动目标;在活动组内新增的副本按普通目录变更进入运行时收敛。
**导入来源(Import source)**:
一次服务器导入中可独立处理的一份资料,例如剪贴板文本或单个文件。每个来源独立解码、独立提交,是成功、部分成功与失败报告的粒度单位;一个来源失败不影响其他来源。
@@ -17,9 +17,18 @@ extension CatalogWorkflow {
let siblings = try source.children(of: parent)
guard let position = siblings.firstIndex(of: id) else { throw CatalogError.nodeNotFound(id) }
let names = Set(siblings.compactMap { source.entry(for: $0)?.displayName })
let base = "\(entry.displayName) \(nameSuffix)"
var base = "\(entry.displayName) \(nameSuffix)"
var name = base
var number = 2
// 两种语言的已保存后缀都识别,切换语言后仍沿用原名称的复制序列。
if let suffixRange = entry.displayName.range(
of: #" (?:副本|Copy)(?: [0-9]+)?$"#, options: .regularExpression)
{
let suffix = entry.displayName[suffixRange].split(separator: " ")[0]
base = "\(entry.displayName[..<suffixRange.lowerBound]) \(suffix)"
name = "\(base) 2"
number = 3
}
while names.contains(name) {
name = "\(base) \(number)"
number += 1
@@ -127,6 +127,28 @@ struct CatalogWorkflowDuplicateTests {
#expect(fixture.runtime.convergeSnapshots.last?.catalog.rootChildren == [original, copy])
}
@Test(arguments: ["副本", "Copy"])
func copyingCopiesUsesOneSuffixAndSiblingNumbers(suffix: String) async throws {
let fixture = try DuplicateFixture()
let workflow = fixture.workflow
let original = try await workflow.createGroup(named: "websocket", into: nil)
let first = try await workflow.duplicate(original, nameSuffix: suffix)
let second = try await workflow.duplicate(first, nameSuffix: suffix)
let third = try await workflow.duplicate(second, nameSuffix: suffix)
#expect(workflow.displayName(for: first) == "websocket \(suffix)")
#expect(workflow.displayName(for: second) == "websocket \(suffix) 2")
#expect(workflow.displayName(for: third) == "websocket \(suffix) 3")
let otherSuffix = suffix == "Copy" ? "副本" : "Copy"
let fourth = try await workflow.duplicate(third, nameSuffix: otherSuffix)
#expect(workflow.displayName(for: fourth) == "websocket \(suffix) 4")
let detached = try await workflow.createGroup(named: "Detached \(suffix) 2", into: nil)
let detachedCopy = try await workflow.duplicate(detached, nameSuffix: suffix)
#expect(workflow.displayName(for: detachedCopy) == "Detached \(suffix) 3")
let interior = try await workflow.createGroup(named: "websocket \(suffix) backup", into: nil)
let interiorCopy = try await workflow.duplicate(interior, nameSuffix: suffix)
#expect(workflow.displayName(for: interiorCopy) == "websocket \(suffix) backup \(suffix)")
}
@Test func serverCopyIsAdjacentAndIndependent() async throws {
let fixture = try DuplicateFixture()
let workflow = fixture.workflow