Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -505,3 +505,4 @@ tests/e2e/.shared-state.json

.playwright-mcp
E2E_REPORT.md
.claude/
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public interface IEnvironmentMapRepository
Task<EnvironmentMap?> GetDeletedByIdAsync(int id, CancellationToken cancellationToken = default);
Task<EnvironmentMap?> GetByNameAsync(string name, CancellationToken cancellationToken = default);
Task<EnvironmentMap?> GetByFileHashAsync(string sha256Hash, CancellationToken cancellationToken = default);
Task<bool> ExistsByNameAsync(string name, CancellationToken cancellationToken = default);
Task<IReadOnlyList<string>> GetNamesByPrefixAsync(string prefix, CancellationToken cancellationToken = default);
Task<EnvironmentMap?> GetByFileHashesAsync(
IEnumerable<string> sha256Hashes,
EnvironmentMapProjectionType projectionType,
Expand Down
2 changes: 2 additions & 0 deletions src/Application/Abstractions/Repositories/IModelRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ public interface IModelRepository
Task<Model?> GetByIdForAssociationAsync(int id, CancellationToken cancellationToken = default);
Task<Model?> GetDeletedByIdAsync(int id, CancellationToken cancellationToken = default);
Task<Model?> GetByFileHashAsync(string sha256Hash, CancellationToken cancellationToken = default);
Task<bool> ExistsByNameAsync(string name, CancellationToken cancellationToken = default);
Task<IReadOnlyList<string>> GetNamesByPrefixAsync(string prefix, CancellationToken cancellationToken = default);
Task<(IEnumerable<Model> Items, int TotalCount)> GetPagedAsync(
int page, int pageSize,
int? packId = null, int? projectId = null, int? textureSetId = null, IReadOnlyCollection<int>? categoryIds = null, IReadOnlyCollection<string>? normalizedTagNames = null, bool? hasConceptImages = null,
Expand Down
2 changes: 2 additions & 0 deletions src/Application/Abstractions/Repositories/ISoundRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ public interface ISoundRepository
Task<Sound?> GetDeletedByIdAsync(int id, CancellationToken cancellationToken = default);
Task<Sound?> GetByNameAsync(string name, CancellationToken cancellationToken = default);
Task<Sound?> GetByFileHashAsync(string sha256Hash, CancellationToken cancellationToken = default);
Task<bool> ExistsByNameAsync(string name, CancellationToken cancellationToken = default);
Task<IReadOnlyList<string>> GetNamesByPrefixAsync(string prefix, CancellationToken cancellationToken = default);
Task<(IEnumerable<Sound> Items, int TotalCount)> GetPagedAsync(
int page, int pageSize,
int? packId = null, int? projectId = null, int? categoryId = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ public interface ISpriteRepository
Task<Sprite?> GetDeletedByIdAsync(int id, CancellationToken cancellationToken = default);
Task<Sprite?> GetByNameAsync(string name, CancellationToken cancellationToken = default);
Task<Sprite?> GetByFileHashAsync(string sha256Hash, CancellationToken cancellationToken = default);
Task<bool> ExistsByNameAsync(string name, CancellationToken cancellationToken = default);
Task<IReadOnlyList<string>> GetNamesByPrefixAsync(string prefix, CancellationToken cancellationToken = default);
Task<(IEnumerable<Sprite> Items, int TotalCount)> GetPagedAsync(
int page, int pageSize,
int? packId = null, int? projectId = null, int? categoryId = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public interface ITextureSetRepository
Task<TextureSet?> GetDeletedByIdAsync(int id, CancellationToken cancellationToken = default);
Task<TextureSet?> GetByNameAsync(string name, CancellationToken cancellationToken = default);
Task<TextureSet?> GetByFileHashAsync(string sha256Hash, CancellationToken cancellationToken = default);
Task<bool> ExistsByNameAsync(string name, CancellationToken cancellationToken = default);
Task<IReadOnlyList<string>> GetNamesByPrefixAsync(string prefix, CancellationToken cancellationToken = default);
Task<(IEnumerable<TextureSet> Items, int TotalCount)> GetPagedAsync(
int page, int pageSize,
int? packId = null, int? projectId = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Application.Abstractions.Messaging;
using Application.Abstractions.Repositories;
using Application.Abstractions.Services;
using Application.Models;
using Application.Services;
using Domain.Models;
using Domain.Services;
Expand All @@ -15,6 +16,7 @@ internal sealed class CreateEnvironmentMapWithFileCommandHandler : ICommandHandl
private readonly IBatchUploadRepository _batchUploadRepository;
private readonly IFileCreationService _fileCreationService;
private readonly IEnvironmentMapSizeLabelService _sizeLabelService;
private readonly ISettingRepository _settingRepository;
private readonly IThumbnailQueue _thumbnailQueue;
private readonly IDateTimeProvider _dateTimeProvider;

Expand All @@ -23,13 +25,15 @@ public CreateEnvironmentMapWithFileCommandHandler(
IBatchUploadRepository batchUploadRepository,
IFileCreationService fileCreationService,
IEnvironmentMapSizeLabelService sizeLabelService,
ISettingRepository settingRepository,
IThumbnailQueue thumbnailQueue,
IDateTimeProvider dateTimeProvider)
{
_environmentMapRepository = environmentMapRepository;
_batchUploadRepository = batchUploadRepository;
_fileCreationService = fileCreationService;
_sizeLabelService = sizeLabelService;
_settingRepository = settingRepository;
_thumbnailQueue = thumbnailQueue;
_dateTimeProvider = dateTimeProvider;
}
Expand Down Expand Up @@ -94,7 +98,16 @@ await _batchUploadRepository.AddRangeAsync(
if (sizeLabelResult.IsFailure)
return Result.Failure<CreateEnvironmentMapWithFileResponse>(sizeLabelResult.Error);

var environmentMap = EnvironmentMap.Create(command.Name, now);
// Resolve name collision based on DuplicateNamePolicy setting
var nameResult = await AssetNameService.ResolveNameAsync(
command.Name, "EnvironmentMap",
_environmentMapRepository.ExistsByNameAsync,
_environmentMapRepository.GetNamesByPrefixAsync,
_settingRepository, cancellationToken);
if (nameResult.IsFailure)
return Result.Failure<CreateEnvironmentMapWithFileResponse>(nameResult.Error);

var environmentMap = EnvironmentMap.Create(nameResult.Value, now);
var variant = resolvedFiles.CreateVariant(sizeLabelResult.Value, now);
environmentMap.AddVariant(variant, now);

Expand Down
16 changes: 15 additions & 1 deletion src/Application/Models/AddModelCommandHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,24 @@ internal class AddModelCommandHandler : ICommandHandler<AddModelCommand, AddMode
private readonly IDateTimeProvider _dateTimeProvider;
private readonly IDomainEventDispatcher _domainEventDispatcher;
private readonly IBatchUploadRepository _batchUploadRepository;
private readonly ISettingRepository _settingRepository;

public AddModelCommandHandler(
IModelRepository modelRepository,
IModelVersionRepository versionRepository,
IFileCreationService fileCreationService,
IDateTimeProvider dateTimeProvider,
IDomainEventDispatcher domainEventDispatcher,
IBatchUploadRepository batchUploadRepository)
IBatchUploadRepository batchUploadRepository,
ISettingRepository settingRepository)
{
_modelRepository = modelRepository;
_versionRepository = versionRepository;
_fileCreationService = fileCreationService;
_dateTimeProvider = dateTimeProvider;
_domainEventDispatcher = domainEventDispatcher;
_batchUploadRepository = batchUploadRepository;
_settingRepository = settingRepository;
}

public async Task<Result<AddModelCommandResponse>> Handle(AddModelCommand command, CancellationToken cancellationToken)
Expand Down Expand Up @@ -86,6 +89,17 @@ public async Task<Result<AddModelCommandResponse>> Handle(AddModelCommand comman
var modelName = command.ModelName ??
Path.GetFileNameWithoutExtension(command.File.FileName);

// Resolve name collision based on DuplicateNamePolicy setting
var nameResult = await AssetNameService.ResolveNameAsync(
modelName, "Model",
_modelRepository.ExistsByNameAsync,
_modelRepository.GetNamesByPrefixAsync,
_settingRepository, cancellationToken);
if (nameResult.IsFailure)
return Result.Failure<AddModelCommandResponse>(nameResult.Error);

modelName = nameResult.Value;

try
{
var model = Model.Create(modelName, _dateTimeProvider.UtcNow);
Expand Down
90 changes: 90 additions & 0 deletions src/Application/Models/AssetNameService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using System.Text.RegularExpressions;
using Application.Abstractions.Repositories;
using Application.Settings;
using SharedKernel;

namespace Application.Models;

/// <summary>
/// Centralized service for resolving asset name collisions based on the configured duplicate name policy.
/// Works for all asset types (Models, Sprites, Sounds, TextureSets, EnvironmentMaps).
/// </summary>
internal static partial class AssetNameService
{
/// <summary>
/// Resolves the final asset name based on the duplicate name policy.
/// Returns the original or auto-renamed name on success, or a failure if the name is rejected.
/// </summary>
public static async Task<Result<string>> ResolveNameAsync(
string requestedName,
string assetTypeName,
Func<string, CancellationToken, Task<bool>> existsByNameAsync,
Func<string, CancellationToken, Task<IReadOnlyList<string>>> getNamesByPrefixAsync,
ISettingRepository settingRepository,
CancellationToken cancellationToken)
{
var exists = await existsByNameAsync(requestedName, cancellationToken);
if (!exists)
return Result.Success(requestedName);

var policy = await GetPolicyAsync(settingRepository, cancellationToken);

if (policy == "Reject")
{
return Result.Failure<string>(
new Error($"{assetTypeName}NameAlreadyExists", $"A {assetTypeName.ToLowerInvariant()} with the name '{requestedName}' already exists."));
}

// AutoRename: generate next available name
var baseName = GetBaseName(requestedName);
var existingNames = await getNamesByPrefixAsync(baseName, cancellationToken);
var uniqueName = GenerateUniqueName(baseName, existingNames);

return Result.Success(uniqueName);
}

/// <summary>
/// Gets the configured duplicate name policy. Defaults to "Reject" if not set.
/// Falls back to legacy "ModelDuplicateNamePolicy" key for backward compatibility.
/// </summary>
internal static async Task<string> GetPolicyAsync(
ISettingRepository settingRepository,
CancellationToken cancellationToken)
{
var setting = await settingRepository.GetByKeyAsync(SettingKeys.DuplicateNamePolicy, cancellationToken);
setting ??= await settingRepository.GetByKeyAsync("ModelDuplicateNamePolicy", cancellationToken);
return setting?.Value is "AutoRename" ? "AutoRename" : "Reject";
}

/// <summary>
/// Extracts the base name from a potentially suffixed name.
/// "Chair (3)" → "Chair", "Chair" → "Chair", "Chair (2) (3)" → "Chair (2)"
/// </summary>
internal static string GetBaseName(string name)
{
var match = DuplicateSuffixRegex().Match(name);
return match.Success ? match.Groups[1].Value : name;
}

/// <summary>
/// Generates the next available unique name using Windows-style duplicate naming.
/// </summary>
internal static string GenerateUniqueName(string baseName, IReadOnlyList<string> existingNames)
{
var nameSet = new HashSet<string>(existingNames, StringComparer.Ordinal);

// Start from 2 (Windows convention: "Chair", "Chair (2)", "Chair (3)")
for (int i = 2; i <= 10000; i++)
{
var candidate = $"{baseName} ({i})";
if (!nameSet.Contains(candidate))
return candidate;
}

// Extremely unlikely fallback
return $"{baseName} ({Guid.NewGuid().ToString("N")[..8]})";
}

[GeneratedRegex(@"^(.+?)\s+\(\d+\)$")]
private static partial Regex DuplicateSuffixRegex();
}
17 changes: 16 additions & 1 deletion src/Application/Models/CreateModelFromBlendCommandHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,22 @@ internal class CreateModelFromBlendCommandHandler : ICommandHandler<CreateModelF
private readonly IFileCreationService _fileCreationService;
private readonly IDateTimeProvider _dateTimeProvider;
private readonly IDomainEventDispatcher _domainEventDispatcher;
private readonly ISettingRepository _settingRepository;

public CreateModelFromBlendCommandHandler(
IModelRepository modelRepository,
IModelVersionRepository versionRepository,
IFileCreationService fileCreationService,
IDateTimeProvider dateTimeProvider,
IDomainEventDispatcher domainEventDispatcher)
IDomainEventDispatcher domainEventDispatcher,
ISettingRepository settingRepository)
{
_modelRepository = modelRepository;
_versionRepository = versionRepository;
_fileCreationService = fileCreationService;
_dateTimeProvider = dateTimeProvider;
_domainEventDispatcher = domainEventDispatcher;
_settingRepository = settingRepository;
}

public async Task<Result<CreateModelFromBlendResponse>> Handle(
Expand Down Expand Up @@ -60,6 +63,18 @@ public async Task<Result<CreateModelFromBlendResponse>> Handle(

// Create model
var modelName = command.ModelName;

// Resolve name collision based on DuplicateNamePolicy setting
var nameResult = await AssetNameService.ResolveNameAsync(
modelName, "Model",
_modelRepository.ExistsByNameAsync,
_modelRepository.GetNamesByPrefixAsync,
_settingRepository, cancellationToken);
if (nameResult.IsFailure)
return Result.Failure<CreateModelFromBlendResponse>(nameResult.Error);

modelName = nameResult.Value;

try
{
var model = Model.Create(modelName, _dateTimeProvider.UtcNow);
Expand Down
1 change: 1 addition & 0 deletions src/Application/Settings/GetSettingsQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public record GetSettingsQueryResponse(
int TextureProxySize,
string BlenderPath,
bool BlenderEnabled,
string DuplicateNamePolicy,
DateTime CreatedAt,
DateTime UpdatedAt
);
3 changes: 3 additions & 0 deletions src/Application/Settings/GetSettingsQueryHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public async Task<Result<GetSettingsQueryResponse>> Handle(GetSettingsQuery quer
var textureProxySizeSetting = await _settingRepository.GetByKeyAsync(SettingKeys.TextureProxySize, cancellationToken);
var blenderPathSetting = await _settingRepository.GetByKeyAsync(SettingKeys.BlenderPath, cancellationToken);
var blenderEnabledSetting = await _settingRepository.GetByKeyAsync(SettingKeys.BlenderEnabled, cancellationToken);
var modelDuplicateNamePolicySetting = await _settingRepository.GetByKeyAsync(SettingKeys.DuplicateNamePolicy, cancellationToken);
var response = new GetSettingsQueryResponse(
long.Parse(maxFileSizeBytesSetting.Value),
long.Parse(maxThumbnailSizeBytesSetting?.Value ?? "10485760"),
Expand All @@ -54,6 +55,7 @@ public async Task<Result<GetSettingsQueryResponse>> Handle(GetSettingsQuery quer
int.Parse(textureProxySizeSetting?.Value ?? "512"),
blenderPathSetting?.Value ?? "blender",
bool.Parse(blenderEnabledSetting?.Value ?? "false"),
modelDuplicateNamePolicySetting?.Value ?? "Reject",
maxFileSizeBytesSetting.CreatedAt,
maxFileSizeBytesSetting.UpdatedAt
);
Expand All @@ -76,6 +78,7 @@ public async Task<Result<GetSettingsQueryResponse>> Handle(GetSettingsQuery quer
settings.TextureProxySize,
(await _settingRepository.GetByKeyAsync(SettingKeys.BlenderPath, cancellationToken))?.Value ?? "blender",
bool.Parse((await _settingRepository.GetByKeyAsync(SettingKeys.BlenderEnabled, cancellationToken))?.Value ?? "false"),
(await _settingRepository.GetByKeyAsync(SettingKeys.DuplicateNamePolicy, cancellationToken))?.Value ?? "Reject",
settings.CreatedAt,
settings.UpdatedAt
);
Expand Down
1 change: 1 addition & 0 deletions src/Application/Settings/SettingKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ public static class SettingKeys
public const string BlenderPath = "BlenderPath";
public const string BlenderEnabled = "BlenderEnabled";
public const string BlenderInstallVersion = "BlenderInstallVersion";
public const string DuplicateNamePolicy = "DuplicateNamePolicy";
}
9 changes: 9 additions & 0 deletions src/Application/Settings/SettingValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public static Result ValidateSetting(string key, string value)
SettingKeys.TextureProxySize => ValidateTextureProxySize(value),
SettingKeys.BlenderPath => ValidateBlenderPath(value),
SettingKeys.BlenderEnabled => ValidateBlenderEnabled(value),
SettingKeys.DuplicateNamePolicy => ValidateDuplicateNamePolicy(value),
_ => Result.Success() // Unknown keys are allowed for extensibility
};
}
Expand Down Expand Up @@ -148,4 +149,12 @@ private static Result ValidateBlenderEnabled(string value)

return Result.Success();
}

private static Result ValidateDuplicateNamePolicy(string value)
{
if (value is not ("Reject" or "AutoRename"))
return Result.Failure(new Error("InvalidSetting", "DuplicateNamePolicy must be 'Reject' or 'AutoRename'."));

return Result.Success();
}
}
17 changes: 15 additions & 2 deletions src/Application/Sounds/CreateSoundWithFileCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Application.Abstractions.Repositories;
using Application.Abstractions.Files;
using Application.Abstractions.Services;
using Application.Models;
using Application.Services;
using Domain.Models;
using Domain.Services;
Expand All @@ -17,6 +18,7 @@ internal class CreateSoundWithFileCommandHandler : ICommandHandler<CreateSoundWi
private readonly ISoundCategoryRepository _soundCategoryRepository;
private readonly IBatchUploadRepository _batchUploadRepository;
private readonly IFileCreationService _fileCreationService;
private readonly ISettingRepository _settingRepository;
private readonly IDateTimeProvider _dateTimeProvider;
private readonly IThumbnailQueue _thumbnailQueue;
private readonly ILogger<CreateSoundWithFileCommandHandler> _logger;
Expand All @@ -26,6 +28,7 @@ public CreateSoundWithFileCommandHandler(
ISoundCategoryRepository soundCategoryRepository,
IBatchUploadRepository batchUploadRepository,
IFileCreationService fileCreationService,
ISettingRepository settingRepository,
IDateTimeProvider dateTimeProvider,
IThumbnailQueue thumbnailQueue,
ILogger<CreateSoundWithFileCommandHandler> logger)
Expand All @@ -34,6 +37,7 @@ public CreateSoundWithFileCommandHandler(
_soundCategoryRepository = soundCategoryRepository;
_batchUploadRepository = batchUploadRepository;
_fileCreationService = fileCreationService;
_settingRepository = settingRepository;
_dateTimeProvider = dateTimeProvider;
_thumbnailQueue = thumbnailQueue;
_logger = logger;
Expand Down Expand Up @@ -103,9 +107,18 @@ public async Task<Result<CreateSoundWithFileResponse>> Handle(CreateSoundWithFil
}
}

// 4. Create new sound
// 4. Resolve name collision based on DuplicateNamePolicy setting
var nameResult = await AssetNameService.ResolveNameAsync(
command.Name, "Sound",
_soundRepository.ExistsByNameAsync,
_soundRepository.GetNamesByPrefixAsync,
_settingRepository, cancellationToken);
if (nameResult.IsFailure)
return Result.Failure<CreateSoundWithFileResponse>(nameResult.Error);

// 5. Create new sound with resolved name
var sound = Sound.Create(
command.Name,
nameResult.Value,
file,
command.Duration,
command.Peaks,
Expand Down
Loading
Loading