< Summary

Information
Class: Chronicis.Api.Services.UserService
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/UserService.cs
Line coverage
100%
Covered lines: 14
Uncovered lines: 0
Coverable lines: 14
Total lines: 607
Line coverage: 100%
Branch coverage
100%
Covered branches: 6
Total branches: 6
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
RemapArticleIdsInText(...)100%66100%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/UserService.cs

#LineLine coverage
 1using Chronicis.Api.Data;
 2using Chronicis.Shared.DTOs;
 3using Chronicis.Shared.Enums;
 4using Chronicis.Shared.Models;
 5using Chronicis.Shared.Utilities;
 6using Microsoft.EntityFrameworkCore;
 7
 8namespace Chronicis.Api.Services;
 9
 10/// <summary>
 11/// Implementation of user management service
 12/// </summary>
 13public sealed class UserService : IUserService
 14{
 115    private static readonly Guid TutorialTemplateWorldId = new("bbcee097-e733-4c55-a72b-91fa2cfa0391");
 16
 17    private readonly ChronicisDbContext _context;
 18    private readonly IWorldService _worldService;
 19    private readonly ILogger<UserService> _logger;
 20
 21    public UserService(
 22        ChronicisDbContext context,
 23        IWorldService worldService,
 24        ILogger<UserService> logger)
 25    {
 1626        _context = context;
 1627        _worldService = worldService;
 1628        _logger = logger;
 1629    }
 30
 31    public async Task<User> GetOrCreateUserAsync(
 32        string auth0UserId,
 33        string email,
 34        string displayName,
 35        string? avatarUrl)
 36    {
 37        // Try to find existing user
 38        var user = await _context.Users
 39            .FirstOrDefaultAsync(u => u.Auth0UserId == auth0UserId);
 40
 41        if (user == null)
 42        {
 43            // Create new user
 44            user = new User
 45            {
 46                Id = Guid.NewGuid(),
 47                Auth0UserId = auth0UserId,
 48                Email = email,
 49                DisplayName = displayName,
 50                AvatarUrl = avatarUrl,
 51                CreatedAt = DateTime.UtcNow,
 52                LastLoginAt = DateTime.UtcNow,
 53                HasCompletedOnboarding = false
 54            };
 55
 56            _context.Users.Add(user);
 57            await _context.SaveChangesAsync();
 58
 59            _logger.LogTraceSanitized("Created new user {UserId} for Auth0 ID {Auth0UserId}", user.Id, auth0UserId);
 60
 61            try
 62            {
 63                if (!await TryCloneTutorialWorldFromTemplateAsync(user.Id))
 64                {
 65                    _logger.LogWarningSanitized(
 66                        "Tutorial template world {TemplateWorldId} was not found. Falling back to generated default tuto
 67                        TutorialTemplateWorldId,
 68                        user.Id);
 69
 70                    await CreateDefaultWorldAsync(user.Id, isTutorial: true);
 71                }
 72            }
 73            catch (Exception ex)
 74            {
 75                // Do not block login if tutorial provisioning fails.
 76                _logger.LogErrorSanitized(ex, "Failed to provision tutorial world for new user {UserId}", user.Id);
 77            }
 78        }
 79        else
 80        {
 81            // Update user info in case it changed (e.g., user changed their name/avatar in Auth0)
 82            bool needsUpdate = false;
 83
 84            if (user.Email != email)
 85            {
 86                user.Email = email;
 87                needsUpdate = true;
 88            }
 89
 90            if (user.DisplayName != displayName)
 91            {
 92                user.DisplayName = displayName;
 93                needsUpdate = true;
 94            }
 95
 96            if (user.AvatarUrl != avatarUrl)
 97            {
 98                user.AvatarUrl = avatarUrl;
 99                needsUpdate = true;
 100            }
 101
 102            // Always update last login
 103            user.LastLoginAt = DateTime.UtcNow;
 104            needsUpdate = true;
 105
 106            if (needsUpdate)
 107            {
 108                await _context.SaveChangesAsync();
 109            }
 110        }
 111
 112        return user;
 113    }
 114
 115    public async Task<User?> GetUserByIdAsync(Guid userId)
 116    {
 117        return await _context.Users.FindAsync(userId);
 118    }
 119
 120    public async Task UpdateLastLoginAsync(Guid userId)
 121    {
 122        var user = await _context.Users.FindAsync(userId);
 123        if (user != null)
 124        {
 125            user.LastLoginAt = DateTime.UtcNow;
 126            await _context.SaveChangesAsync();
 127        }
 128    }
 129
 130    public async Task<UserProfileDto?> GetUserProfileAsync(Guid userId)
 131    {
 132        var user = await _context.Users.FindAsync(userId);
 133        if (user == null)
 134        {
 135            return null;
 136        }
 137
 138        return new UserProfileDto
 139        {
 140            Id = user.Id,
 141            Email = user.Email,
 142            DisplayName = user.DisplayName,
 143            AvatarUrl = user.AvatarUrl,
 144            CreatedAt = user.CreatedAt,
 145            LastLoginAt = user.LastLoginAt,
 146            HasCompletedOnboarding = user.HasCompletedOnboarding
 147        };
 148    }
 149
 150    public async Task<bool> CompleteOnboardingAsync(Guid userId)
 151    {
 152        var user = await _context.Users.FindAsync(userId);
 153        if (user == null)
 154        {
 155            _logger.LogWarningSanitized("Attempted to complete onboarding for non-existent user {UserId}", userId);
 156            return false;
 157        }
 158
 159        if (!user.HasCompletedOnboarding)
 160        {
 161            user.HasCompletedOnboarding = true;
 162            await _context.SaveChangesAsync();
 163            _logger.LogTraceSanitized("User {UserId} completed onboarding", userId);
 164        }
 165
 166        return true;
 167    }
 168
 169    /// <summary>
 170    /// Creates a default world with root structure for a new user.
 171    /// Used as a fallback if the tutorial template world is unavailable.
 172    /// </summary>
 173    private async Task CreateDefaultWorldAsync(Guid userId, bool isTutorial = false)
 174    {
 175        _logger.LogTraceSanitized("Creating default world for user {UserId}", userId);
 176
 177        var createDto = new WorldCreateDto
 178        {
 179            Name = "My World",
 180            Description = "Your personal world for campaigns and adventures"
 181        };
 182
 183        var createdWorld = await _worldService.CreateWorldAsync(createDto, userId);
 184
 185        if (!isTutorial)
 186        {
 187            return;
 188        }
 189
 190        var world = await _context.Worlds.FindAsync(createdWorld.Id);
 191        if (world != null && !world.IsTutorial)
 192        {
 193            world.IsTutorial = true;
 194            await _context.SaveChangesAsync();
 195        }
 196    }
 197
 198    private async Task<bool> TryCloneTutorialWorldFromTemplateAsync(Guid userId)
 199    {
 200        var templateWorld = await _context.Worlds
 201            .AsNoTracking()
 202            .FirstOrDefaultAsync(w => w.Id == TutorialTemplateWorldId);
 203
 204        if (templateWorld == null)
 205        {
 206            return false;
 207        }
 208
 209        var templateCampaigns = await _context.Campaigns
 210            .AsNoTracking()
 211            .Where(c => c.WorldId == templateWorld.Id)
 212            .ToListAsync();
 213        var templateCampaignIds = templateCampaigns.Select(c => c.Id).ToList();
 214
 215        var templateArcs = await _context.Arcs
 216            .AsNoTracking()
 217            .Where(a => templateCampaignIds.Contains(a.CampaignId))
 218            .ToListAsync();
 219        var templateArcIds = templateArcs.Select(a => a.Id).ToList();
 220
 221        var templateSessions = await _context.Sessions
 222            .AsNoTracking()
 223            .Where(s => templateArcIds.Contains(s.ArcId))
 224            .ToListAsync();
 225        var templateSessionIds = templateSessions.Select(s => s.Id).ToList();
 226
 227        var templateQuests = await _context.Quests
 228            .AsNoTracking()
 229            .Where(q => templateArcIds.Contains(q.ArcId))
 230            .ToListAsync();
 231        var templateQuestIds = templateQuests.Select(q => q.Id).ToList();
 232
 233        var templateQuestUpdates = await _context.QuestUpdates
 234            .AsNoTracking()
 235            .Where(qu => templateQuestIds.Contains(qu.QuestId))
 236            .ToListAsync();
 237
 238        var templateSummaryTemplates = await _context.SummaryTemplates
 239            .AsNoTracking()
 240            .Where(st => st.WorldId == templateWorld.Id)
 241            .ToListAsync();
 242
 243        var templateArticles = await _context.Articles
 244            .AsNoTracking()
 245            .Where(a =>
 246                a.WorldId == templateWorld.Id ||
 247                (a.CampaignId.HasValue && templateCampaignIds.Contains(a.CampaignId.Value)) ||
 248                (a.ArcId.HasValue && templateArcIds.Contains(a.ArcId.Value)) ||
 249                (a.SessionId.HasValue && templateSessionIds.Contains(a.SessionId.Value)))
 250            .ToListAsync();
 251        var templateArticleIds = templateArticles.Select(a => a.Id).ToList();
 252
 253        var templateArticleAliases = await _context.ArticleAliases
 254            .AsNoTracking()
 255            .Where(aa => templateArticleIds.Contains(aa.ArticleId))
 256            .ToListAsync();
 257
 258        var templateArticleExternalLinks = await _context.ArticleExternalLinks
 259            .AsNoTracking()
 260            .Where(ael => templateArticleIds.Contains(ael.ArticleId))
 261            .ToListAsync();
 262
 263        var templateArticleLinks = await _context.ArticleLinks
 264            .AsNoTracking()
 265            .Where(al => templateArticleIds.Contains(al.SourceArticleId) && templateArticleIds.Contains(al.TargetArticle
 266            .ToListAsync();
 267
 268        var templateWorldLinks = await _context.WorldLinks
 269            .AsNoTracking()
 270            .Where(wl => wl.WorldId == templateWorld.Id)
 271            .ToListAsync();
 272
 273        var templateWorldResourceProviders = await _context.WorldResourceProviders
 274            .AsNoTracking()
 275            .Where(wrp => wrp.WorldId == templateWorld.Id)
 276            .ToListAsync();
 277
 278        var skippedDocumentCount = await _context.WorldDocuments
 279            .AsNoTracking()
 280            .CountAsync(d => d.WorldId == templateWorld.Id);
 281        if (skippedDocumentCount > 0)
 282        {
 283            _logger.LogWarningSanitized(
 284                "Tutorial template world {TemplateWorldId} contains {DocumentCount} world documents. Document rows are n
 285                TutorialTemplateWorldId,
 286                skippedDocumentCount);
 287        }
 288
 289        var now = DateTime.UtcNow;
 290        var newWorldId = Guid.NewGuid();
 291        var newWorldSlug = await GenerateUniqueWorldSlugAsync(templateWorld.Name, userId);
 292
 293        var summaryTemplateIdMap = templateSummaryTemplates.ToDictionary(st => st.Id, _ => Guid.NewGuid());
 294        var campaignIdMap = templateCampaigns.ToDictionary(c => c.Id, _ => Guid.NewGuid());
 295        var arcIdMap = templateArcs.ToDictionary(a => a.Id, _ => Guid.NewGuid());
 296        var sessionIdMap = templateSessions.ToDictionary(s => s.Id, _ => Guid.NewGuid());
 297        var questIdMap = templateQuests.ToDictionary(q => q.Id, _ => Guid.NewGuid());
 298        var articleIdMap = templateArticles.ToDictionary(a => a.Id, _ => Guid.NewGuid());
 299
 300        Guid? MapSummaryTemplate(Guid? templateSummaryTemplateId)
 301        {
 302            if (!templateSummaryTemplateId.HasValue)
 303            {
 304                return null;
 305            }
 306
 307            return summaryTemplateIdMap.TryGetValue(templateSummaryTemplateId.Value, out var mappedId)
 308                ? mappedId
 309                : templateSummaryTemplateId;
 310        }
 311
 312        Guid? MapOptionalGuid(Guid? templateId, IReadOnlyDictionary<Guid, Guid> idMap)
 313        {
 314            if (!templateId.HasValue)
 315            {
 316                return null;
 317            }
 318
 319            return idMap.TryGetValue(templateId.Value, out var mappedId)
 320                ? mappedId
 321                : null;
 322        }
 323
 324        var clonedWorld = new World
 325        {
 326            Id = newWorldId,
 327            Name = templateWorld.Name,
 328            Slug = newWorldSlug,
 329            Description = templateWorld.Description,
 330            OwnerId = userId,
 331            CreatedAt = now,
 332            IsTutorial = true,
 333            IsPublic = false,
 334            PublicSlug = null
 335        };
 336
 337        var clonedSummaryTemplates = templateSummaryTemplates.Select(st => new SummaryTemplate
 338        {
 339            Id = summaryTemplateIdMap[st.Id],
 340            WorldId = newWorldId,
 341            Name = st.Name,
 342            Description = st.Description,
 343            PromptTemplate = st.PromptTemplate,
 344            IsSystem = st.IsSystem,
 345            CreatedBy = st.CreatedBy.HasValue ? userId : null,
 346            CreatedAt = st.CreatedAt
 347        }).ToList();
 348
 349        var clonedCampaigns = templateCampaigns.Select(c => new Campaign
 350        {
 351            Id = campaignIdMap[c.Id],
 352            WorldId = newWorldId,
 353            Name = c.Name,
 354            Description = c.Description,
 355            OwnerId = userId,
 356            CreatedAt = c.CreatedAt,
 357            StartedAt = c.StartedAt,
 358            EndedAt = c.EndedAt,
 359            IsActive = c.IsActive,
 360            SummaryTemplateId = MapSummaryTemplate(c.SummaryTemplateId),
 361            SummaryCustomPrompt = c.SummaryCustomPrompt,
 362            SummaryIncludeWebSources = c.SummaryIncludeWebSources,
 363            AISummary = c.AISummary,
 364            AISummaryGeneratedAt = c.AISummaryGeneratedAt
 365        }).ToList();
 366
 367        var clonedArcs = templateArcs.Select(a => new Arc
 368        {
 369            Id = arcIdMap[a.Id],
 370            CampaignId = campaignIdMap[a.CampaignId],
 371            Name = a.Name,
 372            Description = a.Description,
 373            SortOrder = a.SortOrder,
 374            CreatedAt = a.CreatedAt,
 375            CreatedBy = userId,
 376            IsActive = a.IsActive,
 377            SummaryTemplateId = MapSummaryTemplate(a.SummaryTemplateId),
 378            SummaryCustomPrompt = a.SummaryCustomPrompt,
 379            SummaryIncludeWebSources = a.SummaryIncludeWebSources,
 380            AISummary = a.AISummary,
 381            AISummaryGeneratedAt = a.AISummaryGeneratedAt
 382        }).ToList();
 383
 384        var clonedSessions = templateSessions.Select(s => new Session
 385        {
 386            Id = sessionIdMap[s.Id],
 387            ArcId = arcIdMap[s.ArcId],
 388            Name = s.Name,
 389            SessionDate = s.SessionDate,
 390            PublicNotes = s.PublicNotes,
 391            PrivateNotes = s.PrivateNotes,
 392            AiSummary = s.AiSummary,
 393            AiSummaryGeneratedAt = s.AiSummaryGeneratedAt,
 394            AiSummaryGeneratedByUserId = s.AiSummaryGeneratedByUserId.HasValue ? userId : null,
 395            CreatedAt = s.CreatedAt,
 396            ModifiedAt = s.ModifiedAt,
 397            CreatedBy = userId
 398        }).ToList();
 399
 400        var clonedQuests = templateQuests.Select(q => new Quest
 401        {
 402            Id = questIdMap[q.Id],
 403            ArcId = arcIdMap[q.ArcId],
 404            Title = q.Title,
 405            Description = q.Description,
 406            Status = q.Status,
 407            IsGmOnly = q.IsGmOnly,
 408            SortOrder = q.SortOrder,
 409            CreatedBy = userId,
 410            CreatedAt = q.CreatedAt,
 411            UpdatedAt = q.UpdatedAt,
 412            RowVersion = q.RowVersion?.ToArray() ?? Array.Empty<byte>()
 413        }).ToList();
 414
 415        var clonedQuestUpdates = templateQuestUpdates.Select(qu => new QuestUpdate
 416        {
 417            Id = Guid.NewGuid(),
 418            QuestId = questIdMap[qu.QuestId],
 419            SessionId = MapOptionalGuid(qu.SessionId, sessionIdMap),
 420            Body = qu.Body,
 421            CreatedBy = userId,
 422            CreatedAt = qu.CreatedAt
 423        }).ToList();
 424
 425        var clonedArticles = templateArticles.Select(a => new Article
 426        {
 427            Id = articleIdMap[a.Id],
 428            ParentId = MapOptionalGuid(a.ParentId, articleIdMap),
 429            WorldId = a.WorldId.HasValue ? newWorldId : null,
 430            CampaignId = MapOptionalGuid(a.CampaignId, campaignIdMap),
 431            ArcId = MapOptionalGuid(a.ArcId, arcIdMap),
 432            Title = a.Title,
 433            Slug = a.Slug,
 434            Body = a.Body,
 435            IconEmoji = a.IconEmoji,
 436            Type = a.Type,
 437            Visibility = a.Visibility,
 438            CreatedBy = userId,
 439            LastModifiedBy = a.LastModifiedBy.HasValue ? userId : null,
 440            CreatedAt = a.CreatedAt,
 441            ModifiedAt = a.ModifiedAt,
 442            SessionDate = a.SessionDate,
 443            InGameDate = a.InGameDate,
 444            SessionId = MapOptionalGuid(a.SessionId, sessionIdMap),
 445            PlayerId = a.PlayerId.HasValue ? userId : null,
 446            SummaryTemplateId = MapSummaryTemplate(a.SummaryTemplateId),
 447            SummaryCustomPrompt = a.SummaryCustomPrompt,
 448            SummaryIncludeWebSources = a.SummaryIncludeWebSources,
 449            AISummary = a.AISummary,
 450            AISummaryGeneratedAt = a.AISummaryGeneratedAt,
 451            EffectiveDate = a.EffectiveDate
 452        }).ToList();
 453
 454        // Remap embedded wiki-link target GUIDs (and any other copied article GUID references) in rich text.
 455        foreach (var article in clonedArticles)
 456        {
 457            article.Body = RemapArticleIdsInText(article.Body, articleIdMap);
 458        }
 459
 460        foreach (var session in clonedSessions)
 461        {
 462            session.PublicNotes = RemapArticleIdsInText(session.PublicNotes, articleIdMap);
 463            session.PrivateNotes = RemapArticleIdsInText(session.PrivateNotes, articleIdMap);
 464        }
 465
 466        foreach (var quest in clonedQuests)
 467        {
 468            quest.Description = RemapArticleIdsInText(quest.Description, articleIdMap);
 469        }
 470
 471        foreach (var questUpdate in clonedQuestUpdates)
 472        {
 473            questUpdate.Body = RemapArticleIdsInText(questUpdate.Body, articleIdMap) ?? questUpdate.Body;
 474        }
 475
 476        var clonedArticleAliases = templateArticleAliases.Select(aa => new ArticleAlias
 477        {
 478            Id = Guid.NewGuid(),
 479            ArticleId = articleIdMap[aa.ArticleId],
 480            AliasText = aa.AliasText,
 481            AliasType = aa.AliasType,
 482            EffectiveDate = aa.EffectiveDate,
 483            CreatedAt = aa.CreatedAt
 484        }).ToList();
 485
 486        var clonedArticleExternalLinks = templateArticleExternalLinks.Select(ael => new ArticleExternalLink
 487        {
 488            Id = Guid.NewGuid(),
 489            ArticleId = articleIdMap[ael.ArticleId],
 490            Source = ael.Source,
 491            ExternalId = ael.ExternalId,
 492            DisplayTitle = ael.DisplayTitle
 493        }).ToList();
 494
 495        var clonedArticleLinks = templateArticleLinks.Select(al => new ArticleLink
 496        {
 497            Id = Guid.NewGuid(),
 498            SourceArticleId = articleIdMap[al.SourceArticleId],
 499            TargetArticleId = articleIdMap[al.TargetArticleId],
 500            DisplayText = al.DisplayText,
 501            Position = al.Position,
 502            CreatedAt = al.CreatedAt
 503        }).ToList();
 504
 505        var clonedWorldLinks = templateWorldLinks.Select(wl => new WorldLink
 506        {
 507            Id = Guid.NewGuid(),
 508            WorldId = newWorldId,
 509            Url = wl.Url,
 510            Title = wl.Title,
 511            Description = wl.Description,
 512            CreatedAt = wl.CreatedAt
 513        }).ToList();
 514
 515        var clonedWorldResourceProviders = templateWorldResourceProviders.Select(wrp => new WorldResourceProvider
 516        {
 517            WorldId = newWorldId,
 518            ResourceProviderCode = wrp.ResourceProviderCode,
 519            IsEnabled = wrp.IsEnabled,
 520            LookupKey = wrp.LookupKey,
 521            ModifiedAt = wrp.ModifiedAt,
 522            ModifiedByUserId = userId
 523        }).ToList();
 524
 525        var ownerMembership = new WorldMember
 526        {
 527            Id = Guid.NewGuid(),
 528            WorldId = newWorldId,
 529            UserId = userId,
 530            Role = WorldRole.GM,
 531            JoinedAt = now,
 532            InvitedBy = null
 533        };
 534
 535        _context.Worlds.Add(clonedWorld);
 536        if (clonedSummaryTemplates.Count > 0)
 537            _context.SummaryTemplates.AddRange(clonedSummaryTemplates);
 538        if (clonedCampaigns.Count > 0)
 539            _context.Campaigns.AddRange(clonedCampaigns);
 540        if (clonedArcs.Count > 0)
 541            _context.Arcs.AddRange(clonedArcs);
 542        if (clonedSessions.Count > 0)
 543            _context.Sessions.AddRange(clonedSessions);
 544        if (clonedQuests.Count > 0)
 545            _context.Quests.AddRange(clonedQuests);
 546        if (clonedQuestUpdates.Count > 0)
 547            _context.QuestUpdates.AddRange(clonedQuestUpdates);
 548        if (clonedArticles.Count > 0)
 549            _context.Articles.AddRange(clonedArticles);
 550        if (clonedArticleAliases.Count > 0)
 551            _context.ArticleAliases.AddRange(clonedArticleAliases);
 552        if (clonedArticleExternalLinks.Count > 0)
 553            _context.ArticleExternalLinks.AddRange(clonedArticleExternalLinks);
 554        if (clonedArticleLinks.Count > 0)
 555            _context.ArticleLinks.AddRange(clonedArticleLinks);
 556        if (clonedWorldLinks.Count > 0)
 557            _context.WorldLinks.AddRange(clonedWorldLinks);
 558        if (clonedWorldResourceProviders.Count > 0)
 559            _context.WorldResourceProviders.AddRange(clonedWorldResourceProviders);
 560        _context.WorldMembers.Add(ownerMembership);
 561
 562        await _context.SaveChangesAsync();
 563
 564        _logger.LogTraceSanitized(
 565            "Provisioned tutorial world {WorldId} from template {TemplateWorldId} for user {UserId} (Campaigns={Campaign
 566            newWorldId,
 567            TutorialTemplateWorldId,
 568            userId,
 569            clonedCampaigns.Count,
 570            clonedArcs.Count,
 571            clonedSessions.Count,
 572            clonedArticles.Count);
 573
 574        return true;
 575    }
 576
 577    private async Task<string> GenerateUniqueWorldSlugAsync(string worldName, Guid ownerId)
 578    {
 579        var baseSlug = SlugGenerator.GenerateSlug(worldName);
 580        var existingSlugs = await _context.Worlds
 581            .AsNoTracking()
 582            .Where(w => w.OwnerId == ownerId)
 583            .Select(w => w.Slug)
 584            .ToHashSetAsync();
 585
 586        return SlugGenerator.GenerateUniqueSlug(baseSlug, existingSlugs);
 587    }
 588
 589    private static string? RemapArticleIdsInText(string? text, IReadOnlyDictionary<Guid, Guid> articleIdMap)
 590    {
 9591        if (string.IsNullOrEmpty(text) || articleIdMap.Count == 0)
 592        {
 2593            return text;
 594        }
 595
 7596        var remapped = text;
 56597        foreach (var kvp in articleIdMap)
 598        {
 21599            remapped = remapped.Replace(
 21600                kvp.Key.ToString(),
 21601                kvp.Value.ToString(),
 21602                StringComparison.OrdinalIgnoreCase);
 603        }
 604
 7605        return remapped;
 606    }
 607}