diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs index 016b31f0f..fbe895317 100644 --- a/API.Tests/Services/ReaderServiceTests.cs +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -1152,6 +1152,56 @@ public class ReaderServiceTests #region GetContinuePoint + [Fact] + public async Task GetContinuePoint_ShouldReturnFirstVolume_NoProgress() + { + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("95", false, new List(), 1), + EntityFactory.CreateChapter("96", false, new List(), 1), + }), + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List(), 1), + EntityFactory.CreateChapter("2", false, new List(), 1), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("21", false, new List(), 1), + EntityFactory.CreateChapter("22", false, new List(), 1), + }), + EntityFactory.CreateVolume("3", new List() + { + EntityFactory.CreateChapter("31", false, new List(), 1), + EntityFactory.CreateChapter("32", false, new List(), 1), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + + var nextChapter = await readerService.GetContinuePoint(1, 1); + + Assert.Equal("1", nextChapter.Range); + } + [Fact] public async Task GetContinuePoint_ShouldReturnFirstNonSpecial() { diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs index d91564cff..c8655eaba 100644 --- a/API.Tests/Services/SeriesServiceTests.cs +++ b/API.Tests/Services/SeriesServiceTests.cs @@ -201,8 +201,8 @@ public class SeriesServiceTests Assert.Equal(6, detail.Chapters.Count()); Assert.NotEmpty(detail.Volumes); - Assert.Equal(3, detail.Volumes.Count()); // This returns 3 because 0 volume will still come - Assert.All(detail.Volumes, dto => Assert.Contains(dto.Name, new[] {"0", "2", "3"})); + Assert.Equal(2, detail.Volumes.Count()); // Volume 0 shouldn't be sent in Volumes + Assert.All(detail.Volumes, dto => Assert.Contains(dto.Name, new[] {"2", "3"})); } [Fact] @@ -239,10 +239,11 @@ public class SeriesServiceTests var detail = await _seriesService.GetSeriesDetail(1, 1); Assert.NotEmpty(detail.Chapters); - Assert.Equal(3, detail.Chapters.Count()); // volume 2 has a 0 chapter aka a single chapter that is represented as a volume. We don't show in Chapters area + // volume 2 has a 0 chapter aka a single chapter that is represented as a volume. We don't show in Chapters area + Assert.Equal(3, detail.Chapters.Count()); Assert.NotEmpty(detail.Volumes); - Assert.Equal(3, detail.Volumes.Count()); + Assert.Equal(2, detail.Volumes.Count()); } [Fact] @@ -279,6 +280,46 @@ public class SeriesServiceTests Assert.Equal(2, detail.Volumes.Count()); } + [Fact] + public async Task SeriesDetail_WhenBookLibrary_ShouldReturnVolumesAndSpecial() + { + await ResetDb(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Book, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("Ano Orokamono ni mo Kyakkou wo! - Volume 1.epub", true, new List()), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("Ano Orokamono ni mo Kyakkou wo! - Volume 2.epub", false, new List()), + }), + } + }); + + await _context.SaveChangesAsync(); + + var detail = await _seriesService.GetSeriesDetail(1, 1); + Assert.NotEmpty(detail.Volumes); + Assert.Equal("2 - Ano Orokamono ni mo Kyakkou wo! - Volume 2", detail.Volumes.ElementAt(0).Name); + + Assert.NotEmpty(detail.Specials); + Assert.Equal("Ano Orokamono ni mo Kyakkou wo! - Volume 1.epub", detail.Specials.ElementAt(0).Range); + + // A book library where all books are Volumes, will show no "chapters" on the UI because it doesn't make sense + Assert.Empty(detail.Chapters); + + Assert.Equal(1, detail.Volumes.Count()); + } + [Fact] public async Task SeriesDetail_ShouldSortVolumesByName() { diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 34a7e47b8..83cdc1a04 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -49,6 +49,15 @@ namespace API.Controllers return Ok(items); } + [HttpGet("lists-for-series")] + public async Task>> GetListsForSeries(int seriesId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + var items = await _unitOfWork.ReadingListRepository.GetReadingListDtosForSeriesAndUserAsync(userId, seriesId, true); + + return Ok(items); + } + /// /// Fetches all reading list items for a given list including rich metadata around series, volume, chapters, and progress /// diff --git a/API/DTOs/SeriesDto.cs b/API/DTOs/SeriesDto.cs index fc70ce5ed..18d706a2e 100644 --- a/API/DTOs/SeriesDto.cs +++ b/API/DTOs/SeriesDto.cs @@ -18,6 +18,10 @@ namespace API.DTOs /// public int PagesRead { get; set; } /// + /// DateTime representing last time the series was Read. Calculated at API-time. + /// + public DateTime LatestReadDate { get; set; } + /// /// Rating from logged in user. Calculated at API-time. /// public int UserRating { get; set; } diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index 329ec47a8..ca21bf26c 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -19,6 +19,9 @@ public interface IReadingListRepository Task> AddReadingProgressModifiers(int userId, IList items); Task GetReadingListDtoByTitleAsync(string title); Task> GetReadingListItemsByIdAsync(int readingListId); + + Task> GetReadingListDtosForSeriesAndUserAsync(int userId, int seriesId, + bool includePromoted); void Remove(ReadingListItem item); void BulkRemove(IEnumerable items); void Update(ReadingList list); @@ -62,6 +65,18 @@ public class ReadingListRepository : IReadingListRepository return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); } + public async Task> GetReadingListDtosForSeriesAndUserAsync(int userId, int seriesId, bool includePromoted) + { + var query = _context.ReadingList + .Where(l => l.AppUserId == userId || (includePromoted && l.Promoted )) + .Where(l => l.Items.Any(i => i.SeriesId == seriesId)) + .OrderBy(l => l.LastModified) + .ProjectTo(_mapper.ConfigurationProvider) + .AsNoTracking(); + + return await query.ToListAsync(); + } + public async Task GetReadingListByIdAsync(int readingListId) { return await _context.ReadingList diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 0c8caa2e1..1d72040d9 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -469,6 +469,7 @@ public class SeriesRepository : ISeriesRepository if (rating == null) continue; s.UserRating = rating.Rating; s.UserReview = rating.Review; + s.LatestReadDate = userProgress.Max(p => p.LastModified); } } diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 422d98e66..714639813 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading.Tasks; using API.Comparators; @@ -217,14 +218,34 @@ public class SeriesService : ISeriesService var chapters = volumes.SelectMany(v => v.Chapters).ToList(); // For books, the Name of the Volume is remapped to the actual name of the book, rather than Volume number. + var processedVolumes = new List(); if (libraryType == LibraryType.Book) { foreach (var volume in volumes) { var firstChapter = volume.Chapters.First(); - if (!string.IsNullOrEmpty(firstChapter.TitleName)) volume.Name += $" - {firstChapter.TitleName}"; + // On Books, skip volumes that are specials, since these will be shown + if (firstChapter.IsSpecial) continue; + if (string.IsNullOrEmpty(firstChapter.TitleName)) + { + if (!firstChapter.Range.Equals(Parser.Parser.DefaultVolume)) + { + var title = Path.GetFileNameWithoutExtension(firstChapter.Range); + if (string.IsNullOrEmpty(title)) continue; + volume.Name += $" - {title}"; + } + } + else + { + volume.Name += $" - {firstChapter.TitleName}"; + } + processedVolumes.Add(volume); } } + else + { + processedVolumes = volumes.Where(v => v.Number > 0).ToList(); + } var specials = new List(); @@ -233,14 +254,26 @@ public class SeriesService : ISeriesService chapter.Title = Parser.Parser.CleanSpecialTitle(chapter.Title); specials.Add(chapter); } + + // Don't show chapter 0 (aka single volume chapters) in the Chapters tab or books that are just single numbers (they show as volumes) + IEnumerable retChapters; + if (libraryType == LibraryType.Book) + { + retChapters = Array.Empty(); + } else + { + retChapters = chapters + .Where(ShouldIncludeChapter) + .OrderBy(c => float.Parse(c.Number), new ChapterSortComparer()); + } + + + return new SeriesDetailDto() { Specials = specials, - // Don't show chapter 0 (aka single volume chapters) in the Chapters tab or books that are just single numbers (they show as volumes) - Chapters = chapters - .Where(ShouldIncludeChapter) - .OrderBy(c => float.Parse(c.Number), new ChapterSortComparer()), - Volumes = volumes, + Chapters = retChapters, + Volumes = processedVolumes, StorylineChapters = volumes .Where(v => v.Number == 0) .SelectMany(v => v.Chapters) diff --git a/UI/Web/src/app/_interceptors/error.interceptor.ts b/UI/Web/src/app/_interceptors/error.interceptor.ts index 2e4b50acd..a0b792045 100644 --- a/UI/Web/src/app/_interceptors/error.interceptor.ts +++ b/UI/Web/src/app/_interceptors/error.interceptor.ts @@ -1,4 +1,4 @@ -import { Injectable, OnDestroy } from '@angular/core'; +import { Injectable } from '@angular/core'; import { HttpRequest, HttpHandler, @@ -8,14 +8,12 @@ import { import { Observable, throwError } from 'rxjs'; import { Router } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; -import { catchError, take } from 'rxjs/operators'; +import { catchError } from 'rxjs/operators'; import { AccountService } from '../_services/account.service'; -import { environment } from 'src/environments/environment'; @Injectable() export class ErrorInterceptor implements HttpInterceptor { - public urlKey: string = 'kavita--no-connection-url'; constructor(private router: Router, private toastr: ToastrService, private accountService: AccountService) {} @@ -44,12 +42,6 @@ export class ErrorInterceptor implements HttpInterceptor { if (this.toastr.previousToastMessage !== 'Something unexpected went wrong.') { this.toastr.error('Something unexpected went wrong.'); } - - // If we are not on no-connection, redirect there and save current url so when we refersh, we redirect back there - // if (this.router.url !== '/no-connection') { - // localStorage.setItem(this.urlKey, this.router.url); - // this.router.navigateByUrl('/no-connection'); - // } break; } return throwError(error); @@ -126,8 +118,7 @@ export class ErrorInterceptor implements HttpInterceptor { private handleAuthError(error: any) { // NOTE: Signin has error.error or error.statusText available. // if statement is due to http/2 spec issue: https://github.com/angular/angular/issues/23334 - this.accountService.currentUser$.pipe(take(1)).subscribe(user => { - this.accountService.logout(); - }); + this.accountService.logout(); + this.router.navigateByUrl('/login'); } } diff --git a/UI/Web/src/app/_models/config-data.ts b/UI/Web/src/app/_models/config-data.ts index 360fc45b1..2e8dc7842 100644 --- a/UI/Web/src/app/_models/config-data.ts +++ b/UI/Web/src/app/_models/config-data.ts @@ -1,10 +1,10 @@ /** * This is for base url only. Not to be used my applicaiton, only loading and bootstrapping app */ -export class ConfigData { - baseUrl: string = '/'; +// export class ConfigData { +// baseUrl: string = '/'; - constructor(baseUrl: string) { - this.baseUrl = baseUrl; - } -} \ No newline at end of file +// constructor(baseUrl: string) { +// this.baseUrl = baseUrl; +// } +// } \ No newline at end of file diff --git a/UI/Web/src/app/_models/series.ts b/UI/Web/src/app/_models/series.ts index 332407105..2bed3bc08 100644 --- a/UI/Web/src/app/_models/series.ts +++ b/UI/Web/src/app/_models/series.ts @@ -4,16 +4,41 @@ import { Volume } from './volume'; export interface Series { id: number; name: string; - originalName: string; // This is not shown to user + /** + * This is not shown to user + */ + originalName: string; localizedName: string; sortName: string; coverImageLocked: boolean; volumes: Volume[]; - pages: number; // Total pages in series - pagesRead: number; // Total pages the logged in user has read - userRating: number; // User rating - userReview: string; // User review + /** + * Total pages in series + */ + pages: number; + /** + * Total pages the logged in user has read + */ + pagesRead: number; + /** + * User's rating (0-5) + */ + userRating: number; + /** + * The user's review + */ + userReview: string; libraryId: number; - created: string; // DateTime when entity was created + /** + * DateTime the entity was created + */ + created: string; + /** + * Format of the Series + */ format: MangaFormat; + /** + * DateTime that represents last time the logged in user read this series + */ + latestReadDate: string; } diff --git a/UI/Web/src/app/_services/reading-list.service.ts b/UI/Web/src/app/_services/reading-list.service.ts index 3ad5255f6..45bc86828 100644 --- a/UI/Web/src/app/_services/reading-list.service.ts +++ b/UI/Web/src/app/_services/reading-list.service.ts @@ -30,6 +30,10 @@ export class ReadingListService { ); } + getReadingListsForSeries(seriesId: number) { + return this.httpClient.get(this.baseUrl + 'readinglist/lists-for-series?seriesId=' + seriesId); + } + getListItems(readingListId: number) { return this.httpClient.get(this.baseUrl + 'readinglist/items?readingListId=' + readingListId); } @@ -47,7 +51,7 @@ export class ReadingListService { } updateByMultipleSeries(readingListId: number, seriesIds: Array) { - return this.httpClient.post(this.baseUrl + 'readinglist/update-by-multiple-series', {readingListId, seriesIds}); + return this.httpClient.post(this.baseUrl + 'readinglist/update-by-multiple-series', {readingListId, seriesIds}, { responseType: 'text' as 'json' }); } updateBySeries(readingListId: number, seriesId: number) { diff --git a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html index b8b747b45..7bda84b4b 100644 --- a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html +++ b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html @@ -20,22 +20,15 @@ - -
-
+
  Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect. Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect.
