New Scanner + People Pages (#3286)

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joe Milazzo 2024-10-23 15:11:18 -07:00 committed by GitHub
parent 1ed0eae22d
commit ba20ad4ecc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
142 changed files with 17529 additions and 3038 deletions

View file

@ -60,6 +60,7 @@ public interface ICollectionTagRepository
Task<IEnumerable<AppUserCollection>> GetCollectionsByIds(IEnumerable<int> tags, CollectionIncludes includes = CollectionIncludes.None);
Task<IList<AppUserCollection>> GetAllCollectionsForSyncing(DateTime expirationTime);
}
public class CollectionTagRepository : ICollectionTagRepository
{
private readonly DataContext _context;
@ -195,8 +196,10 @@ public class CollectionTagRepository : ICollectionTagRepository
.Where(t => t.Id == tag.Id)
.SelectMany(uc => uc.Items.Select(s => s.Metadata))
.Select(sm => sm.AgeRating)
.MaxAsync();
tag.AgeRating = maxAgeRating;
.ToListAsync();
tag.AgeRating = maxAgeRating.Count != 0 ? maxAgeRating.Max() : AgeRating.Unknown;
await _context.SaveChangesAsync();
}
@ -219,7 +222,6 @@ public class CollectionTagRepository : ICollectionTagRepository
.ToListAsync();
}
public async Task<AppUserCollection?> GetCollectionAsync(int tagId, CollectionIncludes includes = CollectionIncludes.None)
{
return await _context.AppUserCollection

View file

@ -199,6 +199,7 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor
.Where(r => EF.Functions.Like(r.Name, series.Name) ||
EF.Functions.Like(r.Name, series.LocalizedName))
.ToListAsync();
foreach (var rec in recMatches)
{
rec.SeriesId = series.Id;

View file

@ -6,6 +6,7 @@ using API.DTOs.Metadata;
using API.Entities;
using API.Extensions;
using API.Extensions.QueryExtensions;
using API.Services.Tasks.Scanner.Parser;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
@ -24,6 +25,7 @@ public interface IGenreRepository
Task<int> GetCountAsync();
Task<GenreTagDto> GetRandomGenre();
Task<GenreTagDto> GetGenreById(int id);
Task<List<string>> GetAllGenresNotInListAsync(ICollection<string> genreNames);
}
public class GenreRepository : IGenreRepository
@ -133,4 +135,33 @@ public class GenreRepository : IGenreRepository
.ProjectTo<GenreTagDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
/// <summary>
/// Gets all genres that are not already present in the system.
/// Normalizes genres for lookup, but returns non-normalized names for creation.
/// </summary>
/// <param name="genreNames">The list of genre names (non-normalized).</param>
/// <returns>A list of genre names that do not exist in the system.</returns>
public async Task<List<string>> GetAllGenresNotInListAsync(ICollection<string> genreNames)
{
// Group the genres by their normalized names, keeping track of the original names
var normalizedToOriginalMap = genreNames
.Distinct()
.GroupBy(Parser.Normalize)
.ToDictionary(group => group.Key, group => group.First()); // Take the first original name for each normalized name
var normalizedGenreNames = normalizedToOriginalMap.Keys.ToList();
// Query the database for existing genres using the normalized names
var existingGenres = await _context.Genre
.Where(g => normalizedGenreNames.Contains(g.NormalizedTitle)) // Assuming you have a normalized field
.Select(g => g.NormalizedTitle)
.ToListAsync();
// Find the normalized genres that do not exist in the database
var missingGenres = normalizedGenreNames.Except(existingGenres).ToList();
// Return the original non-normalized genres for the missing ones
return missingGenres.Select(normalizedName => normalizedToOriginalMap[normalizedName]).ToList();
}
}

View file

