mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
Bulk Actions for Reading Lists (#3035)
This commit is contained in:
parent
1918c9305e
commit
6434ed7c9b
@ -491,4 +491,59 @@ public class ReadingListController : BaseApiController
|
||||
if (string.IsNullOrEmpty(name)) return true;
|
||||
return Ok(await _unitOfWork.ReadingListRepository.ReadingListExists(name));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("promote-multiple")]
|
||||
public async Task<ActionResult> 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();
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Delete multiple reading lists in one go
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("delete-multiple")]
|
||||
public async Task<ActionResult> 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();
|
||||
}
|
||||
}
|
||||
|
10
API/DTOs/ReadingLists/DeleteReadingListsDto.cs
Normal file
10
API/DTOs/ReadingLists/DeleteReadingListsDto.cs
Normal file
@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace API.DTOs.ReadingLists;
|
||||
|
||||
public class DeleteReadingListsDto
|
||||
{
|
||||
[Required]
|
||||
public IList<int> ReadingListIds { get; set; }
|
||||
}
|
9
API/DTOs/ReadingLists/PromoteReadingListsDto.cs
Normal file
9
API/DTOs/ReadingLists/PromoteReadingListsDto.cs
Normal file
@ -0,0 +1,9 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace API.DTOs.ReadingLists;
|
||||
|
||||
public class PromoteReadingListsDto
|
||||
{
|
||||
public IList<int> ReadingListIds { get; init; }
|
||||
public bool Promoted { get; init; }
|
||||
}
|
@ -49,6 +49,7 @@ public interface IReadingListRepository
|
||||
Task<IList<ReadingList>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
|
||||
Task<int> RemoveReadingListsWithoutSeries();
|
||||
Task<ReadingList?> GetReadingListByTitleAsync(string name, int userId, ReadingListIncludes includes = ReadingListIncludes.Items);
|
||||
Task<IEnumerable<ReadingList>> GetReadingListsByIds(IList<int> 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<IEnumerable<ReadingList>> GetReadingListsByIds(IList<int> 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);
|
||||
|
@ -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 = [
|
||||
|
@ -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<Library>) => 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<ReadingList>, 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<ReadingList>, 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<Volume>, chapters?: Array<Chapter>, callback?: BooleanActionCallback) {
|
||||
if (this.readingListModalRef != null) { return; }
|
||||
this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' });
|
||||
|
@ -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<string>(this.baseUrl + 'readinglist/remove-read?readingListId=' + readingListId, {}, TextResonse);
|
||||
}
|
||||
|
||||
actionListFilter(action: ActionItem<ReadingList>, readingList: ReadingList, isAdmin: boolean) {
|
||||
if (readingList?.promoted && !isAdmin) return false;
|
||||
actionListFilter(action: ActionItem<ReadingList>, 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<Array<Person>>(this.baseUrl + 'readinglist/characters?readingListId=' + readingListId);
|
||||
}
|
||||
|
||||
promoteMultipleReadingLists(listIds: Array<number>, promoted: boolean) {
|
||||
return this.httpClient.post(this.baseUrl + 'readinglist/promote-multiple', {readingListIds: listIds, promoted}, TextResonse);
|
||||
}
|
||||
|
||||
deleteMultipleReadingLists(listIds: Array<number>) {
|
||||
return this.httpClient.post(this.baseUrl + 'readinglist/delete-multiple', {readingListIds: listIds}, TextResonse);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -4,6 +4,7 @@
|
||||
<h6 subtitle>{{t('item-count', {num: collections.length | number})}}</h6>
|
||||
</app-side-nav-companion-bar>
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
|
||||
<app-card-detail-layout
|
||||
[isLoading]="isLoading"
|
||||
[items]="collections"
|
||||
|
@ -4,8 +4,12 @@
|
||||
<app-card-actionables [actions]="globalActions" (actionHandler)="performGlobalAction($event)"></app-card-actionables>
|
||||
<span>{{t('title')}}</span>
|
||||
</h2>
|
||||
<h6 subtitle class="subtitle-with-actionables" *ngIf="pagination">{{t('item-count', {num: pagination.totalItems | number})}}</h6>
|
||||
@if (pagination) {
|
||||
<h6 subtitle class="subtitle-with-actionables">{{t('item-count', {num: pagination.totalItems | number})}}</h6>
|
||||
}
|
||||
|
||||
</app-side-nav-companion-bar>
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
|
||||
<app-card-detail-layout
|
||||
[isLoading]="loadingLists"
|
||||
@ -18,7 +22,9 @@
|
||||
<ng-template #cardItem let-item let-position="idx" >
|
||||
<app-card-item [title]="item.title" [entity]="item" [actions]="actions[item.id]"
|
||||
[suppressLibraryLink]="true" [imageUrl]="imageService.getReadingListCoverImage(item.id)"
|
||||
(clicked)="handleClick(item)"></app-card-item>
|
||||
(clicked)="handleClick(item)"
|
||||
(selection)="bulkSelectionService.handleCardSelection('readingList', position, lists.length, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('readingList', position)" [allowSelection]="true"></app-card-item>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #noData>
|
||||
|
@ -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<JumpKey> = [];
|
||||
actions: {[key: number]: Array<ActionItem<ReadingList>>} = {};
|
||||
globalActions: Array<ActionItem<any>> = [{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: 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<any>, 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
|
725
openapi.json
725
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<example>\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</example>",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user