diff --git a/API.Tests/Services/CollectionTagServiceTests.cs b/API.Tests/Services/CollectionTagServiceTests.cs index e0e1f4563..c06767ed1 100644 --- a/API.Tests/Services/CollectionTagServiceTests.cs +++ b/API.Tests/Services/CollectionTagServiceTests.cs @@ -119,9 +119,10 @@ public class CollectionTagServiceTests : AbstractDbTest public async Task GetTagOrCreate_ShouldReturnExistingTag() { await SeedSeries(); - var tag = await _service.GetTagOrCreate(1, string.Empty); + var tag = await _service.GetTagOrCreate(1, "Some new tag"); Assert.NotNull(tag); Assert.Equal(1, tag.Id); + Assert.Equal("Tag 1", tag.Title); } [Fact] diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index 895a0b5c3..c5b368fbc 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -137,6 +137,11 @@ public class SettingsController : BaseApiController return Ok(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()); } + /// + /// Sends a test email from the Email Service. Will not send if email service is the Default Provider + /// + /// + /// [Authorize(Policy = "RequireAdminRole")] [HttpPost("test-email-url")] public async Task> TestEmailServiceUrl(TestEmailDto dto) @@ -278,7 +283,17 @@ public class SettingsController : BaseApiController if (setting.Key == ServerSettingKey.HostName && updateSettingsDto.HostName + string.Empty != setting.Value) { setting.Value = (updateSettingsDto.HostName + string.Empty).Trim(); - if (setting.Value.EndsWith('/')) setting.Value = setting.Value.Substring(0, setting.Value.Length - 1); + setting.Value = UrlHelper.RemoveEndingSlash(setting.Value); + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailServiceUrl && updateSettingsDto.EmailServiceUrl + string.Empty != setting.Value) + { + setting.Value = string.IsNullOrEmpty(updateSettingsDto.EmailServiceUrl) ? EmailService.DefaultApiUrl : updateSettingsDto.EmailServiceUrl; + setting.Value = UrlHelper.RemoveEndingSlash(setting.Value); + FlurlHttp.ConfigureClient(setting.Value, cli => + cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + _unitOfWork.SettingsRepository.Update(setting); } @@ -333,15 +348,6 @@ public class SettingsController : BaseApiController _unitOfWork.SettingsRepository.Update(setting); } - if (setting.Key == ServerSettingKey.EmailServiceUrl && updateSettingsDto.EmailServiceUrl + string.Empty != setting.Value) - { - setting.Value = string.IsNullOrEmpty(updateSettingsDto.EmailServiceUrl) ? EmailService.DefaultApiUrl : updateSettingsDto.EmailServiceUrl; - FlurlHttp.ConfigureClient(setting.Value, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); - - _unitOfWork.SettingsRepository.Update(setting); - } - if (setting.Key == ServerSettingKey.EnableFolderWatching && updateSettingsDto.EnableFolderWatching + string.Empty != setting.Value) { setting.Value = updateSettingsDto.EnableFolderWatching + string.Empty; diff --git a/API/Services/AccountService.cs b/API/Services/AccountService.cs index 17f5e8110..8e1ede54d 100644 --- a/API/Services/AccountService.cs +++ b/API/Services/AccountService.cs @@ -74,8 +74,8 @@ public class AccountService : IAccountService } } - if (withHost) return $"{basePart}/registration/{routePart}?token={HttpUtility.UrlEncode(token)}&email={HttpUtility.UrlEncode(email)}"; - return $"registration/{routePart}?token={HttpUtility.UrlEncode(token)}&email={HttpUtility.UrlEncode(email)}"; + if (withHost) return $"{basePart}/registration/{routePart}?token={HttpUtility.UrlEncode(token)}&email={HttpUtility.UrlEncode(email)}".Replace("//", "/"); + return $"registration/{routePart}?token={HttpUtility.UrlEncode(token)}&email={HttpUtility.UrlEncode(email)}".Replace("//", "/"); } public async Task> ChangeUserPassword(AppUser user, string newPassword) diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 81c73a59e..434862027 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -50,6 +50,13 @@ public class SeriesService : ISeriesService private readonly IScrobblingService _scrobblingService; private readonly ILocalizationService _localizationService; + private readonly NextExpectedChapterDto _emptyExpectedChapter = new NextExpectedChapterDto() + { + ExpectedDate = null, + ChapterNumber = 0, + VolumeNumber = 0 + }; + public SeriesService(IUnitOfWork unitOfWork, IEventHub eventHub, ITaskScheduler taskScheduler, ILogger logger, IScrobblingService scrobblingService, ILocalizationService localizationService) { @@ -59,6 +66,7 @@ public class SeriesService : ISeriesService _logger = logger; _scrobblingService = scrobblingService; _localizationService = localizationService; + } /// @@ -657,12 +665,7 @@ public class SeriesService : ISeriesService } if (series?.Metadata.PublicationStatus is not (PublicationStatus.OnGoing or PublicationStatus.Ended) || series.Library.Type == LibraryType.Book) { - return new NextExpectedChapterDto() - { - ExpectedDate = null, - ChapterNumber = 0, - VolumeNumber = 0 - }; + return _emptyExpectedChapter; } var chapters = _unitOfWork.ChapterRepository.GetChaptersForSeries(seriesId) @@ -671,15 +674,6 @@ public class SeriesService : ISeriesService .ToList(); // Calculate the time differences between consecutive chapters - // var timeDifferences = chapters - // .Select((chapter, index) => new - // { - // ChapterNumber = chapter.Number, - // VolumeNumber = chapter.Volume.Number, - // TimeDifference = index == 0 ? TimeSpan.Zero : (chapter.CreatedUtc - chapters.ElementAt(index - 1).CreatedUtc) - // }) - // .ToList(); - // Quantize time differences: Chapters created within an hour from each other will be treated as one time delta var timeDifferences = new List(); DateTime? previousChapterTime = null; foreach (var chapter in chapters) @@ -688,30 +682,73 @@ public class SeriesService : ISeriesService { continue; // Skip this chapter if it's within an hour of the previous one } - timeDifferences.Add(chapter.CreatedUtc - previousChapterTime ?? TimeSpan.Zero); + + if ((chapter.CreatedUtc - previousChapterTime ?? TimeSpan.Zero) != TimeSpan.Zero) + { + timeDifferences.Add(chapter.CreatedUtc - previousChapterTime ?? TimeSpan.Zero); + } + previousChapterTime = chapter.CreatedUtc; } - // Calculate the average time difference between chapters - // var averageTimeDifference = timeDifferences - // .Average(td => td.TimeDifference.TotalDays); - var averageTimeDifference = timeDifferences - .Average(td => td.TotalDays); + + if (timeDifferences.Count < 3) + { + return _emptyExpectedChapter; + } + + var historicalTimeDifferences = timeDifferences.Select(td => td.TotalDays).ToList(); + + if (historicalTimeDifferences.Count < 3) + { + return _emptyExpectedChapter; + } + + const double alpha = 0.2; // A smaller alpha will give more weight to recent data, while a larger alpha will smooth the data more. + var forecastedTimeDifference = ExponentialSmoothing(historicalTimeDifferences, alpha); + + if (forecastedTimeDifference <= 0) + { + return _emptyExpectedChapter; + } // Calculate the forecast for when the next chapter is expected var nextChapterExpected = chapters.Any() - ? chapters.Max(c => c.CreatedUtc) + TimeSpan.FromDays(averageTimeDifference) - : (DateTime?) null; + ? chapters.Max(c => c.CreatedUtc) + TimeSpan.FromDays(forecastedTimeDifference) + : (DateTime?)null; - if (nextChapterExpected != null && nextChapterExpected < DateTime.UtcNow) - { - nextChapterExpected = DateTime.UtcNow + TimeSpan.FromDays(averageTimeDifference); - } + // if (nextChapterExpected != null && nextChapterExpected < DateTime.UtcNow) + // { + // nextChapterExpected = DateTime.UtcNow + TimeSpan.FromDays(forecastedTimeDifference); + // } + // + // var averageTimeDifference = timeDifferences + // .Average(td => td.TotalDays); + // + // + // if (averageTimeDifference == 0) + // { + // return _emptyExpectedChapter; + // } + // + // + // // Calculate the forecast for when the next chapter is expected + // var nextChapterExpected = chapters.Any() + // ? chapters.Max(c => c.CreatedUtc) + TimeSpan.FromDays(averageTimeDifference) + // : (DateTime?) null; + // + // if (nextChapterExpected != null && nextChapterExpected < DateTime.UtcNow) + // { + // nextChapterExpected = DateTime.UtcNow + TimeSpan.FromDays(averageTimeDifference); + // } - var lastChapter = chapters.Last(); + // For number and volume number, we need the highest chapter, not the latest created + var lastChapter = chapters.MaxBy(c => float.Parse(c.Number))!; float.TryParse(lastChapter.Number, NumberStyles.Number, CultureInfo.InvariantCulture, out var lastChapterNumber); + var lastVolumeNum = chapters.Select(c => c.Volume.Number).Max(); + var result = new NextExpectedChapterDto() { ChapterNumber = 0, @@ -737,7 +774,7 @@ public class SeriesService : ISeriesService } else { - result.VolumeNumber = lastChapter.Volume.Number + 1; + result.VolumeNumber = lastVolumeNum + 1; result.Title = await _localizationService.Translate(userId, "volume-num", new object[] {result.VolumeNumber}); } @@ -745,4 +782,16 @@ public class SeriesService : ISeriesService return result; } + + private double ExponentialSmoothing(IEnumerable data, double alpha) + { + double forecast = data.First(); + + foreach (var value in data) + { + forecast = alpha * value + (1 - alpha) * forecast; + } + + return forecast; + } } diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index 6ce24d951..c30a396e1 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -291,10 +291,15 @@ public class ProcessSeries : IProcessSeries var maxVolume = series.Volumes.Max(v => (int) Parser.Parser.MaxNumberFromRange(v.Name)); var maxChapter = chapters.Max(c => (int) Parser.Parser.MaxNumberFromRange(c.Range)); - var maxActual = Math.Max(maxVolume, maxChapter); - - series.Metadata.MaxCount = maxActual; + if (maxChapter > series.Metadata.TotalCount && maxVolume <= series.Metadata.TotalCount) + { + series.Metadata.MaxCount = maxVolume; + } + else + { + series.Metadata.MaxCount = maxChapter; + } if (!series.Metadata.PublicationStatusLocked) { diff --git a/API/Services/Tasks/SiteThemeService.cs b/API/Services/Tasks/SiteThemeService.cs index 92b084e23..40017f0ef 100644 --- a/API/Services/Tasks/SiteThemeService.cs +++ b/API/Services/Tasks/SiteThemeService.cs @@ -56,7 +56,8 @@ public class ThemeService : IThemeService var reservedNames = Seed.DefaultThemes.Select(t => t.NormalizedName).ToList(); var themeFiles = _directoryService .GetFilesWithExtension(Scanner.Parser.Parser.NormalizePath(_directoryService.SiteThemeDirectory), @"\.css") - .Where(name => !reservedNames.Contains(name.ToNormalized())).ToList(); + .Where(name => !reservedNames.Contains(name.ToNormalized()) && !name.Contains(" ")) + .ToList(); var allThemes = (await _unitOfWork.SiteThemeRepository.GetThemes()).ToList(); diff --git a/Kavita.Common/Helpers/UrlHelper.cs b/Kavita.Common/Helpers/UrlHelper.cs index 320c0346c..847d37184 100644 --- a/Kavita.Common/Helpers/UrlHelper.cs +++ b/Kavita.Common/Helpers/UrlHelper.cs @@ -38,4 +38,11 @@ public static class UrlHelper ? $"/{url}" : url; } + + public static string? RemoveEndingSlash(string? url) + { + if (string.IsNullOrEmpty(url)) return url; + if (url.EndsWith('/')) return url.Substring(0, url.Length - 1); + return url; + } } diff --git a/UI/Web/src/app/app.component.ts b/UI/Web/src/app/app.component.ts index 356866bad..a23635277 100644 --- a/UI/Web/src/app/app.component.ts +++ b/UI/Web/src/app/app.component.ts @@ -1,11 +1,11 @@ import {Component, DestroyRef, HostListener, inject, Inject, OnInit} from '@angular/core'; -import { NavigationStart, Router, RouterOutlet } from '@angular/router'; +import {NavigationStart, Router, RouterOutlet} from '@angular/router'; import {map, shareReplay, take} from 'rxjs/operators'; import { AccountService } from './_services/account.service'; import { LibraryService } from './_services/library.service'; import { NavService } from './_services/nav.service'; import { filter } from 'rxjs/operators'; -import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap'; +import {NgbModal, NgbOffcanvas, NgbRatingConfig} from '@ng-bootstrap/ng-bootstrap'; import { DOCUMENT, NgClass, NgIf, AsyncPipe } from '@angular/common'; import { Observable } from 'rxjs'; import {ThemeService} from "./_services/theme.service"; @@ -24,7 +24,8 @@ export class AppComponent implements OnInit { transitionState$!: Observable; - destroyRef = inject(DestroyRef); + private readonly destroyRef = inject(DestroyRef); + private readonly offcanvas = inject(NgbOffcanvas); constructor(private accountService: AccountService, public navService: NavService, private libraryService: LibraryService, @@ -37,13 +38,30 @@ export class AppComponent implements OnInit { // Close any open modals when a route change occurs router.events - .pipe(filter(event => event instanceof NavigationStart), takeUntilDestroyed(this.destroyRef)) - .subscribe((event) => { + .pipe( + filter(event => event instanceof NavigationStart), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(async (event) => { + + if (!this.ngbModal.hasOpenModals() && !this.offcanvas.hasOpenOffcanvas()) return; + if (this.ngbModal.hasOpenModals()) { this.ngbModal.dismissAll(); } + + if (this.offcanvas.hasOpenOffcanvas()) { + this.offcanvas.dismiss(); + } + + if ((event as any).navigationTrigger === 'popstate') { + const currentRoute = this.router.routerState; + await this.router.navigateByUrl(currentRoute.snapshot.url, { skipLocationChange: true }); + } + }); + this.transitionState$ = this.accountService.currentUser$.pipe(map((user) => { if (!user) return false; return user.preferences.noTransitions; diff --git a/UI/Web/src/app/cards/card-item/card-item.component.ts b/UI/Web/src/app/cards/card-item/card-item.component.ts index c7546d529..9d71c00ae 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.ts +++ b/UI/Web/src/app/cards/card-item/card-item.component.ts @@ -5,7 +5,7 @@ import { EventEmitter, HostListener, inject, - Input, + Input, NgZone, OnInit, Output } from '@angular/core'; @@ -39,10 +39,11 @@ import {MangaFormatIconPipe} from "../../pipe/manga-format-icon.pipe"; import {SentenceCasePipe} from "../../pipe/sentence-case.pipe"; import {CommonModule} from "@angular/common"; import {RouterLink} from "@angular/router"; -import {translate, TranslocoModule} from "@ngneat/transloco"; +import {translate, TranslocoModule, TranslocoService} from "@ngneat/transloco"; import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component"; import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter"; import {UtcToLocalTimePipe} from "../../pipe/utc-to-local-time.pipe"; +import {TimeAgoPipe} from "../../pipe/time-ago.pipe"; @Component({ selector: 'app-card-item', @@ -159,6 +160,8 @@ export class CardItemComponent implements OnInit { private user: User | undefined; private readonly destroyRef = inject(DestroyRef); + private readonly ngZone = inject(NgZone); + private readonly translocoService = inject(TranslocoService); get MangaFormat(): typeof MangaFormat { return MangaFormat; @@ -221,17 +224,12 @@ export class CardItemComponent implements OnInit { this.imageUrl = ''; const nextDate = (this.entity as NextExpectedChapter); - // if (nextDate.volumeNumber > 0 && nextDate.chapterNumber === 0) { - // this.overlayInformation = 'Volume ' + nextDate.volumeNumber; - // - // } else { - // this.overlayInformation = 'Chapter ' + nextDate.chapterNumber; - // } this.overlayInformation = nextDate.title; this.centerOverlay = true; if (nextDate.expectedDate) { const utcPipe = new UtcToLocalTimePipe(); + //const timeUntilPipe = new TimeAgoPipe(this.cdRef, this.ngZone, this.translocoService); this.title = utcPipe.transform(nextDate.expectedDate); } diff --git a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts index 798fcc205..fe8f55275 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts @@ -200,18 +200,22 @@ export class MetadataFilterComponent implements OnInit { limitTo: new FormControl(this.filterV2?.limitTo || 0, []), name: new FormControl(this.filterV2?.name || '', []) }); + if (this.filterSettings?.presetsV2?.sortOptions) { + this.isAscendingSort = this.filterSettings?.presetsV2?.sortOptions!.isAscending; + } + this.sortGroup.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { - if (this.filterV2?.sortOptions === null) { - this.filterV2.sortOptions = { - isAscending: this.isAscendingSort, - sortField: parseInt(this.sortGroup.get('sortField')?.value, 10) - }; - } - this.filterV2!.sortOptions!.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10); - this.filterV2!.limitTo = Math.max(parseInt(this.sortGroup.get('limitTo')?.value || '0', 10), 0); - this.filterV2!.name = this.sortGroup.get('name')?.value || ''; - this.cdRef.markForCheck(); + if (this.filterV2?.sortOptions === null) { + this.filterV2.sortOptions = { + isAscending: this.isAscendingSort, + sortField: parseInt(this.sortGroup.get('sortField')?.value, 10) + }; + } + this.filterV2!.sortOptions!.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10); + this.filterV2!.limitTo = Math.max(parseInt(this.sortGroup.get('limitTo')?.value || '0', 10), 0); + this.filterV2!.name = this.sortGroup.get('name')?.value || ''; + this.cdRef.markForCheck(); }); this.fullyLoaded = true; @@ -230,6 +234,7 @@ export class MetadataFilterComponent implements OnInit { } this.filterV2!.sortOptions!.isAscending = this.isAscendingSort; + this.cdRef.markForCheck(); } clear() { diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts index 0b0006873..30c5eadc7 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts @@ -92,6 +92,7 @@ import { SeriesPreviewDrawerComponent } from "../../../_single-module/series-preview-drawer/series-preview-drawer.component"; import {PublicationStatus} from "../../../_models/metadata/publication-status"; +import {NextExpectedChapter} from "../../../_models/series-detail/next-expected-chapter"; interface RelatedSeriesPair { series: Series; @@ -167,7 +168,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { seriesImage: string = ''; downloadInProgress: boolean = false; - nextExpectedChapter: any | undefined; + nextExpectedChapter: NextExpectedChapter | undefined; /** * Track by function for Volume to tell when to refresh card data diff --git a/openapi.json b/openapi.json index 24cb9c861..263b009e3 100644 --- a/openapi.json +++ b/openapi.json @@ -7,7 +7,7 @@ "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.7.9.0" + "version": "0.7.9.2" }, "servers": [ { @@ -9713,7 +9713,9 @@ "tags": [ "Settings" ], + "summary": "Sends a test email from the Email Service. Will not send if email service is the Default Provider", "requestBody": { + "description": "", "content": { "application/json": { "schema": {