@ -1,4 +1,6 @@
using System.Collections.Generic;
using System.Collections;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs;
@ -6,6 +8,7 @@ using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Extensions.QueryExtensions;
using API.Helpers;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
@ -15,7 +18,12 @@ namespace API.Data.Repositories;
public interface IPersonRepository
{
void Attach(Person person);
void Attach(IEnumerable<Person> person);
void Remove(Person person);
void Remove(ChapterPeople person);
void Remove(SeriesMetadataPeople person);
void Update(Person person);
Task<IList<Person>> GetAllPeople();
Task<IList<PersonDto>> GetAllPersonDtosAsync(int userId);
Task<IList<PersonDto>> GetAllPersonDtosByRoleAsync(int userId, PersonRole role);
@ -23,7 +31,17 @@ public interface IPersonRepository
Task<IList<PersonDto>> GetAllPeopleDtosForLibrariesAsync(int userId, List<int>? libraryIds = null);
Task<int> GetCountAsync();
Task<IList<Person>> GetAllPeopleByRoleAndNames(PersonRole role, IEnumerable<string> normalizeNames);
Task<string> GetCoverImageAsync(int personId);
Task<string?> GetCoverImageByNameAsync(string name);
Task<IEnumerable<PersonRole>> GetRolesForPersonByName(string name, int userId);
Task<PagedList<BrowsePersonDto>> GetAllWritersAndSeriesCount(int userId, UserParams userParams);
Task<Person?> GetPersonById(int personId);
Task<PersonDto?> GetPersonDtoByName(string name, int userId);
Task<Person> GetPersonByName(string name);
Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId);
Task<IEnumerable<StandaloneChapterDto>> GetChaptersForPersonByRole(int personId, int userId, PersonRole role);
Task<IList<Person>> GetPeopleByNames(List<string> normalizedNames);
}
public class PersonRepository : IPersonRepository
@ -42,17 +60,37 @@ public class PersonRepository : IPersonRepository
_context.Person.Attach(person);
}
public void Attach(IEnumerable<Person> person)
{
_context.Person.AttachRange(person);
}
public void Remove(Person person)
{
_context.Person.Remove(person);
}
public void Remove(ChapterPeople person)
{
_context.ChapterPeople.Remove(person);
}
public void Remove(SeriesMetadataPeople person)
{
_context.SeriesMetadataPeople.Remove(person);
}
public void Update(Person person)
{
_context.Person.Update(person);
}
public async Task RemoveAllPeopleNoLongerAssociated()
{
var peopleWithNoConnections = await _context.Person
.Include(p => p.SeriesMetadatas)
.Include(p => p.ChapterMetadatas)
.Where(p => p.SeriesMetadatas.Count == 0 && p.ChapterMetadatas.Count == 0)
.Include(p => p.SeriesMetadataPeople)
.Include(p => p.ChapterPeople)
.Where(p => p.SeriesMetadataPeople.Count == 0 && p.ChapterPeople.Count == 0)
.AsSplitQuery()
.ToListAsync();
@ -61,6 +99,7 @@ public class PersonRepository : IPersonRepository
await _context.SaveChangesAsync();
}
public async Task<IList<PersonDto>> GetAllPeopleDtosForLibrariesAsync(int userId, List<int>? libraryIds = null)
{
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
@ -74,7 +113,7 @@ public class PersonRepository : IPersonRepository
return await _context.Series
.Where(s => userLibs.Contains(s.LibraryId))
.RestrictAgainstAgeRestriction(ageRating)
.SelectMany(s => s.Metadata.People)
.SelectMany(s => s.Metadata.People.Select(p => p.Person))
.Distinct()
.OrderBy(p => p.Name)
.AsNoTracking()
@ -88,13 +127,124 @@ public class PersonRepository : IPersonRepository
return await _context.Person.CountAsync();
}
public async Task<IList<Person>> GetAllPeopleByRoleAndNames(PersonRole role, IEnumerable<string> normalizeNames)
public async Task<string> GetCoverImageAsync(int personId)
{
return await _context.Person
.Where(p => p.Role == role && normalizeNames.Contains(p.NormalizedName))
.Where(c => c.Id == personId)
.Select(c => c.CoverImage)
.SingleOrDefaultAsync();
}
public async Task<string> GetCoverImageByNameAsync(string name)
{
var normalized = name.ToNormalized();
return await _context.Person
.Where(c => c.NormalizedName == normalized)
.Select(c => c.CoverImage)
.SingleOrDefaultAsync();
}
public async Task<IEnumerable<PersonRole>> GetRolesForPersonByName(string name, int userId)
{
// TODO: This will need to check both series and chapters (in cases where komf only updates series)
var normalized = name.ToNormalized();
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
return await _context.Person
.Where(p => p.NormalizedName == normalized)
.RestrictAgainstAgeRestriction(ageRating)
.SelectMany(p => p.ChapterPeople.Select(cp => cp.Role))
.Distinct()
.ToListAsync();
}
public async Task<PagedList<BrowsePersonDto>> GetAllWritersAndSeriesCount(int userId, UserParams userParams)
{
List<PersonRole> roles = [PersonRole.Writer, PersonRole.CoverArtist];
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
var query = _context.Person
.Where(p => p.SeriesMetadataPeople.Any(smp => roles.Contains(smp.Role)) || p.ChapterPeople.Any(cmp => roles.Contains(cmp.Role)))
.RestrictAgainstAgeRestriction(ageRating)
.Select(p => new BrowsePersonDto
{
Id = p.Id,
Name = p.Name,
Description = p.Description,
SeriesCount = p.SeriesMetadataPeople
.Where(smp => roles.Contains(smp.Role))
.Select(smp => smp.SeriesMetadata.SeriesId)
.Distinct()
.Count(),
IssueCount = p.ChapterPeople
.Where(cp => roles.Contains(cp.Role))
.Select(cp => cp.Chapter.Id)
.Distinct()
.Count()
})
.OrderBy(p => p.Name);
return await PagedList<BrowsePersonDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
}
public async Task<Person?> GetPersonById(int personId)
{
return await _context.Person.Where(p => p.Id == personId)
.FirstOrDefaultAsync();
}
public async Task<PersonDto> GetPersonDtoByName(string name, int userId)
{
var normalized = name.ToNormalized();
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
return await _context.Person
.Where(p => p.NormalizedName == normalized)
.RestrictAgainstAgeRestriction(ageRating)
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync();
}
public async Task<Person> GetPersonByName(string name)
{
return await _context.Person.FirstOrDefaultAsync(p => p.NormalizedName == name.ToNormalized());
}
public async Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId)
{
return await _context.Person
.Where(p => p.Id == personId)
.SelectMany(p => p.SeriesMetadataPeople)
.Select(smp => smp.SeriesMetadata)
.Select(sm => sm.Series)
.Distinct()
.OrderByDescending(s => s.ExternalSeriesMetadata.AverageExternalRating)
.Take(20)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<IEnumerable<StandaloneChapterDto>> GetChaptersForPersonByRole(int personId, int userId, PersonRole role)
{
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
return await _context.ChapterPeople
.Where(cp => cp.PersonId == personId && cp.Role == role)
.Select(cp => cp.Chapter)
.RestrictAgainstAgeRestriction(ageRating)
.OrderBy(ch => ch.SortOrder)
.Take(20)
.ProjectTo<StandaloneChapterDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<IList<Person>> GetPeopleByNames(List<string> normalizedNames)
{
return await _context.Person
.Where(p => normalizedNames.Contains(p.NormalizedName))
.OrderBy(p => p.Name)
.ToListAsync();
}
public async Task<IList<Person>> GetAllPeople()
{
@ -106,7 +256,7 @@ public class PersonRepository : IPersonRepository
public async Task<IList<PersonDto>> GetAllPersonDtosAsync(int userId)
{
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();
return await _context.Person
.OrderBy(p => p.Name)
.RestrictAgainstAgeRestriction(ageRating)
@ -117,8 +267,9 @@ public class PersonRepository : IPersonRepository
public async Task<IList<PersonDto>> GetAllPersonDtosByRoleAsync(int userId, PersonRole role)
{
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
return await _context.Person
.Where(p => p.Role == role)
.Where(p => p.SeriesMetadataPeople.Any(smp => smp.Role == role) || p.ChapterPeople.Any(cp => cp.Role == role)) // Filter by role in both series and chapters
.OrderBy(p => p.Name)
.RestrictAgainstAgeRestriction(ageRating)
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)

View file

@ -122,8 +122,10 @@ public class ReadingListRepository : IReadingListRepository
{
return _context.ReadingListItem
.Where(item => item.ReadingListId == readingListId)
.SelectMany(item => item.Chapter.People.Where(p => p.Role == PersonRole.Character))
.OrderBy(p => p.NormalizedName)
.SelectMany(item => item.Chapter.People)
.Where(p => p.Role == PersonRole.Character)
.OrderBy(p => p.Person.NormalizedName)
.Select(p => p.Person)
.Distinct()
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
.AsEnumerable();

View file

@ -44,6 +44,9 @@ public enum SeriesIncludes
{
None = 1,
Volumes = 2,
/// <summary>
/// This will include all necessary includes
/// </summary>
Metadata = 4,
Related = 8,
Library = 16,
@ -51,8 +54,7 @@ public enum SeriesIncludes
ExternalReviews = 64,
ExternalRatings = 128,
ExternalRecommendations = 256,
ExternalMetadata = 512
ExternalMetadata = 512,
}
/// <summary>
@ -138,7 +140,7 @@ public interface ISeriesRepository
Task<IList<Series>> GetWantToReadForUserAsync(int userId);
Task<bool> IsSeriesInWantToRead(int userId, int seriesId);
Task<Series?> GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None);
Task<Series?> GetSeriesThatContainsLowestFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None);
Task<Series?> GetSeriesThatContainsLowestFolderPath(string path, SeriesIncludes includes = SeriesIncludes.None);
Task<IEnumerable<Series>> GetAllSeriesByNameAsync(IList<string> normalizedNames,
int userId, SeriesIncludes includes = SeriesIncludes.None);
Task<Series?> GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true);
@ -363,11 +365,11 @@ public class SeriesRepository : ISeriesRepository
var searchQueryNormalized = searchQuery.ToNormalized();
var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
var seriesIds = _context.Series
var seriesIds = await _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.RestrictAgainstAgeRestriction(userRating)
.Select(s => s.Id)
.ToList();
.ToListAsync();
result.Libraries = await _context.Library
.Search(searchQuery, userId, libraryIds)
@ -440,6 +442,7 @@ public class SeriesRepository : ISeriesRepository
.SearchPeople(searchQuery, seriesIds)
.Take(maxRecords)
.OrderBy(t => t.NormalizedName)
.Distinct()
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
.ToListAsync();
@ -532,14 +535,6 @@ public class SeriesRepository : ISeriesRepository
.SingleOrDefaultAsync();
}
public async Task<Series?> GetSeriesByIdForUserAsync(int seriesId, int userId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata)
{
return await _context.Series
.Where(s => s.Id == seriesId)
.Includes(includes)
.SingleOrDefaultAsync();
}
/// <summary>
/// Returns Full Series including all external links
/// </summary>
@ -661,6 +656,7 @@ public class SeriesRepository : ISeriesRepository
.Include(m => m.Genres.OrderBy(g => g.NormalizedTitle))
.Include(m => m.Tags.OrderBy(g => g.NormalizedTitle))
.Include(m => m.People)
.ThenInclude(p => p.Person)
.AsNoTracking()
.ProjectTo<SeriesMetadataDto>(_mapper.ConfigurationProvider)
.AsSplitQuery()
@ -1273,7 +1269,7 @@ public class SeriesRepository : ISeriesRepository
var query = sQuery
.WhereIf(hasGenresFilter, s => s.Metadata.Genres.Any(g => filter.Genres.Contains(g.Id)))
.WhereIf(hasPeopleFilter, s => s.Metadata.People.Any(p => allPeopleIds.Contains(p.Id)))
.WhereIf(hasPeopleFilter, s => s.Metadata.People.Any(p => allPeopleIds.Contains(p.PersonId)))
.WhereIf(hasCollectionTagFilter,
s => s.Metadata.CollectionTags.Any(t => filter.CollectionTags.Contains(t.Id)))
.WhereIf(hasRatingFilter, s => s.Ratings.Any(r => r.Rating >= filter.Rating && r.AppUserId == userId))
@ -1302,6 +1298,7 @@ public class SeriesRepository : ISeriesRepository
.Include(m => m.Genres.OrderBy(g => g.NormalizedTitle))
.Include(m => m.Tags.OrderBy(g => g.NormalizedTitle))
.Include(m => m.People)
.ThenInclude(p => p.Person)
.AsNoTracking()
.ProjectTo<SeriesMetadataDto>(_mapper.ConfigurationProvider)
.AsSplitQuery()
@ -1606,9 +1603,24 @@ public class SeriesRepository : ISeriesRepository
.SingleOrDefaultAsync();
}
public async Task<Series?> GetSeriesThatContainsLowestFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None)
public async Task<Series?> GetSeriesThatContainsLowestFolderPath(string path, SeriesIncludes includes = SeriesIncludes.None)
{
var normalized = Services.Tasks.Scanner.Parser.Parser.NormalizePath(folder);
// Check if the path ends with a file (has a file extension)
string directoryPath;
if (Path.HasExtension(path))
{
// Remove the file part and get the directory path
directoryPath = Path.GetDirectoryName(path);
if (string.IsNullOrEmpty(directoryPath)) return null;
}
else
{
// Use the path as is if it doesn't end with a file
directoryPath = path;
}
// Normalize the directory path
var normalized = Services.Tasks.Scanner.Parser.Parser.NormalizePath(directoryPath);
if (string.IsNullOrEmpty(normalized)) return null;
normalized = normalized.TrimEnd('/');
@ -1672,6 +1684,7 @@ public class SeriesRepository : ISeriesRepository
.Include(s => s.Metadata)
.ThenInclude(m => m.People)
.ThenInclude(p => p.Person)
.Include(s => s.Metadata)
.ThenInclude(m => m.Genres)
@ -1682,6 +1695,7 @@ public class SeriesRepository : ISeriesRepository
.Include(s => s.Volumes)
.ThenInclude(v => v.Chapters)
.ThenInclude(cm => cm.People)
.ThenInclude(p => p.Person)
.Include(s => s.Volumes)
.ThenInclude(v => v.Chapters)
@ -1697,6 +1711,7 @@ public class SeriesRepository : ISeriesRepository
.AsSplitQuery();
return query.SingleOrDefaultAsync();
#nullable enable
}
@ -1705,6 +1720,7 @@ public class SeriesRepository : ISeriesRepository
var libraryIds = GetLibraryIdsForUser(userId);
var normalizedSeries = seriesName.ToNormalized();
var normalizedLocalized = localizedName.ToNormalized();
return await _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.Where(s => formats.Contains(s.Format))
@ -1749,45 +1765,36 @@ public class SeriesRepository : ISeriesRepository
/// <param name="libraryId"></param>
public async Task<IList<Series>> RemoveSeriesNotInList(IList<ParsedSeries> seenSeries, int libraryId)
{
if (seenSeries.Count == 0) return Array.Empty<Series>();
if (!seenSeries.Any()) return Array.Empty<Series>();
// Get all series from DB in one go, based on libraryId
var dbSeries = await _context.Series
.Where(s => s.LibraryId == libraryId)
.ToListAsync();
// Get a set of matching series ids for the given parsedSeries
var ids = new HashSet<int>();
var ids = new List<int>();
foreach (var parsedSeries in seenSeries)
{
try
var matchingSeries = dbSeries
.Where(s => s.Format == parsedSeries.Format && s.NormalizedName == parsedSeries.NormalizedName)
.OrderBy(s => s.Id) // Sort to handle potential duplicates
.ToList();
// Prefer the first match or handle duplicates by choosing the last one
if (matchingSeries.Any())
{
var seriesId = await _context.Series
.Where(s => s.Format == parsedSeries.Format && s.NormalizedName == parsedSeries.NormalizedName &&
s.LibraryId == libraryId)
.Select(s => s.Id)
.SingleOrDefaultAsync();
if (seriesId > 0)
{
ids.Add(seriesId);
}
}
catch (Exception)
{
// This is due to v0.5.6 introducing bugs where we could have multiple series get duplicated and no way to delete them
// This here will delete the 2nd one as the first is the one to likely be used.
var sId = await _context.Series
.Where(s => s.Format == parsedSeries.Format && s.NormalizedName == parsedSeries.NormalizedName &&
s.LibraryId == libraryId)
.Select(s => s.Id)
.OrderBy(s => s)
.LastAsync();
if (sId > 0)
{
ids.Add(sId);
}
ids.Add(matchingSeries.Last().Id);
}
}
var seriesToRemove = await _context.Series
.Where(s => s.LibraryId == libraryId)
// Filter out series that are not in the seenSeries
var seriesToRemove = dbSeries
.Where(s => !ids.Contains(s.Id))
.ToListAsync();
.ToList();
// Remove series in bulk
_context.Series.RemoveRange(seriesToRemove);
return seriesToRemove;

View file

@ -5,6 +5,7 @@ using API.DTOs.Metadata;
using API.Entities;
using API.Extensions;
using API.Extensions.QueryExtensions;
using API.Services.Tasks.Scanner.Parser;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
@ -20,6 +21,7 @@ public interface ITagRepository
Task<IList<TagDto>> GetAllTagDtosAsync(int userId);
Task RemoveAllTagNoLongerAssociated();
Task<IList<TagDto>> GetAllTagDtosForLibrariesAsync(int userId, IList<int>? libraryIds = null);
Task<List<string>> GetAllTagsNotInListAsync(ICollection<string> tags);
}
public class TagRepository : ITagRepository
@ -79,6 +81,28 @@ public class TagRepository : ITagRepository
.ToListAsync();
}
public async Task<List<string>> GetAllTagsNotInListAsync(ICollection<string> tags)
{
// Create a dictionary mapping normalized names to non-normalized names
var normalizedToOriginalMap = tags.Distinct()
.GroupBy(Parser.Normalize)
.ToDictionary(group => group.Key, group => group.First());
var normalizedTagNames = normalizedToOriginalMap.Keys.ToList();
// Query the database for existing genres using the normalized names
var existingTags = await _context.Tag
.Where(g => normalizedTagNames.Contains(g.NormalizedTitle)) // Assuming you have a normalized field
.Select(g => g.NormalizedTitle)
.ToListAsync();
// Find the normalized genres that do not exist in the database
var missingTags = normalizedTagNames.Except(existingTags).ToList();
// Return the original non-normalized genres for the missing ones
return missingTags.Select(normalizedName => normalizedToOriginalMap[normalizedName]).ToList();
}
public async Task<IList<Tag>> GetAllTagsAsync()
{
return await _context.Tag.ToListAsync();