-
+
  Use debug to help identify issues. Debug can eat up a lot of disk space. Requires restart to take effect. Port the server listens on. Requires restart to take effect. diff --git a/UI/Web/src/app/app-routing.module.ts b/UI/Web/src/app/app-routing.module.ts index 14763cbb3..cebf755db 100644 --- a/UI/Web/src/app/app-routing.module.ts +++ b/UI/Web/src/app/app-routing.module.ts @@ -1,7 +1,6 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; import { LibraryDetailComponent } from './library-detail/library-detail.component'; -import { NotConnectedComponent } from './not-connected/not-connected.component'; import { SeriesDetailComponent } from './series-detail/series-detail.component'; import { RecentlyAddedComponent } from './recently-added/recently-added.component'; import { UserLoginComponent } from './user-login/user-login.component'; @@ -16,7 +15,6 @@ import { ThemeTestComponent } from './theme-test/theme-test.component'; // TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules const routes: Routes = [ - {path: '', component: UserLoginComponent}, { path: 'admin', canActivate: [AdminGuard], @@ -37,6 +35,10 @@ const routes: Routes = [ canActivate: [AuthGuard], loadChildren: () => import('./reading-list/reading-list.module').then(m => m.ReadingListModule) }, + { + path: 'registration', + loadChildren: () => import('../app/registration/registration.module').then(m => m.RegistrationModule) + }, { path: '', runGuardsAndResolvers: 'always', @@ -66,13 +68,10 @@ const routes: Routes = [ ] }, - { - path: 'registration', - loadChildren: () => import('../app/registration/registration.module').then(m => m.RegistrationModule) - }, - {path: 'login', component: UserLoginComponent}, // TODO: move this to registration module - {path: 'no-connection', component: NotConnectedComponent}, {path: 'theme', component: ThemeTestComponent}, + + {path: '', component: UserLoginComponent}, + {path: 'login', component: UserLoginComponent}, // TODO: move this to registration module {path: '**', component: UserLoginComponent, pathMatch: 'full'} ]; diff --git a/UI/Web/src/app/app.module.ts b/UI/Web/src/app/app.module.ts index 8ced2e262..8847e77d2 100644 --- a/UI/Web/src/app/app.module.ts +++ b/UI/Web/src/app/app.module.ts @@ -7,7 +7,7 @@ import { AppComponent } from './app.component'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; -import { NgbCollapseModule, NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbPopoverModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap'; +import { NgbAccordionModule, NgbCollapseModule, NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbPopoverModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap'; import { NavHeaderComponent } from './nav-header/nav-header.component'; import { JwtInterceptor } from './_interceptors/jwt.interceptor'; import { UserLoginComponent } from './user-login/user-login.component'; @@ -17,7 +17,6 @@ import { LibraryComponent } from './library/library.component'; import { SharedModule } from './shared/shared.module'; import { LibraryDetailComponent } from './library-detail/library-detail.component'; import { SeriesDetailComponent } from './series-detail/series-detail.component'; -import { NotConnectedComponent } from './not-connected/not-connected.component'; import { ReviewSeriesModalComponent } from './_modals/review-series-modal/review-series-modal.component'; import { CarouselModule } from './carousel/carousel.module'; @@ -29,7 +28,6 @@ import { CardsModule } from './cards/cards.module'; import { CollectionsModule } from './collections/collections.module'; import { ReadingListModule } from './reading-list/reading-list.module'; import { SAVER, getSaver } from './shared/_providers/saver.provider'; -import { ConfigData } from './_models/config-data'; import { NavEventsToggleComponent } from './nav-events-toggle/nav-events-toggle.component'; import { PersonRolePipe } from './_pipes/person-role.pipe'; import { SeriesMetadataDetailComponent } from './series-metadata-detail/series-metadata-detail.component'; @@ -48,7 +46,6 @@ import { ThemeTestComponent } from './theme-test/theme-test.component'; LibraryComponent, LibraryDetailComponent, SeriesDetailComponent, - NotConnectedComponent, // Move into ExtrasModule ReviewSeriesModalComponent, RecentlyAddedComponent, OnDeckComponent, @@ -85,6 +82,8 @@ import { ThemeTestComponent } from './theme-test/theme-test.component'; ReadingListModule, RegistrationModule, + NgbAccordionModule, // ThemeTest Component only + ToastrModule.forRoot({ positionClass: 'toast-bottom-right', @@ -99,7 +98,7 @@ import { ThemeTestComponent } from './theme-test/theme-test.component'; {provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true}, Title, {provide: SAVER, useFactory: getSaver}, - { provide: APP_BASE_HREF, useFactory: (config: ConfigData) => config.baseUrl, deps: [ConfigData] }, + // { provide: APP_BASE_HREF, useFactory: (config: ConfigData) => config.baseUrl, deps: [ConfigData] }, ], entryComponents: [], bootstrap: [AppComponent] diff --git a/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.html b/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.html index ed72bb89c..8f5fd60df 100644 --- a/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.html +++ b/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.html @@ -28,7 +28,7 @@