diff --git a/API.Tests/Helpers/SmartFilterHelperTests.cs b/API.Tests/Helpers/SmartFilterHelperTests.cs index 510748821..b557ecd27 100644 --- a/API.Tests/Helpers/SmartFilterHelperTests.cs +++ b/API.Tests/Helpers/SmartFilterHelperTests.cs @@ -56,7 +56,7 @@ public class SmartFilterHelperTests }; var encodedFilter = SmartFilterHelper.Encode(filter); - Assert.Equal("name=Test&stmts=comparison%253D0%252Cfield%253D4%252Cvalue%253D0&sortOptions=sortField%3D2%26isAscending%3DFalse&limitTo=10&combination=1", encodedFilter); + Assert.Equal("name=Test&stmts=comparison%253D0%252Cfield%253D4%252Cvalue%253D0&sortOptions=sortField%3D2%2CisAscending%3DFalse&limitTo=10&combination=1", encodedFilter); } private void AssertStatementSame(FilterStatementDto statement, FilterField field, FilterComparison combination, string value) diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 80cf05009..ab8d46edd 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -412,14 +412,15 @@ public class LibraryController : BaseApiController [HttpPost("update")] public async Task UpdateLibrary(UpdateLibraryDto dto) { + var userId = User.GetUserId(); var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(dto.Id, LibraryIncludes.Folders); - if (library == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "library-doesnt-exist")); + if (library == null) return BadRequest(await _localizationService.Translate(userId, "library-doesnt-exist")); var newName = dto.Name.Trim(); if (await _unitOfWork.LibraryRepository.LibraryExists(newName) && !library.Name.Equals(newName)) - return BadRequest(await _localizationService.Translate(User.GetUserId(), "library-name-exists")); + return BadRequest(await _localizationService.Translate(userId, "library-name-exists")); - var originalFolders = library.Folders.Select(x => x.Path).ToList(); + var originalFoldersCount = library.Folders.Count; library.Name = newName; library.Folders = dto.Folders.Select(s => new FolderPath() {Path = s}).Distinct().ToList(); @@ -445,8 +446,8 @@ public class LibraryController : BaseApiController _unitOfWork.LibraryRepository.Update(library); - if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library-update")); - if (originalFolders.Count != dto.Folders.Count() || typeUpdate) + if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(userId, "generic-library-update")); + if (originalFoldersCount != dto.Folders.Count() || typeUpdate) { await _libraryWatcher.RestartWatching(); _taskScheduler.ScanLibrary(library.Id); @@ -458,8 +459,9 @@ public class LibraryController : BaseApiController } await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, MessageFactory.LibraryModifiedEvent(library.Id, "update"), false); + await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate, - MessageFactory.SideNavUpdateEvent(User.GetUserId()), false); + MessageFactory.SideNavUpdateEvent(userId), false); await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey); diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 92597f903..f0c527d47 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -1079,10 +1079,11 @@ public class OpdsController : BaseApiController /// /// /// + /// Optional parameter. Can pass false and progress saving will be suppressed /// [HttpGet("{apiKey}/image")] public async Task GetPageStreamedImage(string apiKey, [FromQuery] int libraryId, [FromQuery] int seriesId, - [FromQuery] int volumeId,[FromQuery] int chapterId, [FromQuery] int pageNumber) + [FromQuery] int volumeId,[FromQuery] int chapterId, [FromQuery] int pageNumber, [FromQuery] bool saveProgress = true) { var userId = await GetUser(apiKey); if (pageNumber < 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "Page")); @@ -1101,15 +1102,20 @@ public class OpdsController : BaseApiController // Calculates SHA1 Hash for byte[] Response.AddCacheHeader(content); - // Save progress for the user - await _readerService.SaveReadingProgress(new ProgressDto() + // Save progress for the user (except Panels, they will use a direct connection) + var userAgent = Request.Headers["User-Agent"].ToString(); + if (!userAgent.StartsWith("Panels", StringComparison.InvariantCultureIgnoreCase) || !saveProgress) { - ChapterId = chapterId, - PageNum = pageNumber, - SeriesId = seriesId, - VolumeId = volumeId, - LibraryId =libraryId - }, await GetUser(apiKey)); + await _readerService.SaveReadingProgress(new ProgressDto() + { + ChapterId = chapterId, + PageNum = pageNumber, + SeriesId = seriesId, + VolumeId = volumeId, + LibraryId =libraryId + }, await GetUser(apiKey)); + } + return File(content, MimeTypeMap.GetMimeType(format)); } diff --git a/API/Controllers/PanelsController.cs b/API/Controllers/PanelsController.cs new file mode 100644 index 000000000..dc4d1b00a --- /dev/null +++ b/API/Controllers/PanelsController.cs @@ -0,0 +1,55 @@ +using System.Threading.Tasks; +using API.Data; +using API.DTOs; +using API.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +/// +/// For the Panels app explicitly +/// +[AllowAnonymous] +public class PanelsController : BaseApiController +{ + private readonly IReaderService _readerService; + private readonly IUnitOfWork _unitOfWork; + + public PanelsController(IReaderService readerService, IUnitOfWork unitOfWork) + { + _readerService = readerService; + _unitOfWork = unitOfWork; + } + + /// + /// Saves the progress of a given chapter. + /// + /// + /// + /// + [HttpPost("save-progress")] + public async Task SaveProgress(ProgressDto dto, [FromQuery] string apiKey) + { + if (string.IsNullOrEmpty(apiKey)) return Unauthorized("ApiKey is required"); + var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); + await _readerService.SaveReadingProgress(dto, userId); + return Ok(); + } + + /// + /// Gets the Progress of a given chapter + /// + /// + /// + /// The number of pages read, 0 if none read + [HttpGet("get-progress")] + public async Task> GetProgress(int chapterId, [FromQuery] string apiKey) + { + if (string.IsNullOrEmpty(apiKey)) return Unauthorized("ApiKey is required"); + var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); + + var progress = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(chapterId, userId); + return Ok(progress?.PageNum ?? 0); + } +} diff --git a/API/Helpers/SmartFilterHelper.cs b/API/Helpers/SmartFilterHelper.cs index 30b66c3ee..740b8cd4e 100644 --- a/API/Helpers/SmartFilterHelper.cs +++ b/API/Helpers/SmartFilterHelper.cs @@ -72,7 +72,7 @@ public static class SmartFilterHelper private static string EncodeSortOptions(SortOptions sortOptions) { - return Uri.EscapeDataString($"sortField={(int) sortOptions.SortField}&isAscending={sortOptions.IsAscending}"); + return Uri.EscapeDataString($"sortField={(int) sortOptions.SortField},isAscending={sortOptions.IsAscending}"); } private static string EncodeFilterStatementDtos(ICollection statements) diff --git a/UI/Web/src/app/app.component.ts b/UI/Web/src/app/app.component.ts index a23635277..f93ea8416 100644 --- a/UI/Web/src/app/app.component.ts +++ b/UI/Web/src/app/app.component.ts @@ -1,4 +1,4 @@ -import {Component, DestroyRef, HostListener, inject, Inject, OnInit} from '@angular/core'; +import {ChangeDetectorRef, Component, DestroyRef, HostListener, inject, Inject, OnInit} from '@angular/core'; import {NavigationStart, Router, RouterOutlet} from '@angular/router'; import {map, shareReplay, take} from 'rxjs/operators'; import { AccountService } from './_services/account.service'; @@ -26,8 +26,10 @@ export class AppComponent implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly offcanvas = inject(NgbOffcanvas); + public readonly navService = inject(NavService); + public readonly cdRef = inject(ChangeDetectorRef); - constructor(private accountService: AccountService, public navService: NavService, + constructor(private accountService: AccountService, private libraryService: LibraryService, private router: Router, private ngbModal: NgbModal, ratingConfig: NgbRatingConfig, @Inject(DOCUMENT) private document: Document, private themeService: ThemeService) { @@ -88,7 +90,7 @@ export class AppComponent implements OnInit { if (user) { // Bootstrap anything that's needed this.themeService.getThemes().subscribe(); - this.libraryService.getLibraryNames().pipe(take(1), shareReplay()).subscribe(); + this.libraryService.getLibraryNames().pipe(take(1), shareReplay({refCount: true, bufferSize: 1})).subscribe(); } } } 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 fe8f55275..c5107e4f2 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts @@ -121,37 +121,37 @@ export class MetadataFilterComponent implements OnInit { this.loadFromPresetsAndSetup(); } - loadSavedFilter(event: Select2UpdateEvent) { - // Load the filter from the backend and update the screen - if (event.value === undefined || typeof(event.value) === 'string') return; - const smartFilter = event.value as SmartFilter; - this.filterV2 = this.filterUtilitiesService.decodeSeriesFilter(smartFilter.filter); - this.cdRef.markForCheck(); - console.log('update event: ', event); - } - - createFilterValue(event: Select2AutoCreateEvent) { - // Create a new name and filter - if (!this.filterV2) return; - this.filterV2.name = event.value; - this.filterService.saveFilter(this.filterV2).subscribe(() => { - - const item = { - value: { - filter: this.filterUtilitiesService.encodeSeriesFilter(this.filterV2!), - name: event.value, - } as SmartFilter, - label: event.value - }; - this.smartFilters.push(item); - this.sortGroup.get('name')?.setValue(item); - this.cdRef.markForCheck(); - this.toastr.success(translate('toasts.smart-filter-updated')); - this.apply(); - }); - - console.log('create event: ', event); - } + // loadSavedFilter(event: Select2UpdateEvent) { + // // Load the filter from the backend and update the screen + // if (event.value === undefined || typeof(event.value) === 'string') return; + // const smartFilter = event.value as SmartFilter; + // this.filterV2 = this.filterUtilitiesService.decodeSeriesFilter(smartFilter.filter); + // this.cdRef.markForCheck(); + // console.log('update event: ', event); + // } + // + // createFilterValue(event: Select2AutoCreateEvent) { + // // Create a new name and filter + // if (!this.filterV2) return; + // this.filterV2.name = event.value; + // this.filterService.saveFilter(this.filterV2).subscribe(() => { + // + // const item = { + // value: { + // filter: this.filterUtilitiesService.encodeSeriesFilter(this.filterV2!), + // name: event.value, + // } as SmartFilter, + // label: event.value + // }; + // this.smartFilters.push(item); + // this.sortGroup.get('name')?.setValue(item); + // this.cdRef.markForCheck(); + // this.toastr.success(translate('toasts.smart-filter-updated')); + // this.apply(); + // }); + // + // console.log('create event: ', event); + // } close() { diff --git a/UI/Web/src/app/shared/_services/filter-utilities.service.ts b/UI/Web/src/app/shared/_services/filter-utilities.service.ts index 1dc62124d..2b5bb647d 100644 --- a/UI/Web/src/app/shared/_services/filter-utilities.service.ts +++ b/UI/Web/src/app/shared/_services/filter-utilities.service.ts @@ -209,9 +209,9 @@ export class FilterUtilitiesService { } decodeFilterStatements(encodedStatements: string): FilterStatement[] { - const statementStrings = decodeURIComponent(encodedStatements).split(','); + const statementStrings = decodeURIComponent(encodedStatements).split(',').map(s => decodeURIComponent(s)); return statementStrings.map(statementString => { - const parts = statementString.split('&'); + const parts = statementString.split(','); if (parts === null || parts.length < 3) return null; const comparisonStartToken = parts.find(part => part.startsWith('comparison=')); diff --git a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html index c6f6b70d0..1b0ec5e85 100644 --- a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html +++ b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html @@ -22,7 +22,7 @@ + [icon]="getLibraryTypeIcon(navStream.library!.type)" [imageUrl]="getLibraryImage(navStream.library!)" [title]="navStream.library!.name" [comparisonMethod]="'startsWith'"> diff --git a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts index de32b2f0a..fa75fed2f 100644 --- a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts +++ b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts @@ -8,7 +8,7 @@ import { } from '@angular/core'; import { NavigationEnd, Router } from '@angular/router'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import {distinctUntilChanged, filter, map, shareReplay, take, tap} from 'rxjs/operators'; +import {distinctUntilChanged, filter, map, take, tap} from 'rxjs/operators'; import { ImportCblModalComponent } from 'src/app/reading-list/_modals/import-cbl-modal/import-cbl-modal.component'; import { ImageService } from 'src/app/_services/image.service'; import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service'; @@ -17,7 +17,6 @@ import { Library, LibraryType } from '../../../_models/library'; import { AccountService } from '../../../_services/account.service'; import { Action, ActionFactoryService, ActionItem } from '../../../_services/action-factory.service'; import { ActionService } from '../../../_services/action.service'; -import { LibraryService } from '../../../_services/library.service'; import { NavService } from '../../../_services/nav.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {BehaviorSubject, merge, Observable, of, ReplaySubject, startWith, switchMap} from "rxjs"; @@ -56,7 +55,18 @@ export class SideNavComponent implements OnInit { } showAll: boolean = false; totalSize = 0; + protected readonly SideNavStreamType = SideNavStreamType; + private readonly router = inject(Router); + private readonly utilityService = inject(UtilityService); + private readonly messageHub = inject(MessageHubService); + private readonly actionService = inject(ActionService); + public readonly navService = inject(NavService); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly ngbModal = inject(NgbModal); + private readonly imageService = inject(ImageService); + public readonly accountService = inject(AccountService); + private showAllSubject = new BehaviorSubject(false); showAll$ = this.showAllSubject.asObservable(); @@ -111,25 +121,22 @@ export class SideNavComponent implements OnInit { takeUntilDestroyed(this.destroyRef), ); + collapseSideNavOnMobileNav$ = this.router.events.pipe( + filter(event => event instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef), + map(evt => evt as NavigationEnd), + filter(() => this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet), + switchMap(() => this.navService.sideNavCollapsed$), + take(1), + filter(collapsed => !collapsed) + ); - constructor( - public utilityService: UtilityService, private messageHub: MessageHubService, - private actionService: ActionService, - public navService: NavService, private router: Router, private readonly cdRef: ChangeDetectorRef, - private ngbModal: NgbModal, private imageService: ImageService, public readonly accountService: AccountService) { - this.router.events.pipe( - filter(event => event instanceof NavigationEnd), - takeUntilDestroyed(this.destroyRef), - map(evt => evt as NavigationEnd), - filter(() => this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet), - switchMap(() => this.navService.sideNavCollapsed$), - take(1), - filter(collapsed => !collapsed) - ).subscribe(() => { + constructor() { + this.collapseSideNavOnMobileNav$.subscribe(() => { this.navService.toggleSideNav(); this.cdRef.markForCheck(); - }); + }); } ngOnInit(): void { diff --git a/openapi.json b/openapi.json index 4c339ed08..ebd36449a 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.4" + "version": "0.7.10.0" }, "servers": [ { @@ -4137,6 +4137,15 @@ "type": "integer", "format": "int32" } + }, + { + "name": "saveProgress", + "in": "query", + "description": "Optional parameter. Can pass false and progress saving will be suppressed", + "schema": { + "type": "boolean", + "default": true + } } ], "responses": { @@ -4168,6 +4177,101 @@ } } }, + "/api/Panels/save-progress": { + "post": { + "tags": [ + "Panels" + ], + "summary": "Saves the progress of a given chapter.", + "parameters": [ + { + "name": "apiKey", + "in": "query", + "description": "", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProgressDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProgressDto" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ProgressDto" + } + } + } + }, + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/Panels/get-progress": { + "get": { + "tags": [ + "Panels" + ], + "summary": "Gets the Progress of a given chapter", + "parameters": [ + { + "name": "chapterId", + "in": "query", + "description": "", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "apiKey", + "in": "query", + "description": "", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "type": "integer", + "format": "int32" + } + }, + "application/json": { + "schema": { + "type": "integer", + "format": "int32" + } + }, + "text/json": { + "schema": { + "type": "integer", + "format": "int32" + } + } + } + } + } + } + }, "/api/Plugin/authenticate": { "post": { "tags": [ @@ -19687,6 +19791,10 @@ "name": "Image", "description": "Responsible for servicing up images stored in Kavita for entities" }, + { + "name": "Panels", + "description": "For the Panels app explicitly" + }, { "name": "Rating", "description": "Responsible for providing external ratings for Series"