Last Read Filter + A lot of bug fixes (#3312)

This commit is contained in:
Joe Milazzo 2024-10-27 09:39:10 -05:00 committed by GitHub
parent 953d80de1a
commit 6b13db129e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 620 additions and 198 deletions

View File

@ -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)

View File

@ -0,0 +1,5 @@
[
"葬送のフィリーレン/葬送のフィリーレン vol 1/0001.png",
"葬送のフィリーレン/葬送のフィリーレン vol 2/0002.png",
"葬送のフィリーレン/Specials/葬送のフリーレン 公式ファンブック SP01/0001.png"
]

View File

@ -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"
]

View File

@ -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"
]

View File

@ -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"
]

View File

@ -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>

View File

@ -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,
}

View File

@ -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>

View File

@ -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()
};

View File

@ -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)
{

View File

@ -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>();

View File

@ -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(),

View File

@ -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)
{

View File

@ -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;
}

View File

@ -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>();

View File

@ -18,4 +18,5 @@ export interface UserCollection {
totalSourceCount: number;
missingSeriesFromSource: string | null;
ageRating: AgeRating;
itemCount: number;
}

View File

@ -34,7 +34,8 @@ export enum FilterField
AverageRating = 28,
Imprint = 29,
Team = 30,
Location = 31
Location = 31,
ReadLast = 32
}

View File

@ -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;
}

View File

@ -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:

View File

@ -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) {

View File

@ -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>
}

View File

@ -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);
}

View File

@ -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();
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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;

View File

@ -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>

View File

@ -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;
}

View File

@ -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>

View File

@ -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;
}

View File

@ -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

View File

@ -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">

View File

@ -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">

View File

@ -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;
}

View File

@ -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>

View File

@ -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>

View File

@ -17,4 +17,4 @@
.word-count {
font-size: 0.8rem;
}
}

View File

@ -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',

View File

@ -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);

View File

@ -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);

View File

@ -252,6 +252,7 @@ export class TypeaheadComponent implements OnInit {
case KEY_CODES.ESC_KEY:
this.hasFocus = false;
event.stopPropagation();
event.preventDefault();
break;
default:
break;

View File

@ -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"
},