diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 11a50d614..db0b134ef 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -491,4 +491,59 @@ public class ReadingListController : BaseApiController if (string.IsNullOrEmpty(name)) return true; return Ok(await _unitOfWork.ReadingListRepository.ReadingListExists(name)); } + + + + /// + /// Promote/UnPromote multiple reading lists in one go. Will only update the authenticated user's reading lists and will only work if the user has promotion role + /// + /// + /// + [HttpPost("promote-multiple")] + public async Task PromoteMultipleReadingLists(PromoteReadingListsDto dto) + { + // This needs to take into account owner as I can select other users cards + var userId = User.GetUserId(); + if (!User.IsInRole(PolicyConstants.PromoteRole) && !User.IsInRole(PolicyConstants.AdminRole)) + { + return BadRequest(await _localizationService.Translate(userId, "permission-denied")); + } + + var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListsByIds(dto.ReadingListIds); + + foreach (var readingList in readingLists) + { + if (readingList.AppUserId != userId) continue; + readingList.Promoted = dto.Promoted; + _unitOfWork.ReadingListRepository.Update(readingList); + } + + if (!_unitOfWork.HasChanges()) return Ok(); + await _unitOfWork.CommitAsync(); + + return Ok(); + } + + + /// + /// Delete multiple reading lists in one go + /// + /// + /// + [HttpPost("delete-multiple")] + public async Task DeleteMultipleReadingLists(DeleteReadingListsDto dto) + { + // This needs to take into account owner as I can select other users cards + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.ReadingLists); + if (user == null) return Unauthorized(); + + user.ReadingLists = user.ReadingLists.Where(uc => !dto.ReadingListIds.Contains(uc.Id)).ToList(); + _unitOfWork.UserRepository.Update(user); + + + if (!_unitOfWork.HasChanges()) return Ok(); + await _unitOfWork.CommitAsync(); + + return Ok(); + } } diff --git a/API/DTOs/ReadingLists/DeleteReadingListsDto.cs b/API/DTOs/ReadingLists/DeleteReadingListsDto.cs new file mode 100644 index 000000000..8417f8132 --- /dev/null +++ b/API/DTOs/ReadingLists/DeleteReadingListsDto.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs.ReadingLists; + +public class DeleteReadingListsDto +{ + [Required] + public IList ReadingListIds { get; set; } +} diff --git a/API/DTOs/ReadingLists/PromoteReadingListsDto.cs b/API/DTOs/ReadingLists/PromoteReadingListsDto.cs new file mode 100644 index 000000000..f64bbb5ca --- /dev/null +++ b/API/DTOs/ReadingLists/PromoteReadingListsDto.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace API.DTOs.ReadingLists; + +public class PromoteReadingListsDto +{ + public IList ReadingListIds { get; init; } + public bool Promoted { get; init; } +} diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index c04328116..a1d2d754e 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -49,6 +49,7 @@ public interface IReadingListRepository Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat); Task RemoveReadingListsWithoutSeries(); Task GetReadingListByTitleAsync(string name, int userId, ReadingListIncludes includes = ReadingListIncludes.Items); + Task> GetReadingListsByIds(IList ids, ReadingListIncludes includes = ReadingListIncludes.Items); } public class ReadingListRepository : IReadingListRepository @@ -156,6 +157,15 @@ public class ReadingListRepository : IReadingListRepository .FirstOrDefaultAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized) && x.AppUserId == userId); } + public async Task> GetReadingListsByIds(IList ids, ReadingListIncludes includes = ReadingListIncludes.Items) + { + return await _context.ReadingList + .Where(c => ids.Contains(c.Id)) + .Includes(includes) + .AsSplitQuery() + .ToListAsync(); + } + public void Remove(ReadingListItem item) { _context.ReadingListItem.Remove(item); diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index ecd2ee604..8ee9deef5 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -550,7 +550,7 @@ export class ActionFactoryService { } ], }, - // RBS will handle rendering this, so non-admins with download are appicable + // RBS will handle rendering this, so non-admins with download are applicable { action: Action.Download, title: 'download', @@ -583,6 +583,20 @@ export class ActionFactoryService { class: 'danger', children: [], }, + { + action: Action.Promote, + title: 'promote', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + }, + { + action: Action.UnPromote, + title: 'unpromote', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + }, ]; this.bookmarkActions = [ diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 22b8d862d..938b70315 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -24,6 +24,7 @@ import {UserCollection} from "../_models/collection-tag"; import {CollectionTagService} from "./collection-tag.service"; import {SmartFilter} from "../_models/metadata/v2/smart-filter"; import {FilterService} from "./filter.service"; +import {ReadingListService} from "./reading-list.service"; export type LibraryActionCallback = (library: Partial) => void; export type SeriesActionCallback = (series: Series) => void; @@ -48,7 +49,8 @@ export class ActionService implements OnDestroy { constructor(private libraryService: LibraryService, private seriesService: SeriesService, private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal, private confirmService: ConfirmService, private memberService: MemberService, private deviceService: DeviceService, - private readonly collectionTagService: CollectionTagService, private filterService: FilterService) { } + private readonly collectionTagService: CollectionTagService, private filterService: FilterService, + private readonly readingListService: ReadingListService) { } ngOnDestroy() { this.onDestroy.next(); @@ -386,7 +388,7 @@ export class ActionService implements OnDestroy { } /** - * Mark all series as Unread. + * Mark all collections as promoted/unpromoted. * @param collections UserCollection, should have id, pagesRead populated * @param promoted boolean, promoted state * @param callback Optional callback to perform actions after API completes @@ -422,6 +424,43 @@ export class ActionService implements OnDestroy { }); } + /** + * Mark all reading lists as promoted/unpromoted. + * @param readingLists UserCollection, should have id, pagesRead populated + * @param promoted boolean, promoted state + * @param callback Optional callback to perform actions after API completes + */ + promoteMultipleReadingLists(readingLists: Array, promoted: boolean, callback?: BooleanActionCallback) { + this.readingListService.promoteMultipleReadingLists(readingLists.map(v => v.id), promoted).pipe(take(1)).subscribe(() => { + if (promoted) { + this.toastr.success(translate('toasts.reading-list-promoted')); + } else { + this.toastr.success(translate('toasts.reading-list-unpromoted')); + } + + if (callback) { + callback(true); + } + }); + } + + /** + * Deletes multiple collections + * @param readingLists ReadingList, should have id + * @param callback Optional callback to perform actions after API completes + */ + async deleteMultipleReadingLists(readingLists: Array, callback?: BooleanActionCallback) { + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-reading-list'))) return; + + this.readingListService.deleteMultipleReadingLists(readingLists.map(v => v.id)).pipe(take(1)).subscribe(() => { + this.toastr.success(translate('toasts.reading-lists-deleted')); + + if (callback) { + callback(true); + } + }); + } + addMultipleToReadingList(seriesId: number, volumes: Array, chapters?: Array, callback?: BooleanActionCallback) { if (this.readingListModalRef != null) { return; } this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' }); diff --git a/UI/Web/src/app/_services/reading-list.service.ts b/UI/Web/src/app/_services/reading-list.service.ts index 4789fe67d..7afe5fa3c 100644 --- a/UI/Web/src/app/_services/reading-list.service.ts +++ b/UI/Web/src/app/_services/reading-list.service.ts @@ -8,7 +8,7 @@ import { PaginatedResult } from '../_models/pagination'; import { ReadingList, ReadingListItem } from '../_models/reading-list'; import { CblImportSummary } from '../_models/reading-list/cbl/cbl-import-summary'; import { TextResonse } from '../_types/text-response'; -import { ActionItem } from './action-factory.service'; +import {Action, ActionItem} from './action-factory.service'; @Injectable({ providedIn: 'root' @@ -87,9 +87,15 @@ export class ReadingListService { return this.httpClient.post(this.baseUrl + 'readinglist/remove-read?readingListId=' + readingListId, {}, TextResonse); } - actionListFilter(action: ActionItem, readingList: ReadingList, isAdmin: boolean) { - if (readingList?.promoted && !isAdmin) return false; + actionListFilter(action: ActionItem, readingList: ReadingList, canPromote: boolean) { + + const isPromotionAction = action.action == Action.Promote || action.action == Action.UnPromote; + + if (isPromotionAction) return canPromote; return true; + + // if (readingList?.promoted && !isAdmin) return false; + // return true; } nameExists(name: string) { @@ -107,4 +113,14 @@ export class ReadingListService { getCharacters(readingListId: number) { return this.httpClient.get>(this.baseUrl + 'readinglist/characters?readingListId=' + readingListId); } + + promoteMultipleReadingLists(listIds: Array, promoted: boolean) { + return this.httpClient.post(this.baseUrl + 'readinglist/promote-multiple', {readingListIds: listIds, promoted}, TextResonse); + } + + deleteMultipleReadingLists(listIds: Array) { + return this.httpClient.post(this.baseUrl + 'readinglist/delete-multiple', {readingListIds: listIds}, TextResonse); + } + + } diff --git a/UI/Web/src/app/cards/bulk-selection.service.ts b/UI/Web/src/app/cards/bulk-selection.service.ts index e1d625329..0572847ed 100644 --- a/UI/Web/src/app/cards/bulk-selection.service.ts +++ b/UI/Web/src/app/cards/bulk-selection.service.ts @@ -4,7 +4,7 @@ import {ReplaySubject} from 'rxjs'; import {filter} from 'rxjs/operators'; import {Action, ActionFactoryService, ActionItem} from '../_services/action-factory.service'; -type DataSource = 'volume' | 'chapter' | 'special' | 'series' | 'bookmark' | 'sideNavStream' | 'collection'; +type DataSource = 'volume' | 'chapter' | 'special' | 'series' | 'bookmark' | 'sideNavStream' | 'collection' | 'readingList'; /** * Responsible for handling selections on cards. Can handle multiple card sources next to each other in different loops. @@ -159,6 +159,10 @@ export class BulkSelectionService { return this.applyFilterToList(this.actionFactory.getCollectionTagActions(callback), [Action.Promote, Action.UnPromote, Action.Delete]); } + if (Object.keys(this.selectedCards).filter(item => item === 'readingList').length > 0) { + return this.applyFilterToList(this.actionFactory.getReadingListActions(callback), [Action.Promote, Action.UnPromote, Action.Delete]); + } + return this.applyFilterToList(this.actionFactory.getVolumeActions(callback), allowedActions); } diff --git a/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html b/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html index 4143e9c4a..7ec5a843b 100644 --- a/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html +++ b/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html @@ -4,6 +4,7 @@
{{t('item-count', {num: collections.length | number})}}
+ {{t('title')}} -
{{t('item-count', {num: pagination.totalItems | number})}}
+ @if (pagination) { +
{{t('item-count', {num: pagination.totalItems | number})}}
+ } + + + (clicked)="handleClick(item)" + (selection)="bulkSelectionService.handleCardSelection('readingList', position, lists.length, $event)" + [selected]="bulkSelectionService.isCardSelected('readingList', position)" [allowSelection]="true"> diff --git a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.ts b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.ts index 4fb54d7f5..8fb7d7410 100644 --- a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.ts +++ b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.ts @@ -1,4 +1,4 @@ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core'; +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, HostListener, inject, OnInit} from '@angular/core'; import { Router } from '@angular/router'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; @@ -21,6 +21,12 @@ import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component"; import {Title} from "@angular/platform-browser"; import {WikiLink} from "../../../_models/wiki"; +import {BulkSelectionService} from "../../../cards/bulk-selection.service"; +import {BulkOperationsComponent} from "../../../cards/bulk-operations/bulk-operations.component"; +import {KEY_CODES} from "../../../shared/_services/utility.service"; +import {UserCollection} from "../../../_models/collection-tag"; +import {User} from "../../../_models/user"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; @Component({ selector: 'app-reading-lists', @@ -28,30 +34,51 @@ import {WikiLink} from "../../../_models/wiki"; styleUrls: ['./reading-lists.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgIf, CardDetailLayoutComponent, CardItemComponent, DecimalPipe, TranslocoDirective] + imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgIf, CardDetailLayoutComponent, CardItemComponent, DecimalPipe, TranslocoDirective, BulkOperationsComponent] }) export class ReadingListsComponent implements OnInit { + public readonly bulkSelectionService = inject(BulkSelectionService); + public readonly actionService = inject(ActionService); + protected readonly WikiLink = WikiLink; lists: ReadingList[] = []; loadingLists = false; pagination!: Pagination; isAdmin: boolean = false; + hasPromote: boolean = false; jumpbarKeys: Array = []; actions: {[key: number]: Array>} = {}; globalActions: Array> = [{action: Action.Import, title: 'import-cbl', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)}]; trackByIdentity = (index: number, item: ReadingList) => `${item.id}_${item.title}`; - translocoService = inject(TranslocoService); + @HostListener('document:keydown.shift', ['$event']) + handleKeypress(event: KeyboardEvent) { + if (event.key === KEY_CODES.SHIFT) { + this.bulkSelectionService.isShiftDown = true; + } + } + + @HostListener('document:keyup.shift', ['$event']) + handleKeyUp(event: KeyboardEvent) { + if (event.key === KEY_CODES.SHIFT) { + this.bulkSelectionService.isShiftDown = false; + } + } + constructor(private readingListService: ReadingListService, public imageService: ImageService, private actionFactoryService: ActionFactoryService, - private accountService: AccountService, private toastr: ToastrService, private router: Router, private actionService: ActionService, + private accountService: AccountService, private toastr: ToastrService, private router: Router, private jumpbarService: JumpbarService, private readonly cdRef: ChangeDetectorRef, private ngbModal: NgbModal, private titleService: Title) { } ngOnInit(): void { this.accountService.currentUser$.pipe(take(1)).subscribe(user => { if (user) { this.isAdmin = this.accountService.hasAdminRole(user); + this.hasPromote = this.accountService.hasPromoteRole(user); + + this.cdRef.markForCheck(); + this.loadPage(); this.titleService.setTitle('Kavita - ' + translate('side-nav.reading-lists')); } @@ -59,8 +86,10 @@ export class ReadingListsComponent implements OnInit { } getActions(readingList: ReadingList) { + const d = this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this)) + .filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin || this.hasPromote)); return this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this)) - .filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin)); + .filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin || this.hasPromote)); } performAction(action: ActionItem, readingList: ReadingList) { @@ -85,7 +114,7 @@ export class ReadingListsComponent implements OnInit { switch(action.action) { case Action.Delete: this.readingListService.delete(readingList.id).subscribe(() => { - this.toastr.success(this.translocoService.translate('toasts.reading-list-deleted')); + this.toastr.success(translate('toasts.reading-list-deleted')); this.loadPage(); }); break; @@ -126,4 +155,33 @@ export class ReadingListsComponent implements OnInit { handleClick(list: ReadingList) { this.router.navigateByUrl('lists/' + list.id); } + + bulkActionCallback = (action: ActionItem, data: any) => { + const selectedReadingListIndexies = this.bulkSelectionService.getSelectedCardsForSource('readingList'); + const selectedReadingLists = this.lists.filter((col, index: number) => selectedReadingListIndexies.includes(index + '')); + + switch (action.action) { + case Action.Promote: + this.actionService.promoteMultipleReadingLists(selectedReadingLists, true, (success) => { + if (!success) return; + this.bulkSelectionService.deselectAll(); + this.loadPage(); + }); + break; + case Action.UnPromote: + this.actionService.promoteMultipleReadingLists(selectedReadingLists, false, (success) => { + if (!success) return; + this.bulkSelectionService.deselectAll(); + this.loadPage(); + }); + break; + case Action.Delete: + this.actionService.deleteMultipleReadingLists(selectedReadingLists, (successful) => { + if (!successful) return; + this.loadPage(); + this.bulkSelectionService.deselectAll(); + }); + break; + } + } } diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 30f860801..da092a9b0 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -2134,8 +2134,10 @@ "no-series-collection-warning": "Warning! No series are selected, saving will delete the Collection. Are you sure you want to continue?", "collection-updated": "Collection updated", "reading-list-deleted": "Reading list deleted", + "reading-lists-deleted": "Reading lists deleted", "reading-list-updated": "Reading list updated", "confirm-delete-reading-list": "Are you sure you want to delete the reading list? This cannot be undone.", + "confirm-delete-reading-lists": "Are you sure you want to delete the reading lists? This cannot be undone.", "item-removed": "Item removed", "nothing-to-remove": "Nothing to remove", "series-added-to-reading-list": "Series added to reading list", @@ -2209,6 +2211,8 @@ "collection-not-owned": "You do not own this collection", "collections-promoted": "Collections promoted", "collections-unpromoted": "Collections un-promoted", + "reading-lists-promoted": "Reading Lists promoted", + "reading-lists-unpromoted": "Reading Lists un-promoted", "confirm-delete-collections": "Are you sure you want to delete multiple collections?", "collections-deleted": "Collections deleted", "pdf-book-mode-screen-size": "Screen too small for Book mode", diff --git a/openapi.json b/openapi.json index 11956030d..891190de4 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.1", "info": { "title": "Kavita", - "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.1.18", + "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.1.19", "license": { "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" @@ -7150,6 +7150,72 @@ } } }, + "/api/ReadingList/promote-multiple": { + "post": { + "tags": [ + "ReadingList" + ], + "summary": "Promote/UnPromote multiple reading lists in one go. Will only update the authenticated user's reading lists and will only work if the user has promotion role", + "requestBody": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PromoteReadingListsDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PromoteReadingListsDto" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/PromoteReadingListsDto" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/ReadingList/delete-multiple": { + "post": { + "tags": [ + "ReadingList" + ], + "summary": "Delete multiple reading lists in one go", + "requestBody": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteReadingListsDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/DeleteReadingListsDto" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/DeleteReadingListsDto" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/api/Recommended/quick-reads": { "get": { "tags": [ @@ -15836,6 +15902,22 @@ }, "additionalProperties": false }, + "DeleteReadingListsDto": { + "required": [ + "readingListIds" + ], + "type": "object", + "properties": { + "readingListIds": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + }, + "additionalProperties": false + }, "DeleteSeriesDto": { "type": "object", "properties": { @@ -17991,6 +18073,23 @@ }, "additionalProperties": false }, + "PromoteReadingListsDto": { + "type": "object", + "properties": { + "readingListIds": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + }, + "nullable": true + }, + "promoted": { + "type": "boolean" + } + }, + "additionalProperties": false + }, "PublicationStatusStatCount": { "type": "object", "properties": { @@ -22251,4 +22350,628 @@ "description": "Responsible for all things Want To Read" } ] +}"UserDtoICount": { + "type": "object", + "properties": { + "value": { + "$ref": "#/components/schemas/UserDto" + }, + "count": { + "type": "integer", + "format": "int64" + } + }, + "additionalProperties": false + }, + "UserPreferencesDto": { + "required": [ + "autoCloseMenu", + "backgroundColor", + "blurUnreadSummaries", + "bookReaderFontFamily", + "bookReaderFontSize", + "bookReaderImmersiveMode", + "bookReaderLayoutMode", + "bookReaderLineSpacing", + "bookReaderMargin", + "bookReaderReadingDirection", + "bookReaderTapToPaginate", + "bookReaderThemeName", + "bookReaderWritingStyle", + "collapseSeriesRelationships", + "emulateBook", + "globalPageLayoutMode", + "layoutMode", + "locale", + "noTransitions", + "pageSplitOption", + "pdfLayoutMode", + "pdfScrollMode", + "pdfSpreadMode", + "pdfTheme", + "promptForDownloadSize", + "readerMode", + "readingDirection", + "scalingOption", + "shareReviews", + "showScreenHints", + "swipeToPaginate", + "theme" + ], + "type": "object", + "properties": { + "readingDirection": { + "enum": [ + 0, + 1 + ], + "type": "integer", + "description": "Manga Reader Option: What direction should the next/prev page buttons go", + "format": "int32" + }, + "scalingOption": { + "enum": [ + 0, + 1, + 2, + 3 + ], + "type": "integer", + "description": "Manga Reader Option: How should the image be scaled to screen", + "format": "int32" + }, + "pageSplitOption": { + "enum": [ + 0, + 1, + 2, + 3 + ], + "type": "integer", + "description": "Manga Reader Option: Which side of a split image should we show first", + "format": "int32" + }, + "readerMode": { + "enum": [ + 0, + 1, + 2 + ], + "type": "integer", + "description": "Manga Reader Option: How the manga reader should perform paging or reading of the file\r\n\r\nWebtoon uses scrolling to page, LeftRight uses paging by clicking left/right side of reader, UpDown uses paging\r\nby clicking top/bottom sides of reader.\r\n", + "format": "int32" + }, + "layoutMode": { + "enum": [ + 1, + 2, + 3 + ], + "type": "integer", + "description": "Manga Reader Option: How many pages to display in the reader at once", + "format": "int32" + }, + "emulateBook": { + "type": "boolean", + "description": "Manga Reader Option: Emulate a book by applying a shadow effect on the pages" + }, + "backgroundColor": { + "minLength": 1, + "type": "string", + "description": "Manga Reader Option: Background color of the reader" + }, + "swipeToPaginate": { + "type": "boolean", + "description": "Manga Reader Option: Should swiping trigger pagination" + }, + "autoCloseMenu": { + "type": "boolean", + "description": "Manga Reader Option: Allow the menu to close after 6 seconds without interaction" + }, + "showScreenHints": { + "type": "boolean", + "description": "Manga Reader Option: Show screen hints to the user on some actions, ie) pagination direction change" + }, + "bookReaderMargin": { + "type": "integer", + "description": "Book Reader Option: Override extra Margin", + "format": "int32" + }, + "bookReaderLineSpacing": { + "type": "integer", + "description": "Book Reader Option: Override line-height", + "format": "int32" + }, + "bookReaderFontSize": { + "type": "integer", + "description": "Book Reader Option: Override font size", + "format": "int32" + }, + "bookReaderFontFamily": { + "minLength": 1, + "type": "string", + "description": "Book Reader Option: Maps to the default Kavita font-family (inherit) or an override" + }, + "bookReaderTapToPaginate": { + "type": "boolean", + "description": "Book Reader Option: Allows tapping on side of screens to paginate" + }, + "bookReaderReadingDirection": { + "enum": [ + 0, + 1 + ], + "type": "integer", + "description": "Book Reader Option: What direction should the next/prev page buttons go", + "format": "int32" + }, + "bookReaderWritingStyle": { + "enum": [ + 0, + 1 + ], + "type": "integer", + "description": "Book Reader Option: What writing style should be used, horizontal or vertical.", + "format": "int32" + }, + "theme": { + "$ref": "#/components/schemas/SiteThemeDto" + }, + "bookReaderThemeName": { + "minLength": 1, + "type": "string" + }, + "bookReaderLayoutMode": { + "enum": [ + 0, + 1, + 2 + ], + "type": "integer", + "format": "int32" + }, + "bookReaderImmersiveMode": { + "type": "boolean", + "description": "Book Reader Option: A flag that hides the menu-ing system behind a click on the screen. This should be used with tap to paginate, but the app doesn't enforce this." + }, + "globalPageLayoutMode": { + "enum": [ + 0, + 1 + ], + "type": "integer", + "description": "Global Site Option: If the UI should layout items as Cards or List items", + "format": "int32" + }, + "blurUnreadSummaries": { + "type": "boolean", + "description": "UI Site Global Setting: If unread summaries should be blurred until expanded or unless user has read it already" + }, + "promptForDownloadSize": { + "type": "boolean", + "description": "UI Site Global Setting: Should Kavita prompt user to confirm downloads that are greater than 100 MB." + }, + "noTransitions": { + "type": "boolean", + "description": "UI Site Global Setting: Should Kavita disable CSS transitions" + }, + "collapseSeriesRelationships": { + "type": "boolean", + "description": "When showing series, only parent series or series with no relationships will be returned" + }, + "shareReviews": { + "type": "boolean", + "description": "UI Site Global Setting: Should series reviews be shared with all users in the server" + }, + "locale": { + "minLength": 1, + "type": "string", + "description": "UI Site Global Setting: The language locale that should be used for the user" + }, + "pdfTheme": { + "enum": [ + 0, + 1 + ], + "type": "integer", + "description": "PDF Reader: Theme of the Reader", + "format": "int32" + }, + "pdfScrollMode": { + "enum": [ + 0, + 1, + 3 + ], + "type": "integer", + "description": "PDF Reader: Scroll mode of the reader", + "format": "int32" + }, + "pdfLayoutMode": { + "enum": [ + 0, + 2 + ], + "type": "integer", + "description": "PDF Reader: Layout Mode of the reader", + "format": "int32" + }, + "pdfSpreadMode": { + "enum": [ + 0, + 1, + 2 + ], + "type": "integer", + "description": "PDF Reader: Spread Mode of the reader", + "format": "int32" + } + }, + "additionalProperties": false + }, + "UserReadStatistics": { + "type": "object", + "properties": { + "totalPagesRead": { + "type": "integer", + "description": "Total number of pages read", + "format": "int64" + }, + "totalWordsRead": { + "type": "integer", + "description": "Total number of words read", + "format": "int64" + }, + "timeSpentReading": { + "type": "integer", + "description": "Total time spent reading based on estimates", + "format": "int64" + }, + "chaptersRead": { + "type": "integer", + "format": "int64" + }, + "lastActive": { + "type": "string", + "format": "date-time" + }, + "avgHoursPerWeekSpentReading": { + "type": "number", + "format": "double" + }, + "percentReadPerLibrary": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SingleStatCount" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "UserReviewDto": { + "type": "object", + "properties": { + "tagline": { + "type": "string", + "description": "A tagline for the review", + "nullable": true + }, + "body": { + "type": "string", + "description": "The main review", + "nullable": true + }, + "bodyJustText": { + "type": "string", + "description": "The main body with just text, for review preview", + "nullable": true + }, + "seriesId": { + "type": "integer", + "description": "The series this is for", + "format": "int32" + }, + "libraryId": { + "type": "integer", + "description": "The library this series belongs in", + "format": "int32" + }, + "username": { + "type": "string", + "description": "The user who wrote this", + "nullable": true + }, + "totalVotes": { + "type": "integer", + "format": "int32" + }, + "rating": { + "type": "number", + "format": "float" + }, + "rawBody": { + "type": "string", + "nullable": true + }, + "score": { + "type": "integer", + "description": "How many upvotes this review has gotten", + "format": "int32" + }, + "siteUrl": { + "type": "string", + "description": "If External, the url of the review", + "nullable": true + }, + "isExternal": { + "type": "boolean", + "description": "Does this review come from an external Source" + }, + "provider": { + "enum": [ + 0, + 1, + 2 + ], + "type": "integer", + "description": "If this review is External, which Provider did it come from", + "format": "int32" + } + }, + "additionalProperties": false, + "description": "Represents a User Review for a given Series" + }, + "Volume": { + "required": [ + "maxNumber", + "minNumber", + "name" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "description": "A String representation of the volume number. Allows for floats. Can also include a range (1-2).", + "nullable": true + }, + "lookupName": { + "type": "string", + "description": "This is just the original Parsed volume number for lookups", + "nullable": true + }, + "number": { + "type": "integer", + "description": "The minimum number in the Name field in Int form", + "format": "int32", + "deprecated": true + }, + "minNumber": { + "type": "number", + "description": "The minimum number in the Name field", + "format": "float" + }, + "maxNumber": { + "type": "number", + "description": "The maximum number in the Name field (same as Minimum if Name isn't a range)", + "format": "float" + }, + "chapters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Chapter" + }, + "nullable": true + }, + "created": { + "type": "string", + "format": "date-time" + }, + "lastModified": { + "type": "string", + "format": "date-time" + }, + "createdUtc": { + "type": "string", + "format": "date-time" + }, + "lastModifiedUtc": { + "type": "string", + "format": "date-time" + }, + "coverImage": { + "type": "string", + "description": "Absolute path to the (managed) image file", + "nullable": true + }, + "pages": { + "type": "integer", + "description": "Total pages of all chapters in this volume", + "format": "int32" + }, + "wordCount": { + "type": "integer", + "description": "Total Word count of all chapters in this volume.", + "format": "int64" + }, + "minHoursToRead": { + "type": "integer", + "format": "int32" + }, + "maxHoursToRead": { + "type": "integer", + "format": "int32" + }, + "avgHoursToRead": { + "type": "integer", + "format": "int32" + }, + "series": { + "$ref": "#/components/schemas/Series" + }, + "seriesId": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, + "VolumeDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "minNumber": { + "type": "number", + "format": "float" + }, + "maxNumber": { + "type": "number", + "format": "float" + }, + "name": { + "type": "string", + "nullable": true + }, + "number": { + "type": "integer", + "description": "This will map to MinNumber. Number was removed in v0.7.13.8/v0.7.14", + "format": "int32", + "deprecated": true + }, + "pages": { + "type": "integer", + "format": "int32" + }, + "pagesRead": { + "type": "integer", + "format": "int32" + }, + "lastModifiedUtc": { + "type": "string", + "format": "date-time" + }, + "createdUtc": { + "type": "string", + "format": "date-time" + }, + "created": { + "type": "string", + "description": "When chapter was created in local server time", + "format": "date-time" + }, + "lastModified": { + "type": "string", + "description": "When chapter was last modified in local server time", + "format": "date-time" + }, + "seriesId": { + "type": "integer", + "format": "int32" + }, + "chapters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChapterDto" + }, + "nullable": true + }, + "minHoursToRead": { + "type": "integer", + "format": "int32" + }, + "maxHoursToRead": { + "type": "integer", + "format": "int32" + }, + "avgHoursToRead": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + } + }, + "securitySchemes": { + "Bearer": { + "type": "apiKey", + "description": "Please insert JWT with Bearer into field", + "name": "Authorization", + "in": "header" + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ], + "tags": [ + { + "name": "Account", + "description": "All Account matters" + }, + { + "name": "Cbl", + "description": "Responsible for the CBL import flow" + }, + { + "name": "Collection", + "description": "APIs for Collections" + }, + { + "name": "Device", + "description": "Responsible interacting and creating Devices" + }, + { + "name": "Download", + "description": "All APIs related to downloading entities from the system. Requires Download Role or Admin Role." + }, + { + "name": "Filter", + "description": "This is responsible for Filter caching" + }, + { + "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" + }, + { + "name": "Reader", + "description": "For all things regarding reading, mainly focusing on non-Book related entities" + }, + { + "name": "Search", + "description": "Responsible for the Search interface from the UI" + }, + { + "name": "Stream", + "description": "Responsible for anything that deals with Streams (SmartFilters, ExternalSource, DashboardStream, SideNavStream)" + }, + { + "name": "Tachiyomi", + "description": "All APIs are for Tachiyomi extension and app. They have hacks for our implementation and should not be used for any\r\nother purposes." + }, + { + "name": "Upload", + "description": "" + }, + { + "name": "WantToRead", + "description": "Responsible for all things Want To Read" + } + ] } \ No newline at end of file