mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
Last Read Filter + A lot of bug fixes (#3312)
This commit is contained in:
parent
953d80de1a
commit
6b13db129e
@ -14,6 +14,7 @@ using API.Data.Metadata;
|
||||
using API.Data.Repositories;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.Helpers.Builders;
|
||||
using API.Services;
|
||||
@ -157,6 +158,34 @@ public class ScannerServiceTests : AbstractDbTest
|
||||
Assert.Equal(3, postLib.Series.First().Volumes.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScanLibrary_LocalizedSeries2()
|
||||
{
|
||||
const string testcase = "Series with Localized 2 - Manga.json";
|
||||
|
||||
// Get the first file and generate a ComicInfo
|
||||
var infos = new Dictionary<string, ComicInfo>();
|
||||
infos.Add("Immoral Guild v01.cbz", new ComicInfo()
|
||||
{
|
||||
Series = "Immoral Guild",
|
||||
LocalizedSeries = "Futoku no Guild" // Filename has a capital N and localizedSeries has lowercase
|
||||
});
|
||||
|
||||
var library = await GenerateScannerData(testcase, infos);
|
||||
|
||||
|
||||
var scanner = CreateServices();
|
||||
await scanner.ScanLibrary(library.Id);
|
||||
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
|
||||
|
||||
Assert.NotNull(postLib);
|
||||
Assert.Single(postLib.Series);
|
||||
var s = postLib.Series.First();
|
||||
Assert.Equal("Immoral Guild", s.Name);
|
||||
Assert.Equal("Futoku no Guild", s.LocalizedName);
|
||||
Assert.Equal(3, s.Volumes.Count);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Files under a folder with a SP marker should group into one issue
|
||||
@ -179,6 +208,109 @@ public class ScannerServiceTests : AbstractDbTest
|
||||
Assert.Equal(3, postLib.Series.First().Volumes.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This test is currently disabled because the Image parser is unable to support multiple files mapping into one single Special.
|
||||
/// https://github.com/Kareadita/Kavita/issues/3299
|
||||
/// </summary>
|
||||
public async Task ScanLibrary_ImageSeries_SpecialGrouping_NonEnglish()
|
||||
{
|
||||
const string testcase = "Image Series with SP Folder (Non English) - Image.json";
|
||||
|
||||
var library = await GenerateScannerData(testcase);
|
||||
|
||||
|
||||
var scanner = CreateServices();
|
||||
await scanner.ScanLibrary(library.Id);
|
||||
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
|
||||
|
||||
Assert.NotNull(postLib);
|
||||
Assert.Single(postLib.Series);
|
||||
var series = postLib.Series.First();
|
||||
Assert.Equal(3, series.Volumes.Count);
|
||||
var specialVolume = series.Volumes.FirstOrDefault(v => v.Name == Parser.SpecialVolume);
|
||||
Assert.NotNull(specialVolume);
|
||||
Assert.Single(specialVolume.Chapters);
|
||||
Assert.True(specialVolume.Chapters.First().IsSpecial);
|
||||
//Assert.Equal("葬送のフリーレン 公式ファンブック SP01", specialVolume.Chapters.First().Title);
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task ScanLibrary_PublishersInheritFromChapters()
|
||||
{
|
||||
const string testcase = "Flat Special - Manga.json";
|
||||
|
||||
var infos = new Dictionary<string, ComicInfo>();
|
||||
infos.Add("Uzaki-chan Wants to Hang Out! v01 (2019) (Digital) (danke-Empire).cbz", new ComicInfo()
|
||||
{
|
||||
Publisher = "Correct Publisher"
|
||||
});
|
||||
infos.Add("Uzaki-chan Wants to Hang Out! - 2022 New Years Special SP01.cbz", new ComicInfo()
|
||||
{
|
||||
Publisher = "Special Publisher"
|
||||
});
|
||||
infos.Add("Uzaki-chan Wants to Hang Out! - Ch. 103 - Kouhai and Control.cbz", new ComicInfo()
|
||||
{
|
||||
Publisher = "Chapter Publisher"
|
||||
});
|
||||
|
||||
var library = await GenerateScannerData(testcase, infos);
|
||||
|
||||
|
||||
var scanner = CreateServices();
|
||||
await scanner.ScanLibrary(library.Id);
|
||||
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
|
||||
|
||||
Assert.NotNull(postLib);
|
||||
Assert.Single(postLib.Series);
|
||||
var publishers = postLib.Series.First().Metadata.People
|
||||
.Where(p => p.Role == PersonRole.Publisher);
|
||||
Assert.Equal(3, publishers.Count());
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Tests that pdf parser handles the loose chapters correctly
|
||||
/// https://github.com/Kareadita/Kavita/issues/3148
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ScanLibrary_LooseChapters_Pdf()
|
||||
{
|
||||
const string testcase = "PDF Comic Chapters - Comic.json";
|
||||
|
||||
var library = await GenerateScannerData(testcase);
|
||||
|
||||
|
||||
var scanner = CreateServices();
|
||||
await scanner.ScanLibrary(library.Id);
|
||||
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
|
||||
|
||||
Assert.NotNull(postLib);
|
||||
Assert.Single(postLib.Series);
|
||||
var series = postLib.Series.First();
|
||||
Assert.Single(series.Volumes);
|
||||
Assert.Equal(4, series.Volumes.First().Chapters.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScanLibrary_LooseChapters_Pdf_LN()
|
||||
{
|
||||
const string testcase = "PDF Comic Chapters - LightNovel.json";
|
||||
|
||||
var library = await GenerateScannerData(testcase);
|
||||
|
||||
|
||||
var scanner = CreateServices();
|
||||
await scanner.ScanLibrary(library.Id);
|
||||
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
|
||||
|
||||
Assert.NotNull(postLib);
|
||||
Assert.Single(postLib.Series);
|
||||
var series = postLib.Series.First();
|
||||
Assert.Single(series.Volumes);
|
||||
Assert.Equal(4, series.Volumes.First().Chapters.Count);
|
||||
}
|
||||
|
||||
|
||||
#region Setup
|
||||
private async Task<Library> GenerateScannerData(string testcase, Dictionary<string, ComicInfo> comicInfos = null)
|
||||
|
@ -0,0 +1,5 @@
|
||||
[
|
||||
"葬送のフィリーレン/葬送のフィリーレン vol 1/0001.png",
|
||||
"葬送のフィリーレン/葬送のフィリーレン vol 2/0002.png",
|
||||
"葬送のフィリーレン/Specials/葬送のフリーレン 公式ファンブック SP01/0001.png"
|
||||
]
|
@ -0,0 +1,6 @@
|
||||
[
|
||||
"Dreadwolf/Dreadwolf Chapter 1-12.pdf",
|
||||
"Dreadwolf/Dreadwolf Chapter 13-24.pdf",
|
||||
"Dreadwolf/Dreadwolf Chapter 25.pdf",
|
||||
"Dreadwolf/Dreadwolf Chapter 26.pdf"
|
||||
]
|
@ -0,0 +1,6 @@
|
||||
[
|
||||
"Dreadwolf/Dreadwolf Chapter 1-12.pdf",
|
||||
"Dreadwolf/Dreadwolf Chapter 13-24.pdf",
|
||||
"Dreadwolf/Dreadwolf Chapter 25.pdf",
|
||||
"Dreadwolf/Dreadwolf Chapter 26.pdf"
|
||||
]
|
@ -0,0 +1,5 @@
|
||||
[
|
||||
"Immoral Guild/Immoral Guild v01.cbz",
|
||||
"Immoral Guild/Immoral Guild v02.cbz",
|
||||
"Immoral Guild/Futoku No Guild - Vol. 12 Ch. 67 - Take Responsibility.cbz"
|
||||
]
|
@ -23,11 +23,15 @@ public class AppUserCollectionDto : IHasCoverImage
|
||||
public string SecondaryColor { get; set; } = string.Empty;
|
||||
public bool CoverImageLocked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of Series in the Collection
|
||||
/// </summary>
|
||||
public int ItemCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Owner of the Collection
|
||||
/// </summary>
|
||||
public string? Owner { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Last time Kavita Synced the Collection with an upstream source (for non Kavita sourced collections)
|
||||
/// </summary>
|
||||
|
@ -51,6 +51,10 @@ public enum FilterField
|
||||
AverageRating = 28,
|
||||
Imprint = 29,
|
||||
Team = 30,
|
||||
Location = 31
|
||||
Location = 31,
|
||||
/// <summary>
|
||||
/// Last time User Read
|
||||
/// </summary>
|
||||
ReadLast = 32,
|
||||
|
||||
}
|
||||
|
@ -22,6 +22,11 @@ public class ReadingListDto : IHasCoverImage
|
||||
public string PrimaryColor { get; set; } = string.Empty;
|
||||
public string SecondaryColor { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Number of Items in the Reading List
|
||||
/// </summary>
|
||||
public int ItemCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum Year the Reading List starts
|
||||
/// </summary>
|
||||
|
@ -1253,6 +1253,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
FilterField.ReleaseYear => query.HasReleaseYear(true, statement.Comparison, (int) value),
|
||||
FilterField.ReadTime => query.HasAverageReadTime(true, statement.Comparison, (int) value),
|
||||
FilterField.ReadingDate => query.HasReadingDate(true, statement.Comparison, (DateTime) value, userId),
|
||||
FilterField.ReadLast => query.HasReadLast(true, statement.Comparison, (int) value, userId),
|
||||
FilterField.AverageRating => query.HasAverageRating(true, statement.Comparison, (float) value),
|
||||
_ => throw new ArgumentOutOfRangeException()
|
||||
};
|
||||
|
@ -257,9 +257,9 @@ public static class SeriesFilter
|
||||
.Select(s => new
|
||||
{
|
||||
Series = s,
|
||||
Percentage = ((float) s.Progress
|
||||
Percentage = s.Progress
|
||||
.Where(p => p != null && p.AppUserId == userId)
|
||||
.Sum(p => p != null ? (p.PagesRead * 1.0f / s.Pages) : 0) * 100)
|
||||
.Sum(p => p != null ? (p.PagesRead * 1.0f / s.Pages) : 0) * 100
|
||||
})
|
||||
.AsSplitQuery()
|
||||
.AsEnumerable();
|
||||
@ -361,6 +361,72 @@ public static class SeriesFilter
|
||||
return queryable.Where(s => ids.Contains(s.Id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HasReadingDate but used to filter where last reading point was TODAY() - timeDeltaDays. This allows the user
|
||||
/// to build smart filters "Haven't read in a month"
|
||||
/// </summary>
|
||||
public static IQueryable<Series> HasReadLast(this IQueryable<Series> queryable, bool condition,
|
||||
FilterComparison comparison, int timeDeltaDays, int userId)
|
||||
{
|
||||
if (!condition || timeDeltaDays == 0) return queryable;
|
||||
|
||||
var subQuery = queryable
|
||||
.Include(s => s.Progress)
|
||||
.Where(s => s.Progress != null)
|
||||
.Select(s => new
|
||||
{
|
||||
Series = s,
|
||||
MaxDate = s.Progress.Where(p => p != null && p.AppUserId == userId)
|
||||
.Select(p => (DateTime?) p.LastModified)
|
||||
.DefaultIfEmpty()
|
||||
.Max()
|
||||
})
|
||||
.Where(s => s.MaxDate != null)
|
||||
.AsSplitQuery()
|
||||
.AsEnumerable();
|
||||
|
||||
var date = DateTime.Now.AddDays(-timeDeltaDays);
|
||||
|
||||
switch (comparison)
|
||||
{
|
||||
case FilterComparison.Equal:
|
||||
subQuery = subQuery.Where(s => s.MaxDate != null && s.MaxDate.Equals(date));
|
||||
break;
|
||||
case FilterComparison.IsAfter:
|
||||
case FilterComparison.GreaterThan:
|
||||
subQuery = subQuery.Where(s => s.MaxDate != null && s.MaxDate > date);
|
||||
break;
|
||||
case FilterComparison.GreaterThanEqual:
|
||||
subQuery = subQuery.Where(s => s.MaxDate != null && s.MaxDate >= date);
|
||||
break;
|
||||
case FilterComparison.IsBefore:
|
||||
case FilterComparison.LessThan:
|
||||
subQuery = subQuery.Where(s => s.MaxDate != null && s.MaxDate < date);
|
||||
break;
|
||||
case FilterComparison.LessThanEqual:
|
||||
subQuery = subQuery.Where(s => s.MaxDate != null && s.MaxDate <= date);
|
||||
break;
|
||||
case FilterComparison.NotEqual:
|
||||
subQuery = subQuery.Where(s => s.MaxDate != null && !s.MaxDate.Equals(date));
|
||||
break;
|
||||
case FilterComparison.Matches:
|
||||
case FilterComparison.Contains:
|
||||
case FilterComparison.NotContains:
|
||||
case FilterComparison.BeginsWith:
|
||||
case FilterComparison.EndsWith:
|
||||
case FilterComparison.IsInLast:
|
||||
case FilterComparison.IsNotInLast:
|
||||
case FilterComparison.MustContains:
|
||||
case FilterComparison.IsEmpty:
|
||||
throw new KavitaException($"{comparison} not applicable for Series.ReadProgress");
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
|
||||
}
|
||||
|
||||
var ids = subQuery.Select(s => s.Series.Id).ToList();
|
||||
return queryable.Where(s => ids.Contains(s.Id));
|
||||
}
|
||||
|
||||
public static IQueryable<Series> HasReadingDate(this IQueryable<Series> queryable, bool condition,
|
||||
FilterComparison comparison, DateTime? date, int userId)
|
||||
{
|
||||
|
@ -59,7 +59,8 @@ public class AutoMapperProfiles : Profile
|
||||
CreateMap<Series, SeriesDto>();
|
||||
CreateMap<CollectionTag, CollectionTagDto>();
|
||||
CreateMap<AppUserCollection, AppUserCollectionDto>()
|
||||
.ForMember(dest => dest.Owner, opt => opt.MapFrom(src => src.AppUser.UserName));
|
||||
.ForMember(dest => dest.Owner, opt => opt.MapFrom(src => src.AppUser.UserName))
|
||||
.ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count));
|
||||
CreateMap<Person, PersonDto>();
|
||||
CreateMap<Genre, GenreTagDto>();
|
||||
CreateMap<Tag, TagDto>();
|
||||
@ -266,7 +267,8 @@ public class AutoMapperProfiles : Profile
|
||||
|
||||
CreateMap<AppUserBookmark, BookmarkDto>();
|
||||
|
||||
CreateMap<ReadingList, ReadingListDto>();
|
||||
CreateMap<ReadingList, ReadingListDto>()
|
||||
.ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count));
|
||||
CreateMap<ReadingListItem, ReadingListItemDto>();
|
||||
CreateMap<ScrobbleError, ScrobbleErrorDto>();
|
||||
CreateMap<ChapterDto, TachiyomiChapterDto>();
|
||||
|
@ -101,6 +101,7 @@ public static class FilterFieldValueConverter
|
||||
FilterField.WantToRead => bool.Parse(value),
|
||||
FilterField.ReadProgress => string.IsNullOrEmpty(value) ? 0f : value.AsFloat(),
|
||||
FilterField.ReadingDate => DateTime.Parse(value),
|
||||
FilterField.ReadLast => int.Parse(value),
|
||||
FilterField.Formats => value.Split(',')
|
||||
.Select(x => (MangaFormat) Enum.Parse(typeof(MangaFormat), x))
|
||||
.ToList(),
|
||||
|
@ -613,7 +613,7 @@ public class ParseScannedFiles
|
||||
}
|
||||
|
||||
// Remove or clear any scan results that now have no ParserInfos after merging
|
||||
return scanResults.Where(sr => sr.ParserInfos.Any()).ToList();
|
||||
return scanResults.Where(sr => sr.ParserInfos.Count > 0).ToList();
|
||||
}
|
||||
|
||||
private static List<ParserInfo> GetRelevantInfos(List<ParserInfo> allInfos)
|
||||
@ -665,10 +665,11 @@ public class ParseScannedFiles
|
||||
}
|
||||
}
|
||||
|
||||
private void RemapSeries(IList<ScanResult> scanResults, List<ParserInfo> allInfos, string localizedSeries, string nonLocalizedSeries)
|
||||
private static void RemapSeries(IList<ScanResult> scanResults, List<ParserInfo> allInfos, string localizedSeries, string nonLocalizedSeries)
|
||||
{
|
||||
// Find all infos that need to be remapped from the localized series to the non-localized series
|
||||
var seriesToBeRemapped = allInfos.Where(i => i.Series.Equals(localizedSeries)).ToList();
|
||||
var normalizedLocalizedSeries = localizedSeries.ToNormalized();
|
||||
var seriesToBeRemapped = allInfos.Where(i => i.Series.ToNormalized().Equals(normalizedLocalizedSeries)).ToList();
|
||||
|
||||
foreach (var infoNeedingMapping in seriesToBeRemapped)
|
||||
{
|
||||
|
@ -9,7 +9,7 @@ public class ImageParser(IDirectoryService directoryService) : DefaultParser(dir
|
||||
{
|
||||
public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null)
|
||||
{
|
||||
if (type != LibraryType.Image || !Parser.IsImage(filePath)) return null;
|
||||
if (!IsApplicable(filePath, type)) return null;
|
||||
|
||||
var directoryName = directoryService.FileSystem.DirectoryInfo.New(rootPath).Name;
|
||||
var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath);
|
||||
@ -29,7 +29,7 @@ public class ImageParser(IDirectoryService directoryService) : DefaultParser(dir
|
||||
if (IsEmptyOrDefault(ret.Volumes, ret.Chapters))
|
||||
{
|
||||
ret.IsSpecial = true;
|
||||
ret.Volumes = $"{Parser.SpecialVolumeNumber}";
|
||||
ret.Volumes = Parser.SpecialVolume;
|
||||
}
|
||||
|
||||
// Override the series name, as fallback folders needs it to try and parse folder name
|
||||
@ -38,6 +38,7 @@ public class ImageParser(IDirectoryService directoryService) : DefaultParser(dir
|
||||
ret.Series = Parser.CleanTitle(directoryName, replaceSpecials: false);
|
||||
}
|
||||
|
||||
|
||||
return string.IsNullOrEmpty(ret.Series) ? null : ret;
|
||||
}
|
||||
|
||||
|
@ -722,78 +722,64 @@ public class ProcessSeries : IProcessSeries
|
||||
}
|
||||
|
||||
RemoveChapters(volume, parsedInfos);
|
||||
|
||||
// // Update all the metadata on the Chapters
|
||||
// foreach (var chapter in volume.Chapters)
|
||||
// {
|
||||
// var firstFile = chapter.Files.MinBy(x => x.Chapter);
|
||||
// if (firstFile == null || _cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, forceUpdate, firstFile)) continue;
|
||||
// try
|
||||
// {
|
||||
// var firstChapterInfo = infos.SingleOrDefault(i => i.FullFilePath.Equals(firstFile.FilePath));
|
||||
// await UpdateChapterFromComicInfo(chapter, firstChapterInfo?.ComicInfo, forceUpdate);
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// _logger.LogError(ex, "There was some issue when updating chapter's metadata");
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
private void RemoveChapters(Volume volume, IList<ParserInfo> parsedInfos)
|
||||
{
|
||||
// Remove chapters that aren't in parsedInfos or have no files linked
|
||||
// Chapters to remove after enumeration
|
||||
var chaptersToRemove = new List<Chapter>();
|
||||
|
||||
var existingChapters = volume.Chapters;
|
||||
|
||||
// Extract the directories (without filenames) from parserInfos
|
||||
var parsedDirectories = parsedInfos
|
||||
.Select(p => Path.GetDirectoryName(p.FullFilePath)) // Get directory path
|
||||
.Select(p => Path.GetDirectoryName(p.FullFilePath))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
foreach (var existingChapter in existingChapters)
|
||||
{
|
||||
// Get the directories for the files in the current chapter
|
||||
var chapterFileDirectories = existingChapter.Files
|
||||
.Select(f => Path.GetDirectoryName(f.FilePath)) // Get directory path minus the filename
|
||||
.Select(f => Path.GetDirectoryName(f.FilePath))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
// Check if any of the chapter's file directories match the parsedDirectories
|
||||
var hasMatchingDirectory = chapterFileDirectories.Exists(dir => parsedDirectories.Contains(dir));
|
||||
|
||||
if (hasMatchingDirectory)
|
||||
{
|
||||
// Ensure we remove any files that no longer exist AND order the remaining files
|
||||
existingChapter.Files = existingChapter.Files
|
||||
.Where(f => parsedInfos.Any(p => Parser.Parser.NormalizePath(p.FullFilePath) == Parser.Parser.NormalizePath(f.FilePath)))
|
||||
.OrderByNatural(f => f.FilePath)
|
||||
.ToList();
|
||||
|
||||
// Update the chapter's page count after filtering the files
|
||||
existingChapter.Pages = existingChapter.Files.Sum(f => f.Pages);
|
||||
|
||||
// If no files remain after filtering, remove the chapter
|
||||
if (existingChapter.Files.Count != 0) continue;
|
||||
|
||||
_logger.LogDebug("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName}",
|
||||
existingChapter.Range, volume.Name, parsedInfos[0].Series);
|
||||
volume.Chapters.Remove(existingChapter);
|
||||
chaptersToRemove.Add(existingChapter); // Mark chapter for removal
|
||||
}
|
||||
else
|
||||
{
|
||||
// If there are no matching directories in the current scan, check if the files still exist on disk
|
||||
var filesExist = existingChapter.Files.Any(f => File.Exists(f.FilePath));
|
||||
|
||||
// If no files exist, remove the chapter
|
||||
if (filesExist) continue;
|
||||
|
||||
_logger.LogDebug("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName} as no files exist",
|
||||
existingChapter.Range, volume.Name, parsedInfos[0].Series);
|
||||
volume.Chapters.Remove(existingChapter);
|
||||
chaptersToRemove.Add(existingChapter); // Mark chapter for removal
|
||||
}
|
||||
}
|
||||
|
||||
// Remove chapters after the loop to avoid modifying the collection during enumeration
|
||||
foreach (var chapter in chaptersToRemove)
|
||||
{
|
||||
volume.Chapters.Remove(chapter);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void AddOrUpdateFileForChapter(Chapter chapter, ParserInfo info, bool forceUpdate = false)
|
||||
{
|
||||
chapter.Files ??= new List<MangaFile>();
|
||||
|
@ -18,4 +18,5 @@ export interface UserCollection {
|
||||
totalSourceCount: number;
|
||||
missingSeriesFromSource: string | null;
|
||||
ageRating: AgeRating;
|
||||
itemCount: number;
|
||||
}
|
||||
|
@ -34,7 +34,8 @@ export enum FilterField
|
||||
AverageRating = 28,
|
||||
Imprint = 29,
|
||||
Team = 30,
|
||||
Location = 31
|
||||
Location = 31,
|
||||
ReadLast = 32
|
||||
}
|
||||
|
||||
|
||||
|
@ -3,39 +3,40 @@ import { MangaFormat } from "./manga-format";
|
||||
import {IHasCover} from "./common/i-has-cover";
|
||||
|
||||
export interface ReadingListItem {
|
||||
pagesRead: number;
|
||||
pagesTotal: number;
|
||||
seriesName: string;
|
||||
seriesFormat: MangaFormat;
|
||||
seriesId: number;
|
||||
chapterId: number;
|
||||
order: number;
|
||||
chapterNumber: string;
|
||||
volumeNumber: string;
|
||||
libraryId: number;
|
||||
id: number;
|
||||
releaseDate: string;
|
||||
title: string;
|
||||
libraryType: LibraryType;
|
||||
libraryName: string;
|
||||
summary?: string;
|
||||
pagesRead: number;
|
||||
pagesTotal: number;
|
||||
seriesName: string;
|
||||
seriesFormat: MangaFormat;
|
||||
seriesId: number;
|
||||
chapterId: number;
|
||||
order: number;
|
||||
chapterNumber: string;
|
||||
volumeNumber: string;
|
||||
libraryId: number;
|
||||
id: number;
|
||||
releaseDate: string;
|
||||
title: string;
|
||||
libraryType: LibraryType;
|
||||
libraryName: string;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface ReadingList extends IHasCover {
|
||||
id: number;
|
||||
title: string;
|
||||
summary: string;
|
||||
promoted: boolean;
|
||||
coverImageLocked: boolean;
|
||||
items: Array<ReadingListItem>;
|
||||
/**
|
||||
* If this is empty or null, the cover image isn't set. Do not use this externally.
|
||||
*/
|
||||
coverImage?: string;
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
startingYear: number;
|
||||
startingMonth: number;
|
||||
endingYear: number;
|
||||
endingMonth: number;
|
||||
id: number;
|
||||
title: string;
|
||||
summary: string;
|
||||
promoted: boolean;
|
||||
coverImageLocked: boolean;
|
||||
items: Array<ReadingListItem>;
|
||||
/**
|
||||
* If this is empty or null, the cover image isn't set. Do not use this externally.
|
||||
*/
|
||||
coverImage?: string;
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
startingYear: number;
|
||||
startingMonth: number;
|
||||
endingYear: number;
|
||||
endingMonth: number;
|
||||
itemCount: number;
|
||||
}
|
||||
|
@ -72,6 +72,8 @@ export class FilterFieldPipe implements PipeTransform {
|
||||
return translate('filter-field-pipe.want-to-read');
|
||||
case FilterField.ReadingDate:
|
||||
return translate('filter-field-pipe.read-date');
|
||||
case FilterField.ReadLast:
|
||||
return translate('filter-field-pipe.read-last');
|
||||
case FilterField.AverageRating:
|
||||
return translate('filter-field-pipe.average-rating');
|
||||
default:
|
||||
|
@ -18,7 +18,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<ng-template #submenu let-list="list">
|
||||
@for(action of list; track action.id) {
|
||||
@for(action of list; track action.title) {
|
||||
<!-- Non Submenu items -->
|
||||
@if (action.children === undefined || action?.children?.length === 0 || action.dynamicList !== undefined) {
|
||||
@if (action.dynamicList !== undefined && (action.dynamicList | async | dynamicList); as dList) {
|
||||
|
@ -0,0 +1,39 @@
|
||||
@if (publishers.length > 0) {
|
||||
<div class="publisher-wrapper">
|
||||
<div class="publisher-flipper" [class.is-flipped]="isFlipped">
|
||||
<div class="publisher-side publisher-front">
|
||||
<div class="publisher-img-container d-inline-flex align-items-center me-2 position-relative">
|
||||
<app-image
|
||||
[imageUrl]="imageService.getPersonImage(currentPublisher!.id)"
|
||||
[classes]="'me-2'"
|
||||
[hideOnError]="true"
|
||||
width="32px"
|
||||
height="32px"
|
||||
aria-hidden="true">
|
||||
</app-image>
|
||||
<div class="position-relative d-inline-block"
|
||||
(click)="openPublisher(currentPublisher!.id)">
|
||||
{{currentPublisher!.name}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="publisher-side publisher-back">
|
||||
<div class="publisher-img-container d-inline-flex align-items-center me-2 position-relative">
|
||||
<app-image
|
||||
[imageUrl]="imageService.getPersonImage(nextPublisher!.id)"
|
||||
[classes]="'me-2'"
|
||||
[hideOnError]="true"
|
||||
width="32px"
|
||||
height="32px"
|
||||
aria-hidden="true">
|
||||
</app-image>
|
||||
<div class="position-relative d-inline-block"
|
||||
(click)="openPublisher(nextPublisher!.id)">
|
||||
{{nextPublisher!.name}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
@ -0,0 +1,45 @@
|
||||
//.publisher-flipper-container {
|
||||
// perspective: 1000px;
|
||||
//}
|
||||
//
|
||||
//.publisher-img-container {
|
||||
// transform-style: preserve-3d;
|
||||
// transition: transform 0.5s;
|
||||
//}
|
||||
|
||||
|
||||
// jumpbar example
|
||||
|
||||
.publisher-wrapper {
|
||||
perspective: 1000px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.publisher-flipper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: left;
|
||||
transition: transform 0.6s;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.publisher-flipper.is-flipped {
|
||||
transform: rotateX(180deg);
|
||||
}
|
||||
|
||||
.publisher-side {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
backface-visibility: hidden;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.publisher-front {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.publisher-back {
|
||||
transform: rotateX(180deg);
|
||||
}
|
@ -0,0 +1,81 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnDestroy, OnInit} from '@angular/core';
|
||||
import {ImageComponent} from "../../shared/image/image.component";
|
||||
import {FilterField} from "../../_models/metadata/v2/filter-field";
|
||||
import {Person} from "../../_models/metadata/person";
|
||||
import {ImageService} from "../../_services/image.service";
|
||||
import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
|
||||
import {FilterUtilitiesService} from "../../shared/_services/filter-utilities.service";
|
||||
import {Router} from "@angular/router";
|
||||
|
||||
const ANIMATION_TIME = 3000;
|
||||
|
||||
@Component({
|
||||
selector: 'app-publisher-flipper',
|
||||
standalone: true,
|
||||
imports: [
|
||||
ImageComponent
|
||||
],
|
||||
templateUrl: './publisher-flipper.component.html',
|
||||
styleUrl: './publisher-flipper.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PublisherFlipperComponent implements OnInit, OnDestroy {
|
||||
|
||||
protected readonly imageService = inject(ImageService);
|
||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
@Input() publishers: Array<Person> = [];
|
||||
|
||||
|
||||
currentPublisher: Person | undefined = undefined;
|
||||
nextPublisher: Person | undefined = undefined;
|
||||
|
||||
currentIndex = 0;
|
||||
isFlipped = false;
|
||||
private intervalId: any;
|
||||
|
||||
|
||||
ngOnInit() {
|
||||
if (this.publishers.length > 0) {
|
||||
this.currentPublisher = this.publishers[0];
|
||||
this.nextPublisher = this.publishers[1] || this.publishers[0];
|
||||
if (this.publishers.length > 1) {
|
||||
this.startFlipping();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
}
|
||||
|
||||
private startFlipping() {
|
||||
this.intervalId = setInterval(() => {
|
||||
// First flip
|
||||
this.isFlipped = true;
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
// Update content after flip animation completes
|
||||
setTimeout(() => {
|
||||
// Update indices and content
|
||||
this.currentIndex = (this.currentIndex + 1) % this.publishers.length;
|
||||
this.currentPublisher = this.publishers[this.currentIndex];
|
||||
this.nextPublisher = this.publishers[(this.currentIndex + 1) % this.publishers.length];
|
||||
|
||||
// Reset flip
|
||||
this.isFlipped = false;
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
}, ANIMATION_TIME); // Full transition time to ensure flip completes
|
||||
}, ANIMATION_TIME);
|
||||
}
|
||||
|
||||
openPublisher(filter: string | number) {
|
||||
// TODO: once we build out publisher person-detail page, we can redirect there
|
||||
this.filterUtilityService.applyFilter(['all-series'], FilterField.Publisher, FilterComparison.Equal, `${filter}`).subscribe();
|
||||
}
|
||||
}
|
@ -12,6 +12,7 @@
|
||||
<app-carousel-reel [items]="collections" [title]="t('collections-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-card-item [title]="item.title" [entity]="item"
|
||||
[count]="item.itemCount"
|
||||
[suppressLibraryLink]="true" [imageUrl]="imageService.getCollectionCoverImage(item.id)"
|
||||
(clicked)="openCollection(item)" [linkUrl]="'/collections/' + item.id" [showFormat]="false"></app-card-item>
|
||||
</ng-template>
|
||||
@ -23,6 +24,7 @@
|
||||
<app-carousel-reel [items]="readingLists" [title]="t('reading-lists-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-card-item [title]="item.title" [entity]="item"
|
||||
[count]="item.itemCount"
|
||||
[suppressLibraryLink]="true" [imageUrl]="imageService.getReadingListCoverImage(item.id)"
|
||||
(clicked)="openReadingList(item)" [linkUrl]="'/lists/' + item.id" [showFormat]="false"></app-card-item>
|
||||
</ng-template>
|
||||
|
@ -6,21 +6,21 @@
|
||||
|
||||
<div class="row g-0 mt-2">
|
||||
@if (settingsForm.get('hostName'); as formControl) {
|
||||
<app-setting-item [title]="t('host-name-label')" [subtitle]="t('host-name-tooltip')">
|
||||
<app-setting-item [title]="t('host-name-label')" [subtitle]="t('host-name-tooltip')" [control]="formControl">
|
||||
<ng-template #view>
|
||||
{{formControl.value | defaultValue}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<div class="input-group">
|
||||
<input id="settings-hostname" aria-describedby="hostname-validations" class="form-control" formControlName="hostName" type="text"
|
||||
[class.is-invalid]="formControl.invalid && formControl.touched">
|
||||
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="autofillGmail()">{{t('gmail-label')}}</button>
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="autofillOutlook()">{{t('outlook-label')}}</button>
|
||||
</div>
|
||||
|
||||
@if(settingsForm.dirty || settingsForm.touched) {
|
||||
<div id="hostname-validations" class="invalid-feedback">
|
||||
@if (formControl.errors?.pattern) {
|
||||
@if (formControl.errors; as errors) {
|
||||
<div id="hostname-validations" class="invalid-feedback" style="display: inline-block">
|
||||
@if (errors.pattern) {
|
||||
<div>{{t('host-name-validation')}}</div>
|
||||
}
|
||||
</div>
|
||||
|
@ -198,10 +198,6 @@ export class ManageLibraryComponent implements OnInit {
|
||||
|
||||
|
||||
async applyBulkAction() {
|
||||
if (!this.bulkMode) {
|
||||
this.resetBulkMode();
|
||||
}
|
||||
|
||||
// Get Selected libraries
|
||||
let selected = this.selections.selected();
|
||||
|
||||
@ -218,32 +214,54 @@ export class ManageLibraryComponent implements OnInit {
|
||||
switch(this.bulkAction) {
|
||||
case (Action.Scan):
|
||||
await this.confirmService.alert(translate('toasts.bulk-scan'));
|
||||
this.libraryService.scanMultipleLibraries(selected.map(l => l.id)).subscribe();
|
||||
this.bulkMode = true;
|
||||
this.cdRef.markForCheck();
|
||||
this.libraryService.scanMultipleLibraries(selected.map(l => l.id)).subscribe(_ => this.resetBulkMode());
|
||||
break;
|
||||
case Action.RefreshMetadata:
|
||||
if (!await this.confirmService.confirm(translate('toasts.bulk-covers'))) return;
|
||||
this.libraryService.refreshMetadataMultipleLibraries(selected.map(l => l.id), true, false).subscribe(() => this.getLibraries());
|
||||
this.bulkMode = true;
|
||||
this.cdRef.markForCheck();
|
||||
this.libraryService.refreshMetadataMultipleLibraries(selected.map(l => l.id), true, false).subscribe(() => {
|
||||
this.getLibraries();
|
||||
this.resetBulkMode();
|
||||
});
|
||||
break
|
||||
case Action.AnalyzeFiles:
|
||||
this.libraryService.analyzeFilesMultipleLibraries(selected.map(l => l.id)).subscribe(() => this.getLibraries());
|
||||
this.bulkMode = true;
|
||||
this.cdRef.markForCheck();
|
||||
this.libraryService.analyzeFilesMultipleLibraries(selected.map(l => l.id)).subscribe(() => {
|
||||
this.getLibraries();
|
||||
this.resetBulkMode();
|
||||
});
|
||||
break;
|
||||
case Action.GenerateColorScape:
|
||||
this.libraryService.refreshMetadataMultipleLibraries(selected.map(l => l.id), false, true).subscribe(() => this.getLibraries());
|
||||
this.bulkMode = true;
|
||||
this.cdRef.markForCheck();
|
||||
this.libraryService.refreshMetadataMultipleLibraries(selected.map(l => l.id), false, true).subscribe(() => {
|
||||
this.getLibraries();
|
||||
this.resetBulkMode();
|
||||
});
|
||||
break;
|
||||
case Action.CopySettings:
|
||||
// Remove the source library from the list
|
||||
if (selected.length === 1 && selected[0].id === this.sourceCopyToLibrary!.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bulkMode = true;
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
const includeType = this.bulkForm.get('includeType')!.value + '' == 'true';
|
||||
this.libraryService.copySettingsFromLibrary(this.sourceCopyToLibrary!.id, selected.map(l => l.id), includeType).subscribe(() => this.getLibraries());
|
||||
this.libraryService.copySettingsFromLibrary(this.sourceCopyToLibrary!.id, selected.map(l => l.id), includeType).subscribe(() => {
|
||||
this.getLibraries();
|
||||
this.resetBulkMode();
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async handleBulkAction(action: ActionItem<Library>, library : Library | null) {
|
||||
|
||||
this.bulkMode = true;
|
||||
this.bulkAction = action.action;
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
@ -252,9 +270,11 @@ export class ManageLibraryComponent implements OnInit {
|
||||
case(Action.RefreshMetadata):
|
||||
case(Action.GenerateColorScape):
|
||||
case (Action.Delete):
|
||||
case (Action.AnalyzeFiles):
|
||||
await this.applyBulkAction();
|
||||
break;
|
||||
case (Action.CopySettings):
|
||||
|
||||
// Prompt the user for the library then wait for them to manually trigger applyBulkAction
|
||||
const ref = this.modalService.open(CopySettingsFromLibraryModalComponent, {size: 'lg', fullscreen: 'md'});
|
||||
ref.componentInstance.libraries = this.libraries;
|
||||
|
@ -39,7 +39,7 @@
|
||||
|
||||
@if (count > 1) {
|
||||
<div class="count">
|
||||
<span class="badge bg-primary">{{count}}</span>
|
||||
<span class="badge bg-primary">{{count | compactNumber}}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -76,7 +76,7 @@
|
||||
<span class="card-format">
|
||||
@if (showFormat) {
|
||||
<app-series-format [format]="format"></app-series-format>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
|
||||
<span class="card-title" placement="top" id="{{title}}_{{entity.id}}" [ngbTooltip]="tooltipTitle" (click)="handleClick($event)" tabindex="0">
|
||||
@ -84,16 +84,15 @@
|
||||
<span class="me-1"><app-promoted-icon [promoted]="isPromoted"></app-promoted-icon></span>
|
||||
}
|
||||
@if (linkUrl) {
|
||||
<a class="dark-exempt btn-icon" href="javascript:void(0);" [routerLink]="linkUrl">{{title}}</a>
|
||||
<a class="dark-exempt btn-icon" [routerLink]="linkUrl">{{title}}</a>
|
||||
} @else {
|
||||
{{title}}
|
||||
}
|
||||
</span>
|
||||
|
||||
<span class="card-actions">
|
||||
@if (actions && actions.length > 0) {
|
||||
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="title"></app-card-actionables>
|
||||
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
@ -47,6 +47,7 @@ import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
|
||||
import {PromotedIconComponent} from "../../shared/_components/promoted-icon/promoted-icon.component";
|
||||
import {SeriesFormatComponent} from "../../shared/series-format/series-format.component";
|
||||
import {BrowsePerson} from "../../_models/person/browse-person";
|
||||
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
|
||||
|
||||
export type CardEntity = Series | Volume | Chapter | UserCollection | PageBookmark | RecentlyAddedItem | NextExpectedChapter | BrowsePerson;
|
||||
|
||||
@ -70,7 +71,8 @@ export type CardEntity = Series | Volume | Chapter | UserCollection | PageBookma
|
||||
PromotedIconComponent,
|
||||
SeriesFormatComponent,
|
||||
DecimalPipe,
|
||||
NgTemplateOutlet
|
||||
NgTemplateOutlet,
|
||||
CompactNumberPipe
|
||||
],
|
||||
templateUrl: './card-item.component.html',
|
||||
styleUrls: ['./card-item.component.scss'],
|
||||
@ -257,6 +259,8 @@ export class CardItemComponent implements OnInit {
|
||||
}
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
} else {
|
||||
this.tooltipTitle = this.title;
|
||||
}
|
||||
|
||||
|
||||
|
@ -1,27 +1,28 @@
|
||||
<div class="card-item-container card">
|
||||
<div class="overlay">
|
||||
<app-image [styles]="{'border-radius': '.25rem .25rem 0 0'}" height="232.91px" width="160px" classes="extreme-blur"
|
||||
[imageUrl]="imageUrl"></app-image>
|
||||
<ng-container *transloco="let t; read: 'next-expected-card'">
|
||||
<div class="card-item-container card">
|
||||
<div class="overlay">
|
||||
<app-image [styles]="{'border-radius': '.25rem .25rem 0 0'}" height="232.91px" width="160px" classes="extreme-blur"
|
||||
[imageUrl]="imageUrl"></app-image>
|
||||
|
||||
<div class="card-overlay"></div>
|
||||
</div>
|
||||
<div class="card-overlay"></div>
|
||||
</div>
|
||||
|
||||
@if (entity.title | safeHtml; as info) {
|
||||
@if (info !== '') {
|
||||
<div class="card-body meta-title">
|
||||
<div class="card-content d-flex flex-column pt-2 pb-2 justify-content-center align-items-center text-center">
|
||||
|
||||
<div class="upcoming-header"><i class="fa-regular fa-clock me-1" aria-hidden="true"></i>Upcoming</div>
|
||||
<span [innerHTML]="info"></span>
|
||||
@if (entity.title | safeHtml; as info) {
|
||||
@if (info !== '') {
|
||||
<div class="card-body meta-title">
|
||||
<div class="card-content d-flex flex-column pt-2 pb-2 justify-content-center align-items-center text-center">
|
||||
<div class="upcoming-header"><i class="fa-regular fa-clock me-1" aria-hidden="true"></i>{{t('upcoming-title')}}</div>
|
||||
<span [innerHTML]="info"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<div class="card-title-container">
|
||||
<span class="card-title" tabindex="0">
|
||||
{{title}}
|
||||
</span>
|
||||
<div class="card-title-container">
|
||||
<span class="card-title" tabindex="0">
|
||||
{{title}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</ng-container>
|
||||
|
@ -1,22 +1,10 @@
|
||||
@use '../../../card-item-common';
|
||||
|
||||
::ng-deep .extreme-blur {
|
||||
filter: brightness(50%) blur(4px)
|
||||
:host ::ng-deep .extreme-blur {
|
||||
filter: brightness(50%) blur(4px);
|
||||
}
|
||||
|
||||
.overlay-information {
|
||||
background-color: transparent;
|
||||
.card-title-container {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.upcoming-header {
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
width: 146px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
@ -3,12 +3,12 @@ import {ImageComponent} from "../../shared/image/image.component";
|
||||
import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter";
|
||||
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
||||
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
|
||||
import {translate} from "@jsverse/transloco";
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-next-expected-card',
|
||||
standalone: true,
|
||||
imports: [ImageComponent, SafeHtmlPipe],
|
||||
imports: [ImageComponent, SafeHtmlPipe, TranslocoDirective],
|
||||
templateUrl: './next-expected-card.component.html',
|
||||
styleUrl: './next-expected-card.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
@ -17,6 +17,7 @@
|
||||
<app-card-item [title]="item.title" [entity]="item" [actions]="collectionTagActions"
|
||||
[imageUrl]="imageService.getCollectionCoverImage(item.id)"
|
||||
[linkUrl]="'/collections/' + item.id"
|
||||
[count]="item.itemCount"
|
||||
(clicked)="loadCollection(item)"
|
||||
(selection)="bulkSelectionService.handleCardSelection('collection', position, collections.length, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('collection', position)" [allowSelection]="true" [showFormat]="false">
|
||||
|
@ -3,7 +3,9 @@
|
||||
<div class="row g-0">
|
||||
<div class="col-md-3 me-2 col-10 mb-2">
|
||||
<select class="form-select me-2" formControlName="input">
|
||||
<option *ngFor="let field of availableFields" [value]="field">{{field | filterField}}</option>
|
||||
@for (field of availableFields; track field) {
|
||||
<option [value]="field">{{field | filterField}}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@ -16,19 +18,19 @@
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 col-10 mb-2">
|
||||
@if (formGroup.get('comparison')?.value != FilterComparison.IsEmpty) {
|
||||
<ng-container *ngIf="predicateType$ | async as predicateType">
|
||||
<ng-container [ngSwitch]="predicateType">
|
||||
<ng-container *ngSwitchCase="PredicateType.Text">
|
||||
@if (formGroup.get('comparison')?.value !== FilterComparison.IsEmpty) {
|
||||
@if (predicateType$ | async; as predicateType) {
|
||||
@switch (predicateType) {
|
||||
@case (PredicateType.Text) {
|
||||
<input type="text" class="form-control me-2" autocomplete="true" formControlName="filterValue">
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="PredicateType.Number">
|
||||
}
|
||||
@case (PredicateType.Number) {
|
||||
<input type="number" inputmode="numeric" class="form-control me-2" formControlName="filterValue" min="0">
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="PredicateType.Boolean">
|
||||
}
|
||||
@case (PredicateType.Boolean) {
|
||||
<input type="checkbox" class="form-check-input mt-2 me-2" style="font-size: 1.5rem" formControlName="filterValue">
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="PredicateType.Date">
|
||||
}
|
||||
@case (PredicateType.Date) {
|
||||
<div class="input-group">
|
||||
<input
|
||||
class="form-control"
|
||||
@ -42,27 +44,21 @@
|
||||
/>
|
||||
<button class="btn btn-outline-secondary fa-solid fa-calendar-days" (click)="d.toggle()" type="button"></button>
|
||||
</div>
|
||||
|
||||
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="PredicateType.Dropdown">
|
||||
<ng-container *ngIf="dropdownOptions$ | async as opts">
|
||||
<ng-container *ngTemplateOutlet="dropdown; context: { options: opts, multipleAllowed: MultipleDropdownAllowed }"></ng-container>
|
||||
<ng-template #dropdown let-options="options" let-multipleAllowed="multipleAllowed">
|
||||
<select2 [data]="options"
|
||||
formControlName="filterValue"
|
||||
[hideSelectedItems]="true"
|
||||
[multiple]="multipleAllowed"
|
||||
[infiniteScroll]="true"
|
||||
[resettable]="true">
|
||||
</select2>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
}
|
||||
@case (PredicateType.Dropdown) {
|
||||
@if (dropdownOptions$ | async; as opts) {
|
||||
<select2 [data]="opts"
|
||||
formControlName="filterValue"
|
||||
[hideSelectedItems]="true"
|
||||
[multiple]="MultipleDropdownAllowed"
|
||||
[infiniteScroll]="true"
|
||||
[resettable]="true">
|
||||
</select2>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col pt-2 ms-2">
|
||||
|
@ -19,7 +19,7 @@ import {LibraryService} from 'src/app/_services/library.service';
|
||||
import {CollectionTagService} from 'src/app/_services/collection-tag.service';
|
||||
import {FilterComparison} from 'src/app/_models/metadata/v2/filter-comparison';
|
||||
import {allFields, FilterField} from 'src/app/_models/metadata/v2/filter-field';
|
||||
import {AsyncPipe, NgForOf, NgIf, NgSwitch, NgSwitchCase, NgTemplateOutlet} from "@angular/common";
|
||||
import {AsyncPipe, NgTemplateOutlet} from "@angular/common";
|
||||
import {FilterFieldPipe} from "../../../_pipes/filter-field.pipe";
|
||||
import {FilterComparisonPipe} from "../../../_pipes/filter-comparison.pipe";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
@ -56,17 +56,22 @@ const unitLabels: Map<FilterField, FilterRowUi> = new Map([
|
||||
[FilterField.AverageRating, new FilterRowUi('unit-average-rating')],
|
||||
[FilterField.ReadProgress, new FilterRowUi('unit-reading-progress')],
|
||||
[FilterField.UserRating, new FilterRowUi('unit-user-rating')],
|
||||
[FilterField.ReadLast, new FilterRowUi('unit-read-last')],
|
||||
]);
|
||||
|
||||
const StringFields = [FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath];
|
||||
const NumberFields = [FilterField.ReadTime, FilterField.ReleaseYear, FilterField.ReadProgress, FilterField.UserRating, FilterField.AverageRating];
|
||||
const DropdownFields = [FilterField.PublicationStatus, FilterField.Languages, FilterField.AgeRating,
|
||||
FilterField.Translators, FilterField.Characters, FilterField.Publisher,
|
||||
FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer,
|
||||
FilterField.Colorist, FilterField.Inker, FilterField.Penciller,
|
||||
FilterField.Writers, FilterField.Genres, FilterField.Libraries,
|
||||
FilterField.Formats, FilterField.CollectionTags, FilterField.Tags,
|
||||
FilterField.Imprint, FilterField.Team, FilterField.Location
|
||||
const NumberFields = [
|
||||
FilterField.ReadTime, FilterField.ReleaseYear, FilterField.ReadProgress,
|
||||
FilterField.UserRating, FilterField.AverageRating, FilterField.ReadLast
|
||||
];
|
||||
const DropdownFields = [
|
||||
FilterField.PublicationStatus, FilterField.Languages, FilterField.AgeRating,
|
||||
FilterField.Translators, FilterField.Characters, FilterField.Publisher,
|
||||
FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer,
|
||||
FilterField.Colorist, FilterField.Inker, FilterField.Penciller,
|
||||
FilterField.Writers, FilterField.Genres, FilterField.Libraries,
|
||||
FilterField.Formats, FilterField.CollectionTags, FilterField.Tags,
|
||||
FilterField.Imprint, FilterField.Team, FilterField.Location
|
||||
];
|
||||
const BooleanFields = [FilterField.WantToRead];
|
||||
const DateFields = [FilterField.ReadingDate];
|
||||
@ -89,7 +94,6 @@ const FieldsThatShouldIncludeIsEmpty = [
|
||||
FilterField.Colorist, FilterField.Inker, FilterField.Penciller,
|
||||
FilterField.Writers, FilterField.Imprint, FilterField.Team,
|
||||
FilterField.Location,
|
||||
|
||||
];
|
||||
|
||||
const StringComparisons = [
|
||||
@ -130,10 +134,6 @@ const BooleanComparisons = [
|
||||
AsyncPipe,
|
||||
FilterFieldPipe,
|
||||
FilterComparisonPipe,
|
||||
NgSwitch,
|
||||
NgSwitchCase,
|
||||
NgForOf,
|
||||
NgIf,
|
||||
Select2Module,
|
||||
NgTemplateOutlet,
|
||||
TagBadgeComponent,
|
||||
@ -159,6 +159,8 @@ export class MetadataFilterRowComponent implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly dateParser = inject(NgbDateParserFormatter);
|
||||
|
||||
protected readonly FilterComparison = FilterComparison;
|
||||
|
||||
formGroup: FormGroup = new FormGroup({
|
||||
'comparison': new FormControl<FilterComparison>(FilterComparison.Equal, []),
|
||||
'filterValue': new FormControl<string | number>('', []),
|
||||
@ -425,12 +427,10 @@ export class MetadataFilterRowComponent implements OnInit {
|
||||
|
||||
|
||||
|
||||
onDateSelect(event: NgbDate) {
|
||||
onDateSelect(_: NgbDate) {
|
||||
this.propagateFilterUpdate();
|
||||
}
|
||||
updateIfDateFilled() {
|
||||
this.propagateFilterUpdate();
|
||||
}
|
||||
|
||||
protected readonly FilterComparison = FilterComparison;
|
||||
}
|
||||
|
@ -22,11 +22,14 @@
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx" >
|
||||
<app-card-item [title]="item.title" [entity]="item" [actions]="actions[item.id]"
|
||||
[suppressLibraryLink]="true" [imageUrl]="imageService.getReadingListCoverImage(item.id)"
|
||||
[suppressLibraryLink]="true" [imageUrl]="imageService.getReadingListCoverImage(item.id)"
|
||||
[linkUrl]="'/lists/' + item.id"
|
||||
[count]="item.itemCount"
|
||||
(clicked)="handleClick(item)"
|
||||
(selection)="bulkSelectionService.handleCardSelection('readingList', position, lists.length, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('readingList', position)" [allowSelection]="true" [showFormat]="false"></app-card-item>
|
||||
[selected]="bulkSelectionService.isCardSelected('readingList', position)"
|
||||
[allowSelection]="true" [showFormat]="false"
|
||||
(selection)="bulkSelectionService.handleCardSelection('readingList', position, lists.length, $event)">
|
||||
</app-card-item>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #noData>
|
||||
|
@ -7,6 +7,8 @@
|
||||
<div class="position-relative d-inline-block" (click)="openGeneric(FilterField.Publisher, entity.publishers[0].id)">{{entity.publishers[0].name}}</div>
|
||||
</div>
|
||||
}
|
||||
<!-- TODO: Figure out if I can implement this animation (ROBBIE)-->
|
||||
<!-- <app-publisher-flipper [publishers]="entity.publishers"></app-publisher-flipper>-->
|
||||
<span class="me-2">
|
||||
<app-age-rating-image [rating]="ageRating"></app-age-rating-image>
|
||||
</span>
|
||||
|
@ -17,4 +17,4 @@
|
||||
|
||||
.word-count {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
@ -19,6 +19,7 @@ import {MangaFormatPipe} from "../../../_pipes/manga-format.pipe";
|
||||
import {MangaFormat} from "../../../_models/manga-format";
|
||||
import {MangaFormatIconPipe} from "../../../_pipes/manga-format-icon.pipe";
|
||||
import {SeriesFormatComponent} from "../../../shared/series-format/series-format.component";
|
||||
import {PublisherFlipperComponent} from "../../../_single-modules/publisher-flipper/publisher-flipper.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-metadata-detail-row',
|
||||
@ -33,7 +34,8 @@ import {SeriesFormatComponent} from "../../../shared/series-format/series-format
|
||||
ImageComponent,
|
||||
MangaFormatPipe,
|
||||
MangaFormatIconPipe,
|
||||
SeriesFormatComponent
|
||||
SeriesFormatComponent,
|
||||
PublisherFlipperComponent
|
||||
],
|
||||
templateUrl: './metadata-detail-row.component.html',
|
||||
styleUrl: './metadata-detail-row.component.scss',
|
||||
|
@ -1,7 +1,9 @@
|
||||
import {
|
||||
AsyncPipe,
|
||||
DecimalPipe,
|
||||
DOCUMENT, JsonPipe, Location,
|
||||
DOCUMENT,
|
||||
JsonPipe,
|
||||
Location,
|
||||
NgClass,
|
||||
NgOptimizedImage,
|
||||
NgStyle,
|
||||
@ -35,19 +37,17 @@ import {
|
||||
NgbNavItem,
|
||||
NgbNavLink,
|
||||
NgbNavOutlet,
|
||||
NgbOffcanvas,
|
||||
NgbProgressbar,
|
||||
NgbTooltip
|
||||
} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {ToastrService} from 'ngx-toastr';
|
||||
import {catchError, forkJoin, Observable, of, shareReplay, tap} from 'rxjs';
|
||||
import {catchError, forkJoin, Observable, of, tap} from 'rxjs';
|
||||
import {map} from 'rxjs/operators';
|
||||
import {BulkSelectionService} from 'src/app/cards/bulk-selection.service';
|
||||
import {
|
||||
EditSeriesModalCloseResult,
|
||||
EditSeriesModalComponent
|
||||
} from 'src/app/cards/_modals/edit-series-modal/edit-series-modal.component';
|
||||
import {TagBadgeCursor} from 'src/app/shared/tag-badge/tag-badge.component';
|
||||
import {DownloadEvent, DownloadService} from 'src/app/shared/_services/download.service';
|
||||
import {Breakpoint, KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service';
|
||||
import {Chapter, LooseLeafOrDefaultNumber, SpecialVolumeNumber} from 'src/app/_models/chapter';
|
||||
@ -65,7 +65,6 @@ import {Volume} from 'src/app/_models/volume';
|
||||
import {AccountService} from 'src/app/_services/account.service';
|
||||
import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service';
|
||||
import {ActionService} from 'src/app/_services/action.service';
|
||||
import {DeviceService} from 'src/app/_services/device.service';
|
||||
import {ImageService} from 'src/app/_services/image.service';
|
||||
import {LibraryService} from 'src/app/_services/library.service';
|
||||
import {EVENTS, MessageHubService} from 'src/app/_services/message-hub.service';
|
||||
@ -704,6 +703,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
if (![PublicationStatus.Ended, PublicationStatus.OnGoing].includes(this.seriesMetadata.publicationStatus)) return;
|
||||
|
||||
this.seriesService.getNextExpectedChapterDate(seriesId).subscribe(date => {
|
||||
if (date == null || date.expectedDate === null) {
|
||||
if (this.nextExpectedChapter !== undefined) {
|
||||
@ -716,7 +716,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
|
||||
this.nextExpectedChapter = date;
|
||||
this.cdRef.markForCheck();
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
this.seriesService.isWantToRead(seriesId).subscribe(isWantToRead => {
|
||||
@ -850,7 +850,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
shouldShowStorylineTab() {
|
||||
if (this.libraryType === LibraryType.ComicVine) return false;
|
||||
// Edge case for bad pdf parse
|
||||
if (this.libraryType === LibraryType.Book && (this.volumes.length === 0 && this.chapters.length === 0 && this.storyChapters.length > 0)) return true;
|
||||
if ((this.libraryType === LibraryType.Book || this.libraryType === LibraryType.LightNovel) && (this.volumes.length === 0 && this.chapters.length === 0 && this.storyChapters.length > 0)) return true;
|
||||
|
||||
return (this.libraryType !== LibraryType.Book && this.libraryType !== LibraryType.LightNovel && this.libraryType !== LibraryType.Comic)
|
||||
&& (this.volumes.length > 0 || this.chapters.length > 0);
|
||||
|
@ -11,6 +11,7 @@ import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {NgTemplateOutlet} from "@angular/common";
|
||||
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
|
||||
import {filter, fromEvent, tap} from "rxjs";
|
||||
import {AbstractControl, FormControl} from "@angular/forms";
|
||||
|
||||
@Component({
|
||||
selector: 'app-setting-item',
|
||||
@ -36,6 +37,7 @@ export class SettingItemComponent {
|
||||
@Input() subtitle: string | undefined = undefined;
|
||||
@Input() labelId: string | undefined = undefined;
|
||||
@Input() toggleOnViewClick: boolean = true;
|
||||
@Input() control: AbstractControl<any> | null = null;
|
||||
@Output() editMode = new EventEmitter<boolean>();
|
||||
|
||||
/**
|
||||
@ -67,6 +69,7 @@ export class SettingItemComponent {
|
||||
.pipe(
|
||||
filter((event: Event) => {
|
||||
if (!this.toggleOnViewClick) return false;
|
||||
if (this.control != null && this.control.invalid) return false;
|
||||
|
||||
const mouseEvent = event as MouseEvent;
|
||||
const selection = window.getSelection();
|
||||
@ -86,6 +89,7 @@ export class SettingItemComponent {
|
||||
|
||||
if (!this.toggleOnViewClick) return;
|
||||
if (!this.canEdit) return;
|
||||
if (this.control != null && this.control.invalid) return;
|
||||
|
||||
this.isEditMode = !this.isEditMode;
|
||||
this.editMode.emit(this.isEditMode);
|
||||
|
@ -252,6 +252,7 @@ export class TypeaheadComponent implements OnInit {
|
||||
case KEY_CODES.ESC_KEY:
|
||||
this.hasFocus = false;
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
@ -1156,7 +1156,7 @@
|
||||
"reset": "{{common.reset}}",
|
||||
"test": "Test",
|
||||
"host-name-label": "Host Name",
|
||||
"host-name-tooltip": "Domain Name (of Reverse Proxy). If set, email generation will always use this.",
|
||||
"host-name-tooltip": "Domain Name (of Reverse Proxy). Required for email functionality. If no reverse proxy, use any url.",
|
||||
"host-name-validation": "Host name must start with http(s) and not end in /",
|
||||
|
||||
"sender-address-label": "Sender Address",
|
||||
@ -1207,6 +1207,7 @@
|
||||
"bulk-action-label": "Bulk Action"
|
||||
},
|
||||
|
||||
|
||||
"copy-settings-from-library-modal": {
|
||||
"close": "{{common.close}}",
|
||||
"select": "Select",
|
||||
@ -1831,7 +1832,8 @@
|
||||
"unit-reading-date": "Date",
|
||||
"unit-average-rating": "Kavita+ external rating, percent",
|
||||
"unit-reading-progress": "Percent",
|
||||
"unit-user-rating": "{{metadata-filter-row.unit-reading-progress}}"
|
||||
"unit-user-rating": "{{metadata-filter-row.unit-reading-progress}}",
|
||||
"unit-read-last": "Days from TODAY"
|
||||
},
|
||||
|
||||
"sort-field-pipe": {
|
||||
@ -2097,7 +2099,8 @@
|
||||
},
|
||||
|
||||
"next-expected-card": {
|
||||
"title": "~{{date}}"
|
||||
"title": "~{{date}}",
|
||||
"upcoming-title": "Upcoming"
|
||||
},
|
||||
|
||||
"server-stats": {
|
||||
@ -2270,6 +2273,7 @@
|
||||
"file-path": "File Path",
|
||||
"want-to-read": "Want to Read",
|
||||
"read-date": "Reading Date",
|
||||
"read-last": "Read Last",
|
||||
"average-rating": "Average Rating"
|
||||
},
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user