using Microsoft.EntityFrameworkCore; using Nuuru.Server.Data; using Nuuru.Server.DTOs.Admin; using Nuuru.Server.Models.Booru; using Nuuru.Server.Services.Search; namespace Nuuru.Server.Services { public interface IBulkTagService { Task ApplyAllAliasesAsync(); Task ApplyAllImplicationsAsync(); Task RenameTagAsync(string currentName, string newName, List? postIds = null, string? searchQuery = null, List? excludedPostIds = null); Task DeleteTagAsync(string tagName, List? postIds = null, string? searchQuery = null, List? excludedPostIds = null); Task MergeTagsAsync(string sourceName, string targetName, List? postIds = null, string? searchQuery = null, List? excludedPostIds = null); Task AddTagAsync(string tagName, List? postIds = null, string? searchQuery = null, List? excludedPostIds = null); Task SetRatingAsync(string rating, List? postIds = null, string? searchQuery = null, List? excludedPostIds = null); Task MassUpdateTagsAsync(List postIds, List tagsToAdd, List tagsToRemove); } public class BulkTagService : IBulkTagService { private readonly ApplicationDbContext _context; private readonly ITagService _tagService; private readonly IBooruSearchService _searchService; private readonly ILogger _logger; private const int MaxChainDepth = 10; public BulkTagService( ApplicationDbContext context, ITagService tagService, IBooruSearchService searchService, ILogger logger) { _context = context; _tagService = tagService; _searchService = searchService; _logger = logger; } private async Task> GetTargetPostsAsync(List? postIds, string? searchQuery, List? excludedPostIds) { IQueryable query; if (postIds != null && postIds.Count > 0) { query = _context.BooruPosts.Where(p => postIds.Contains(p.Id)); } else if (!string.IsNullOrWhiteSpace(searchQuery)) { query = await _searchService.BuildQueryAsync(searchQuery); } else { // Global fallback query = _context.BooruPosts.AsQueryable(); } if (excludedPostIds != null && excludedPostIds.Count > 0) { query = query.Where(p => !excludedPostIds.Contains(p.Id)); } return query; } public async Task MassUpdateTagsAsync(List postIds, List tagsToAdd, List tagsToRemove) { if (postIds == null || postIds.Count == 0) return new BulkOperationResult { Success = false, Message = "No posts selected." }; var affectedTagIds = new HashSet(); var affectedPostIds = new HashSet(); int totalModifications = 0; try { // Pre-fetch tags to add/remove var tagsToAddEntities = new List(); foreach (var tagName in tagsToAdd) { var tag = await _tagService.GetOrCreateTagAsync(tagName); tagsToAddEntities.Add(tag); } var tagsToRemoveEntities = new List(); foreach (var tagName in tagsToRemove) { var tag = await _tagService.GetTagByFullNameAsync(tagName); if (tag != null) tagsToRemoveEntities.Add(tag); } // Process in chunks of 100 const int chunkSize = 100; for (int i = 0; i < postIds.Count; i += chunkSize) { var chunkPostIds = postIds.Skip(i).Take(chunkSize).ToList(); using var transaction = await _context.Database.BeginTransactionAsync(); try { var posts = await _context.BooruPosts .Include(p => p.PostTags) .Where(p => chunkPostIds.Contains(p.Id)) .ToListAsync(); foreach (var post in posts) { bool changed = false; // Remove tags foreach (var tagToRemove in tagsToRemoveEntities) { var pt = post.PostTags.FirstOrDefault(x => x.TagId == tagToRemove.Id); if (pt != null && !pt.IsLocked) { post.PostTags.Remove(pt); affectedTagIds.Add(tagToRemove.Id); changed = true; totalModifications++; } } // Add tags foreach (var tagToAdd in tagsToAddEntities) { if (!post.PostTags.Any(pt => pt.TagId == tagToAdd.Id)) { post.PostTags.Add(new PostTag { PostId = post.Id, TagId = tagToAdd.Id, AddedAt = DateTime.UtcNow, IsLocked = false }); affectedTagIds.Add(tagToAdd.Id); changed = true; totalModifications++; } } if (changed) { post.TagCount = post.PostTags.Count; affectedPostIds.Add(post.Id); } } await _context.SaveChangesAsync(); await transaction.CommitAsync(); } catch (Exception) { await transaction.RollbackAsync(); throw; } // Detach processed entities to keep memory low _context.ChangeTracker.Clear(); } // Recount - this can also be slow, but it's done once per affected tag foreach (var tagId in affectedTagIds) await _tagService.UpdatePostCountAsync(tagId); return new BulkOperationResult { Success = true, Message = $"Successfully updated {affectedPostIds.Count} posts with {totalModifications} changes.", AffectedPosts = affectedPostIds.Count, AffectedTags = affectedTagIds.Count, ModifiedRelations = totalModifications }; } catch (Exception ex) { _logger.LogError(ex, "Error in mass tag update for {Count} posts", postIds.Count); return new BulkOperationResult { Success = false, Message = $"Error in mass tag update: {ex.Message}" }; } } public async Task ApplyAllAliasesAsync() { try { var aliases = await _context.BooruTagAliases .Where(a => a.IsActive) .ToListAsync(); int totalMoved = 0; var affectedPostIds = new HashSet(); var affectedTagIds = new HashSet(); foreach (var alias in aliases) { var aliasPostTagIds = await _context.Set() .Where(pt => pt.TagId == alias.AliasTagId) .Select(pt => pt.PostId) .ToListAsync(); if (aliasPostTagIds.Count == 0) continue; // Process in chunks of 100 const int chunkSize = 100; for (int i = 0; i < aliasPostTagIds.Count; i += chunkSize) { var chunkPostIds = aliasPostTagIds.Skip(i).Take(chunkSize).ToList(); using var transaction = await _context.Database.BeginTransactionAsync(); try { var posts = await _context.BooruPosts .Include(p => p.PostTags) .Where(p => chunkPostIds.Contains(p.Id)) .ToListAsync(); foreach (var post in posts) { var pt = post.PostTags.FirstOrDefault(x => x.TagId == alias.AliasTagId); if (pt == null) continue; var hasTarget = post.PostTags.Any(x => x.TagId == alias.TargetTagId); if (!hasTarget) { post.PostTags.Add(new PostTag { PostId = post.Id, TagId = alias.TargetTagId, AddedAt = DateTime.UtcNow, IsLocked = pt.IsLocked }); totalMoved++; } post.PostTags.Remove(pt); affectedPostIds.Add(post.Id); } await _context.SaveChangesAsync(); await transaction.CommitAsync(); } catch (Exception) { await transaction.RollbackAsync(); throw; } _context.ChangeTracker.Clear(); } affectedTagIds.Add(alias.AliasTagId); affectedTagIds.Add(alias.TargetTagId); } await RecountTagPostCountAsync(affectedTagIds); await RecountPostTagCountAsync(affectedPostIds); return new BulkOperationResult { Success = true, Message = $"Applied {aliases.Count} aliases. Moved {totalMoved} post-tag assignments.", AffectedPosts = affectedPostIds.Count, AffectedTags = affectedTagIds.Count, ModifiedRelations = totalMoved }; } catch (Exception ex) { _logger.LogError(ex, "Error applying all aliases"); return new BulkOperationResult { Success = false, Message = $"Error: {ex.Message}" }; } } public async Task ApplyAllImplicationsAsync() { try { var allImplications = await _context.BooruTagImplications .Where(i => i.IsActive) .ToListAsync(); var graph = new Dictionary>(); foreach (var impl in allImplications) { if (!graph.ContainsKey(impl.AntecedentTagId)) graph[impl.AntecedentTagId] = new List(); graph[impl.AntecedentTagId].Add(impl.ConsequentTagId); } int totalAdded = 0; var affectedPostIds = new HashSet(); var affectedTagIds = new HashSet(); foreach (var antecedentId in graph.Keys) { var impliedTags = new HashSet(); var queue = new Queue(); var visited = new HashSet(); int depth = 0; queue.Enqueue(antecedentId); visited.Add(antecedentId); while (queue.Count > 0 && depth < MaxChainDepth) { var levelSize = queue.Count; for (int i = 0; i < levelSize; i++) { var current = queue.Dequeue(); if (graph.TryGetValue(current, out var consequents)) { foreach (var consequent in consequents) { impliedTags.Add(consequent); if (visited.Add(consequent)) queue.Enqueue(consequent); } } } depth++; } if (impliedTags.Count == 0) continue; var postsWithAntecedentIds = await _context.Set() .Where(pt => pt.TagId == antecedentId) .Select(pt => pt.PostId) .ToListAsync(); // Process in chunks const int chunkSize = 100; for (int i = 0; i < postsWithAntecedentIds.Count; i += chunkSize) { var chunkPostIds = postsWithAntecedentIds.Skip(i).Take(chunkSize).ToList(); using var transaction = await _context.Database.BeginTransactionAsync(); try { var posts = await _context.BooruPosts .Include(p => p.PostTags) .Where(p => chunkPostIds.Contains(p.Id)) .ToListAsync(); foreach (var post in posts) { var existingTagIds = post.PostTags.Select(pt => pt.TagId).ToHashSet(); foreach (var impliedTagId in impliedTags) { if (!existingTagIds.Contains(impliedTagId)) { post.PostTags.Add(new PostTag { PostId = post.Id, TagId = impliedTagId, AddedAt = DateTime.UtcNow }); totalAdded++; affectedPostIds.Add(post.Id); affectedTagIds.Add(impliedTagId); } } } await _context.SaveChangesAsync(); await transaction.CommitAsync(); } catch (Exception) { await transaction.RollbackAsync(); throw; } _context.ChangeTracker.Clear(); } affectedTagIds.Add(antecedentId); } await RecountTagPostCountAsync(affectedTagIds); await RecountPostTagCountAsync(affectedPostIds); return new BulkOperationResult { Success = true, Message = $"Applied implications. Added {totalAdded} missing assignments.", AffectedPosts = affectedPostIds.Count, AffectedTags = affectedTagIds.Count, ModifiedRelations = totalAdded }; } catch (Exception ex) { _logger.LogError(ex, "Error applying all implications"); return new BulkOperationResult { Success = false, Message = $"Error: {ex.Message}" }; } } public async Task RenameTagAsync(string currentName, string newName, List? postIds = null, string? searchQuery = null, List? excludedPostIds = null) { if ((postIds != null && postIds.Count > 0) || !string.IsNullOrWhiteSpace(searchQuery) || (excludedPostIds != null && excludedPostIds.Count > 0)) { // Selective "Rename" is actually a Replace on specific posts return await SelectiveReplaceTagAsync(currentName, newName, postIds, searchQuery, excludedPostIds); } using var transaction = await _context.Database.BeginTransactionAsync(); try { currentName = currentName.Trim().ToLowerInvariant(); newName = newName.Trim().ToLowerInvariant(); if (string.IsNullOrWhiteSpace(currentName) || string.IsNullOrWhiteSpace(newName)) return new BulkOperationResult { Success = false, Message = "Tag names cannot be empty." }; var tag = await _tagService.GetTagByFullNameAsync(currentName); if (tag == null) return new BulkOperationResult { Success = false, Message = $"Tag '{currentName}' not found." }; var parts = newName.Split(':'); string? categorySlug = parts.Length > 1 ? parts[0] : null; string namePart = parts.Length > 1 ? parts[1].Trim() : newName; TagCategory? newCategory = null; if (categorySlug != null) { newCategory = await _tagService.GetOrCreateTagCategoryAsync(categorySlug); } var collision = await _context.BooruTags.AnyAsync(t => t.Name == namePart && (newCategory == null ? t.Category == null : t.Category.Id == newCategory.Id) && t.Id != tag.Id); if (collision) return new BulkOperationResult { Success = false, Message = $"A tag with name '{newName}' already exists." }; tag.Name = namePart; tag.Category = newCategory; await _context.SaveChangesAsync(); await transaction.CommitAsync(); return new BulkOperationResult { Success = true, Message = $"Renamed tag '{currentName}' to '{newName}' globally.", AffectedTags = 1, AffectedPosts = tag.PostCount }; } catch (Exception ex) { await transaction.RollbackAsync(); return new BulkOperationResult { Success = false, Message = $"Error: {ex.Message}" }; } } public async Task DeleteTagAsync(string tagName, List? postIds = null, string? searchQuery = null, List? excludedPostIds = null) { if ((postIds != null && postIds.Count > 0) || !string.IsNullOrWhiteSpace(searchQuery) || (excludedPostIds != null && excludedPostIds.Count > 0)) { // Selective "Delete" is just removing the tag from specific posts return await SelectiveRemoveTagAsync(tagName, postIds, searchQuery, excludedPostIds); } using var transaction = await _context.Database.BeginTransactionAsync(); try { tagName = tagName.Trim().ToLowerInvariant(); var tag = await _tagService.GetTagByFullNameAsync(tagName); if (tag == null) return new BulkOperationResult { Success = false, Message = $"Tag '{tagName}' not found." }; var affectedPostIds = await _context.Set() .Where(pt => pt.TagId == tag.Id) .Select(pt => pt.PostId) .ToListAsync(); var postTags = await _context.Set().Where(pt => pt.TagId == tag.Id).ToListAsync(); _context.Set().RemoveRange(postTags); // Clean up relations _context.BooruTagAliases.RemoveRange(_context.BooruTagAliases.Where(a => a.AliasTagId == tag.Id || a.TargetTagId == tag.Id)); _context.BooruTagImplications.RemoveRange(_context.BooruTagImplications.Where(i => i.AntecedentTagId == tag.Id || i.ConsequentTagId == tag.Id)); _context.BooruTags.Remove(tag); await _context.SaveChangesAsync(); await RecountPostTagCountAsync(new HashSet(affectedPostIds)); await transaction.CommitAsync(); return new BulkOperationResult { Success = true, Message = $"Deleted tag '{tagName}' globally.", AffectedPosts = affectedPostIds.Count, AffectedTags = 1, ModifiedRelations = postTags.Count }; } catch (Exception ex) { await transaction.RollbackAsync(); return new BulkOperationResult { Success = false, Message = $"Error: {ex.Message}" }; } } public async Task MergeTagsAsync(string sourceName, string targetName, List? postIds = null, string? searchQuery = null, List? excludedPostIds = null) { if ((postIds != null && postIds.Count > 0) || !string.IsNullOrWhiteSpace(searchQuery) || (excludedPostIds != null && excludedPostIds.Count > 0)) { // Selective "Merge" is identical to selective "Rename" (replace A with B on posts) return await SelectiveReplaceTagAsync(sourceName, targetName, postIds, searchQuery, excludedPostIds); } using var transaction = await _context.Database.BeginTransactionAsync(); try { sourceName = sourceName.Trim().ToLowerInvariant(); targetName = targetName.Trim().ToLowerInvariant(); if (sourceName == targetName) return new BulkOperationResult { Success = false, Message = "Source and target tags cannot be the same." }; var sourceTag = await _tagService.GetTagByFullNameAsync(sourceName); var targetTag = await _tagService.GetTagByFullNameAsync(targetName); if (sourceTag == null) return new BulkOperationResult { Success = false, Message = $"Source tag '{sourceName}' not found." }; if (targetTag == null) return new BulkOperationResult { Success = false, Message = $"Target tag '{targetName}' not found." }; var affectedPostIds = new HashSet(); int movedCount = 0; // Load posts with their PostTags to avoid tracking conflicts var sourcePostTags = await _context.Set().Where(pt => pt.TagId == sourceTag.Id).ToListAsync(); var postIdsToFetch = sourcePostTags.Select(pt => pt.PostId).Distinct().ToList(); var posts = await _context.BooruPosts .Include(p => p.PostTags) .Where(p => postIdsToFetch.Contains(p.Id)) .ToListAsync(); var postsDict = posts.ToDictionary(p => p.Id); foreach (var pt in sourcePostTags) { if (!postsDict.TryGetValue(pt.PostId, out var post)) continue; var hasTarget = post.PostTags.Any(x => x.TagId == targetTag.Id); if (!hasTarget) { post.PostTags.Add(new PostTag { PostId = post.Id, TagId = targetTag.Id, AddedAt = DateTime.UtcNow, IsLocked = pt.IsLocked }); movedCount++; } var sourcePt = post.PostTags.FirstOrDefault(x => x.TagId == sourceTag.Id); if (sourcePt != null) { post.PostTags.Remove(sourcePt); } affectedPostIds.Add(pt.PostId); } // Redirect aliases/implications var aliases = await _context.BooruTagAliases.Where(a => a.TargetTagId == sourceTag.Id || a.AliasTagId == sourceTag.Id).ToListAsync(); foreach (var a in aliases) { if (a.TargetTagId == sourceTag.Id) a.TargetTagId = targetTag.Id; if (a.AliasTagId == sourceTag.Id) a.AliasTagId = targetTag.Id; } var implications = await _context.BooruTagImplications.Where(i => i.AntecedentTagId == sourceTag.Id || i.ConsequentTagId == sourceTag.Id).ToListAsync(); foreach (var i in implications) { if (i.AntecedentTagId == sourceTag.Id) i.AntecedentTagId = targetTag.Id; if (i.ConsequentTagId == sourceTag.Id) i.ConsequentTagId = targetTag.Id; } _context.BooruTags.Remove(sourceTag); await _context.SaveChangesAsync(); await RecountTagPostCountAsync(new HashSet { targetTag.Id }); await RecountPostTagCountAsync(affectedPostIds); await transaction.CommitAsync(); return new BulkOperationResult { Success = true, Message = $"Merged tag '{sourceName}' into '{targetName}' globally.", AffectedPosts = affectedPostIds.Count, AffectedTags = 2, ModifiedRelations = movedCount }; } catch (Exception ex) { await transaction.RollbackAsync(); return new BulkOperationResult { Success = false, Message = $"Error: {ex.Message}" }; } } public async Task AddTagAsync(string tagName, List? postIds = null, string? searchQuery = null, List? excludedPostIds = null) { try { tagName = tagName.Trim().ToLowerInvariant(); var tag = await _tagService.GetOrCreateTagAsync(tagName); IQueryable query = await GetTargetPostsAsync(postIds, searchQuery, excludedPostIds); var targetPostIds = await query.Select(p => p.Id).ToListAsync(); int addedCount = 0; var affectedPostIds = new HashSet(); // Process in chunks const int chunkSize = 100; for (int i = 0; i < targetPostIds.Count; i += chunkSize) { var chunkPostIds = targetPostIds.Skip(i).Take(chunkSize).ToList(); using var transaction = await _context.Database.BeginTransactionAsync(); try { var posts = await _context.BooruPosts .Include(p => p.PostTags) .Where(p => chunkPostIds.Contains(p.Id)) .ToListAsync(); foreach (var post in posts) { if (!post.PostTags.Any(pt => pt.TagId == tag.Id)) { post.PostTags.Add(new PostTag { PostId = post.Id, TagId = tag.Id, AddedAt = DateTime.UtcNow }); addedCount++; affectedPostIds.Add(post.Id); } } await _context.SaveChangesAsync(); await transaction.CommitAsync(); } catch (Exception) { await transaction.RollbackAsync(); throw; } _context.ChangeTracker.Clear(); } await _tagService.UpdatePostCountAsync(tag.Id); await RecountPostTagCountAsync(affectedPostIds); string scope = (postIds != null && postIds.Count > 0) ? "selection" : (!string.IsNullOrWhiteSpace(searchQuery) ? "search query" : "global"); return new BulkOperationResult { Success = true, Message = $"Added tag '{tagName}' to {affectedPostIds.Count} posts ({scope}).", AffectedPosts = affectedPostIds.Count, AffectedTags = 1, ModifiedRelations = addedCount }; } catch (Exception ex) { _logger.LogError(ex, "Error adding tag {Tag}", tagName); return new BulkOperationResult { Success = false, Message = $"Error adding tag: {ex.Message}" }; } } public async Task SetRatingAsync(string rating, List? postIds = null, string? searchQuery = null, List? excludedPostIds = null) { if (!Enum.TryParse(rating, true, out var postRating)) { return new BulkOperationResult { Success = false, Message = $"Invalid rating value: {rating}" }; } try { IQueryable query = await GetTargetPostsAsync(postIds, searchQuery, excludedPostIds); var targetPostIds = await query.Select(p => p.Id).ToListAsync(); int updatedCount = 0; // Process in chunks const int chunkSize = 100; for (int i = 0; i < targetPostIds.Count; i += chunkSize) { var chunkPostIds = targetPostIds.Skip(i).Take(chunkSize).ToList(); using var transaction = await _context.Database.BeginTransactionAsync(); try { var posts = await _context.BooruPosts .Where(p => chunkPostIds.Contains(p.Id)) .ToListAsync(); foreach (var post in posts) { if (post.Rating != postRating) { post.Rating = postRating; updatedCount++; } } await _context.SaveChangesAsync(); await transaction.CommitAsync(); } catch (Exception) { await transaction.RollbackAsync(); throw; } _context.ChangeTracker.Clear(); } string scope = (postIds != null && postIds.Count > 0) ? "selection" : (!string.IsNullOrWhiteSpace(searchQuery) ? "search query" : "global"); return new BulkOperationResult { Success = true, Message = $"Set rating to '{postRating}' for {updatedCount} posts ({scope}).", AffectedPosts = updatedCount }; } catch (Exception ex) { _logger.LogError(ex, "Error setting rating to {Rating}", rating); return new BulkOperationResult { Success = false, Message = $"Error setting rating: {ex.Message}" }; } } #region Selective Operations private async Task SelectiveReplaceTagAsync(string oldName, string newName, List? postIds, string? searchQuery, List? excludedPostIds) { try { oldName = oldName.Trim().ToLowerInvariant(); newName = newName.Trim().ToLowerInvariant(); var oldTag = await _tagService.GetTagByFullNameAsync(oldName); if (oldTag == null) return new BulkOperationResult { Success = false, Message = $"Tag '{oldName}' not found." }; var newTag = await _tagService.GetOrCreateTagAsync(newName); IQueryable query = await GetTargetPostsAsync(postIds, searchQuery, excludedPostIds); var targetPostIds = await query .Where(p => p.PostTags.Any(pt => pt.TagId == oldTag.Id)) .Select(p => p.Id) .ToListAsync(); if (targetPostIds.Count == 0) return new BulkOperationResult { Success = false, Message = $"Tag '{oldName}' not found on any targeted posts." }; int movedCount = 0; var affectedPostIds = new HashSet(); // Process in chunks const int chunkSize = 100; for (int i = 0; i < targetPostIds.Count; i += chunkSize) { var chunkPostIds = targetPostIds.Skip(i).Take(chunkSize).ToList(); using var transaction = await _context.Database.BeginTransactionAsync(); try { var posts = await _context.BooruPosts .Include(p => p.PostTags) .Where(p => chunkPostIds.Contains(p.Id)) .ToListAsync(); foreach (var post in posts) { var oldPt = post.PostTags.FirstOrDefault(pt => pt.TagId == oldTag.Id); if (oldPt == null || oldPt.IsLocked) continue; var hasNew = post.PostTags.Any(pt => pt.TagId == newTag.Id); if (!hasNew) { post.PostTags.Add(new PostTag { PostId = post.Id, TagId = newTag.Id, AddedAt = DateTime.UtcNow }); movedCount++; } post.PostTags.Remove(oldPt); affectedPostIds.Add(post.Id); } await _context.SaveChangesAsync(); await transaction.CommitAsync(); } catch (Exception) { await transaction.RollbackAsync(); throw; } _context.ChangeTracker.Clear(); } await _tagService.UpdatePostCountAsync(oldTag.Id); await _tagService.UpdatePostCountAsync(newTag.Id); await RecountPostTagCountAsync(affectedPostIds); string scope = (postIds != null && postIds.Count > 0) ? "selection" : (!string.IsNullOrWhiteSpace(searchQuery) ? "search query" : "global"); return new BulkOperationResult { Success = true, Message = $"Replaced '{oldName}' with '{newName}' on {affectedPostIds.Count} posts ({scope}).", AffectedPosts = affectedPostIds.Count, AffectedTags = 2, ModifiedRelations = movedCount }; } catch (Exception ex) { _logger.LogError(ex, "Error selective replacing {Old} with {New}", oldName, newName); return new BulkOperationResult { Success = false, Message = $"Error: {ex.Message}" }; } } private async Task SelectiveRemoveTagAsync(string tagName, List? postIds, string? searchQuery, List? excludedPostIds) { try { tagName = tagName.Trim().ToLowerInvariant(); var tag = await _tagService.GetTagByFullNameAsync(tagName); if (tag == null) return new BulkOperationResult { Success = false, Message = $"Tag '{tagName}' not found." }; IQueryable query = await GetTargetPostsAsync(postIds, searchQuery, excludedPostIds); var targetPostIds = await query .Where(p => p.PostTags.Any(pt => pt.TagId == tag.Id)) .Select(p => p.Id) .ToListAsync(); if (targetPostIds.Count == 0) return new BulkOperationResult { Success = false, Message = $"Tag '{tagName}' not found on any targeted posts." }; int removedCount = 0; var affectedPostIds = new HashSet(); // Process in chunks const int chunkSize = 100; for (int i = 0; i < targetPostIds.Count; i += chunkSize) { var chunkPostIds = targetPostIds.Skip(i).Take(chunkSize).ToList(); using var transaction = await _context.Database.BeginTransactionAsync(); try { var posts = await _context.BooruPosts .Include(p => p.PostTags) .Where(p => chunkPostIds.Contains(p.Id)) .ToListAsync(); foreach (var post in posts) { var pt = post.PostTags.FirstOrDefault(x => x.TagId == tag.Id); if (pt == null || pt.IsLocked) continue; post.PostTags.Remove(pt); removedCount++; affectedPostIds.Add(post.Id); } await _context.SaveChangesAsync(); await transaction.CommitAsync(); } catch (Exception) { await transaction.RollbackAsync(); throw; } _context.ChangeTracker.Clear(); } await _tagService.UpdatePostCountAsync(tag.Id); await RecountPostTagCountAsync(affectedPostIds); string scope = (postIds != null && postIds.Count > 100) ? "selection" : (!string.IsNullOrWhiteSpace(searchQuery) ? "search query" : "global"); return new BulkOperationResult { Success = true, Message = $"Removed '{tagName}' from {affectedPostIds.Count} posts ({scope}).", AffectedPosts = affectedPostIds.Count, AffectedTags = 1, ModifiedRelations = removedCount }; } catch (Exception ex) { _logger.LogError(ex, "Error selective removing {Tag}", tagName); return new BulkOperationResult { Success = false, Message = $"Error: {ex.Message}" }; } } #endregion #region Helpers private async Task RecountTagPostCountAsync(IEnumerable tagIds) { foreach (var id in tagIds) await _tagService.UpdatePostCountAsync(id); } private async Task RecountPostTagCountAsync(IEnumerable postIds) { var ids = postIds.ToList(); if (ids.Count == 0) return; var posts = await _context.BooruPosts.Where(p => ids.Contains(p.Id)).ToListAsync(); foreach (var post in posts) post.TagCount = await _context.Set().CountAsync(pt => pt.PostId == post.Id); await _context.SaveChangesAsync(); } private async Task CleanupOrphanTagsAsync() { var aliasIds = await _context.BooruTagAliases.Select(a => a.AliasTagId).Union(_context.BooruTagAliases.Select(a => a.TargetTagId)).ToListAsync(); var implicationIds = await _context.BooruTagImplications.Select(i => i.AntecedentTagId).Union(_context.BooruTagImplications.Select(i => i.ConsequentTagId)).ToListAsync(); var referencedIds = new HashSet(aliasIds.Concat(implicationIds)); var orphans = await _context.BooruTags.Where(t => t.PostCount == 0 && !referencedIds.Contains(t.Id)).ToListAsync(); if (orphans.Count > 0) { _context.BooruTags.RemoveRange(orphans); await _context.SaveChangesAsync(); } } #endregion } }