mirror of
https://github.com/Kareadita/Kavita.git
synced 2026-05-28 02:22:37 -04:00
More Bugs (#4516)
This commit is contained in:
@@ -30,13 +30,13 @@ jobs:
|
||||
run: dotnet restore
|
||||
|
||||
- name: Build project
|
||||
run: dotnet build API/API.csproj --configuration Debug --output ./build-output
|
||||
run: dotnet build Kavita.Server/Kavita.Server.csproj --configuration Debug --output ./build-output
|
||||
|
||||
|
||||
- name: Get Swashbuckle version
|
||||
id: swashbuckle-version
|
||||
run: |
|
||||
VERSION=$(grep -o '<PackageReference Include="Swashbuckle.AspNetCore" Version="[^"]*"' API/API.csproj | grep -o 'Version="[^"]*"' | cut -d'"' -f2)
|
||||
VERSION=$(grep -o '<PackageReference Include="Swashbuckle.AspNetCore" Version="[^"]*"' Kavita.Server/Kavita.Server.csproj | grep -o 'Version="[^"]*"' | cut -d'"' -f2)
|
||||
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Found Swashbuckle.AspNetCore version: $VERSION"
|
||||
|
||||
|
||||
@@ -78,7 +78,6 @@ public interface IUserRepository
|
||||
Task<IEnumerable<UserTokenInfo>> GetUserTokenInfo(CancellationToken ct = default);
|
||||
Task<AppUser?> GetUserByDeviceEmail(string deviceEmail, CancellationToken ct = default);
|
||||
Task<AppUser?> GetByOidcId(string? oidcId, AppUserIncludes includes = AppUserIncludes.None, CancellationToken ct = default);
|
||||
Task UpdateUserAsActive(int userId, CancellationToken ct = default);
|
||||
#endregion
|
||||
|
||||
#region Ratings & Reviews
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Kavita.API.Services;
|
||||
|
||||
public interface IActiveUserTrackerService
|
||||
{
|
||||
void RecordActive(int userId);
|
||||
Task FlushAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -46,12 +46,10 @@ public class RateLimiter(int maxRequests, TimeSpan duration, bool refillBetween
|
||||
if (timeSinceLastRefill >= duration)
|
||||
{
|
||||
_tokenBuckets[key] = (Tokens: maxRequests, LastRefill: now);
|
||||
Console.WriteLine($"Tokens Refilled to Max: {maxRequests}");
|
||||
}
|
||||
else if (tokensToAdd > 0 && refillBetween)
|
||||
{
|
||||
_tokenBuckets[key] = (Tokens: Math.Min(maxRequests, _tokenBuckets[key].Tokens + tokensToAdd), LastRefill: now);
|
||||
Console.WriteLine($"Tokens Refilled: {_tokenBuckets[key].Tokens}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,17 +607,8 @@ public class UserRepository(DataContext context, UserManager<AppUser> userManage
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task UpdateUserAsActive(int userId, CancellationToken ct = default)
|
||||
{
|
||||
await context.Set<AppUser>()
|
||||
.Where(u => u.Id == userId)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(u => u.LastActiveUtc, DateTime.UtcNow)
|
||||
.SetProperty(u => u.LastActive, DateTime.Now), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve all reviews (series and chapter) for a given user, respecting profile privacy settings and age restrictions.
|
||||
/// <summary> Retrieve all reviews (series and chapter) for a given user, respecting profile privacy settings and age restrictions.
|
||||
/// </summary>
|
||||
/// <param name="userId">UserId of source user</param>
|
||||
/// <param name="requestingUserId">Viewer UserId</param>
|
||||
@@ -625,7 +616,8 @@ public class UserRepository(DataContext context, UserManager<AppUser> userManage
|
||||
/// <param name="ratingFilter">Rating, only applies to series/chapters rated. Will show everything greater or equal to</param>
|
||||
/// <param name="ct"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IList<UserReviewExtendedDto>> GetAllReviewsForUser(int userId, int requestingUserId, string? query = null, float? ratingFilter = null, CancellationToken ct = default)
|
||||
public async Task<IList<UserReviewExtendedDto>> GetAllReviewsForUser(int userId, int requestingUserId,
|
||||
string? query = null, float? ratingFilter = null, CancellationToken ct = default)
|
||||
{
|
||||
var bypassPreferences = userId == requestingUserId;
|
||||
if (!bypassPreferences)
|
||||
@@ -639,7 +631,7 @@ public class UserRepository(DataContext context, UserManager<AppUser> userManage
|
||||
}
|
||||
}
|
||||
|
||||
var userRating = await context.AppUser.GetUserAgeRestriction(requestingUserId);
|
||||
var userRating = await context.AppUser.GetUserAgeRestriction(requestingUserId, ct: ct);
|
||||
|
||||
// Get series-level reviews
|
||||
var seriesReviews = await context.AppUserRating
|
||||
|
||||
@@ -23,4 +23,5 @@ public static class TaskSchedulerConstants
|
||||
public const string ReadingHistoryAggregationId = "reading-history-aggregation";
|
||||
public const string AuthKeyExpirationId = "auth-key-expiration";
|
||||
public const string EnsureSideNavId = "ensure-sidenav";
|
||||
public const string FlushUserActiveTaskId = "flush-user-active";
|
||||
}
|
||||
|
||||
@@ -114,7 +114,6 @@ public class ReadingListController(
|
||||
{
|
||||
return BadRequest(await localizationService.Translate(UserId, "reading-list-permission"));
|
||||
}
|
||||
|
||||
if (await readingListService.UpdateReadingListItemPosition(dto)) return Ok(await localizationService.Translate(UserId, "reading-list-updated"));
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Kavita.API.Database;
|
||||
using Kavita.API.Services;
|
||||
using Kavita.API.Store;
|
||||
using Kavita.Models.Entities.User;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -14,14 +14,14 @@ namespace Kavita.Server.Middleware;
|
||||
/// <param name="next"></param>
|
||||
public class UpdateUserAsActiveMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context, IUserContext userContext, IUnitOfWork unitOfWork)
|
||||
public async Task InvokeAsync(HttpContext context, IUserContext userContext, IActiveUserTrackerService tracker)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = userContext.GetUserId();
|
||||
if (userId > 0)
|
||||
{
|
||||
await unitOfWork.UserRepository.UpdateUserAsActive(userId.Value);
|
||||
tracker.RecordActive(userId.Value);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Kavita.API.Database;
|
||||
using Kavita.API.Services;
|
||||
using Kavita.Common.Helpers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Kavita.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Responsible to track userId via <see cref="UpdateUserAsActiveMiddleware"/>. Flushes the queue every 5 mins.
|
||||
/// </summary>
|
||||
public sealed class ActiveUserTrackerService( IServiceScopeFactory serviceScopeFactory, ILogger<ActiveUserTrackerService> logger)
|
||||
: IActiveUserTrackerService, IHostedService
|
||||
{
|
||||
private readonly RateLimiter _rateLimiter = new(1, TimeSpan.FromMinutes(5), refillBetween: false);
|
||||
private readonly ConcurrentDictionary<int, DateTime> _pendingUpdates = new();
|
||||
|
||||
/// <summary>
|
||||
/// Enqueue that the userId was seen
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
public void RecordActive(int userId)
|
||||
{
|
||||
if (_rateLimiter.TryAcquire(userId.ToString()))
|
||||
{
|
||||
_pendingUpdates[userId] = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flush the queue of userId's to the DB.
|
||||
/// </summary>
|
||||
/// <remarks>Expected to be run by Hangfire, not invoked manually</remarks>
|
||||
/// <param name="ct"></param>
|
||||
public async Task FlushAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_pendingUpdates.IsEmpty) return;
|
||||
|
||||
var userIds = _pendingUpdates.Keys.ToList();
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = serviceScopeFactory.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<IDataContext>();
|
||||
|
||||
await context.Users
|
||||
.Where(u => userIds.Contains(u.Id))
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(u => u.LastActiveUtc, DateTime.UtcNow)
|
||||
.SetProperty(u => u.LastActive, DateTime.Now), ct);
|
||||
|
||||
foreach (var key in userIds)
|
||||
{
|
||||
_pendingUpdates.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to bulk update LastActive for {Count} users", userIds.Count);
|
||||
}
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
logger.LogInformation("Flushing pending LastActive updates before shutdown");
|
||||
await FlushAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,9 @@ public static class ApplicationServiceExtensions
|
||||
|
||||
services.AddSingleton<IReadingSessionService, ReadingSessionService>();
|
||||
services.AddSingleton<IEntityNamingService, EntityNamingService>();
|
||||
services.AddSingleton<ActiveUserTrackerService>(); // This is required for the below lines. It allows IHostedService.StopAsync() to be called on shutdown
|
||||
services.AddSingleton<IActiveUserTrackerService>(sp => sp.GetRequiredService<ActiveUserTrackerService>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<ActiveUserTrackerService>());
|
||||
|
||||
services.AddHostedService<StartupTasksHostedService>();
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ public class TaskScheduler : ITaskScheduler
|
||||
public const string ReadingHistoryAggregationId = TaskSchedulerConstants.ReadingHistoryAggregationId;
|
||||
public const string AuthKeyExpirationId = TaskSchedulerConstants.AuthKeyExpirationId;
|
||||
public const string EnsureSideNavId = TaskSchedulerConstants.EnsureSideNavId;
|
||||
public const string FlushUserActiveTaskId = TaskSchedulerConstants.FlushUserActiveTaskId;
|
||||
|
||||
private const int BaseRetryDelay = 60; // 1-minute
|
||||
|
||||
@@ -221,6 +222,10 @@ public class TaskScheduler : ITaskScheduler
|
||||
service => service.AggregateYesterdaysActivity(CancellationToken.None),
|
||||
"5 0 * * *", RecurringJobOptions); // 12:05 AM daily
|
||||
|
||||
RecurringJob.AddOrUpdate<IActiveUserTrackerService>(FlushUserActiveTaskId,
|
||||
service => service.FlushAsync(CancellationToken.None),
|
||||
"*/5 * * * *", RecurringJobOptions);
|
||||
|
||||
BackgroundJob.Enqueue(() => ScheduleKavitaPlusTasks(CancellationToken.None));
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@ import {
|
||||
} from "../cards/_modals/bulk-set-reading-profile-modal/bulk-set-reading-profile-modal.component";
|
||||
import {EditSeriesModalComponent} from "../cards/_modals/edit-series-modal/edit-series-modal.component";
|
||||
import {EditVolumeModalComponent} from "../_single-module/edit-volume-modal/edit-volume-modal.component";
|
||||
import {DownloadService} from "../shared/_services/download.service";
|
||||
import {DownloadService} from '../shared/_services/download.service';
|
||||
import {DownloadEntityType} from '../shared/_models/download-queue-item';
|
||||
import {ReadingProfileService} from "./reading-profile.service";
|
||||
import {Action} from "../_models/actionables/action";
|
||||
import {ActionItem} from "../_models/actionables/action-item";
|
||||
@@ -330,7 +331,7 @@ export class ActionService {
|
||||
}
|
||||
|
||||
case Action.Download:
|
||||
this.downloadService.download('series', series, series.libraryId, series.id);
|
||||
this.downloadService.download(DownloadEntityType.Series, series, series.libraryId, series.id);
|
||||
return of(this.fromAction(action, series, 'none'));
|
||||
|
||||
case Action.AddToWantToReadList:
|
||||
@@ -486,7 +487,7 @@ export class ActionService {
|
||||
}
|
||||
|
||||
case Action.Download:
|
||||
this.downloadService.download('volume', volume, libraryId, seriesId);
|
||||
this.downloadService.download(DownloadEntityType.Volume, volume, libraryId, seriesId);
|
||||
return of(this.fromAction(action, volume, 'none'));
|
||||
|
||||
default:
|
||||
@@ -535,7 +536,7 @@ export class ActionService {
|
||||
);
|
||||
|
||||
case Action.Download:
|
||||
this.downloadService.download('chapter', chapter, libraryId, seriesId);
|
||||
this.downloadService.download(DownloadEntityType.Chapter, chapter, libraryId, seriesId);
|
||||
return of(this.fromAction(action, chapter, 'none'));
|
||||
|
||||
case Action.Edit:
|
||||
@@ -626,7 +627,7 @@ export class ActionService {
|
||||
);
|
||||
|
||||
case Action.Download:
|
||||
this.downloadService.download('bookmark', [bookmark], 0, 0);
|
||||
this.downloadService.download(DownloadEntityType.Bookmark, [bookmark], 0, 0);
|
||||
return of(this.fromAction(action, bookmark, 'none'));
|
||||
|
||||
case Action.ViewSeries:
|
||||
@@ -653,7 +654,7 @@ export class ActionService {
|
||||
);
|
||||
|
||||
case Action.Download:
|
||||
this.downloadService.download('readingList', readingList, 0, 0);
|
||||
this.downloadService.download(DownloadEntityType.ReadingList, readingList, 0, 0);
|
||||
return of(this.fromAction(action, readingList, 'none'));
|
||||
|
||||
case Action.Edit:
|
||||
@@ -707,7 +708,7 @@ export class ActionService {
|
||||
map(() => this.fromAction(action, {...collection, promoted: false}, 'update'))
|
||||
);
|
||||
case Action.Download:
|
||||
this.downloadService.download('collection', collection, 0, 0);
|
||||
this.downloadService.download(DownloadEntityType.Collection, collection, 0, 0);
|
||||
return of(this.fromAction(action, collection, 'none'));
|
||||
|
||||
default:
|
||||
@@ -1033,7 +1034,7 @@ export class ActionService {
|
||||
}
|
||||
|
||||
case Action.Download:
|
||||
for (const s of series) { this.downloadService.download('series', s, s.libraryId, s.id); }
|
||||
for (const s of series) { this.downloadService.download(DownloadEntityType.Series, s, s.libraryId, s.id); }
|
||||
return of(this.fromAction(action, series, 'none'));
|
||||
|
||||
default:
|
||||
@@ -1172,7 +1173,7 @@ export class ActionService {
|
||||
handleBulkBookmarkAction(action: ActionItem<any>, bookmarks: PageBookmark[], seriesIds: number[]): Observable<ActionResult<PageBookmark[]>> {
|
||||
switch (action.action) {
|
||||
case Action.Download:
|
||||
this.downloadService.download('bookmark', bookmarks, 0, 0);
|
||||
this.downloadService.download(DownloadEntityType.Bookmark, bookmarks, 0, 0);
|
||||
return of(this.fromAction(action, bookmarks, 'none'));
|
||||
|
||||
case Action.Delete:
|
||||
@@ -1211,7 +1212,7 @@ export class ActionService {
|
||||
);
|
||||
|
||||
case Action.Download:
|
||||
for (let c of collections) this.downloadService.download('collection', c, 0, 0);
|
||||
for (let c of collections) this.downloadService.download(DownloadEntityType.Collection, c, 0, 0);
|
||||
return of(this.fromAction(action, collections, 'none'));
|
||||
|
||||
default:
|
||||
@@ -1242,7 +1243,7 @@ export class ActionService {
|
||||
);
|
||||
|
||||
case Action.Download:
|
||||
for (const rl of readingLists) { this.downloadService.download('readingList', rl, 0, 0); }
|
||||
for (const rl of readingLists) { this.downloadService.download(DownloadEntityType.ReadingList, rl, 0, 0); }
|
||||
return of(this.fromAction(action, readingLists, 'none'));
|
||||
|
||||
default:
|
||||
|
||||
@@ -12,9 +12,30 @@ export class EntityTitleService {
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
private readonly utilityService = inject(UtilityService);
|
||||
|
||||
computeTitle(
|
||||
entity: Volume | Chapter,
|
||||
libraryType: LibraryType | number,
|
||||
|
||||
/**
|
||||
* Formats a Chapter name based on the library it's in
|
||||
* @param libraryType
|
||||
* @param plural Pluralize word
|
||||
* @returns
|
||||
*/
|
||||
formatChapterName(libraryType: LibraryType, plural: boolean = false) {
|
||||
const pluralKeyPart = plural ? '-plural' : '';
|
||||
|
||||
switch(libraryType) {
|
||||
case LibraryType.Book:
|
||||
case LibraryType.LightNovel:
|
||||
return this.translocoService.translate('entity-title.book-title' + pluralKeyPart);
|
||||
case LibraryType.Comic:
|
||||
case LibraryType.ComicVine:
|
||||
return this.translocoService.translate('entity-title.issue-title' + pluralKeyPart);
|
||||
case LibraryType.Images:
|
||||
case LibraryType.Manga:
|
||||
return this.translocoService.translate('entity-title.chapter-title' + pluralKeyPart);
|
||||
}
|
||||
}
|
||||
|
||||
computeTitle(entity: Volume | Chapter, libraryType: LibraryType | number,
|
||||
options?: {
|
||||
prioritizeTitleName?: boolean;
|
||||
fallbackToVolume?: boolean;
|
||||
@@ -108,9 +129,9 @@ export class EntityTitleService {
|
||||
if (titleName !== '' && prioritizeTitleName) {
|
||||
if (isChapter && includeChapter) {
|
||||
if (number === LooseLeafOrSpecial) {
|
||||
renderText = this.translocoService.translate('entity-title.chapter') + ' - ';
|
||||
renderText = this.translocoService.translate('entity-title.chapter-title') + ' - ';
|
||||
} else {
|
||||
renderText = this.translocoService.translate('entity-title.chapter', {num: number}) + ' - ';
|
||||
renderText = this.translocoService.translate('entity-title.chapter-num', {num: number}) + ' - ';
|
||||
}
|
||||
}
|
||||
renderText += titleName;
|
||||
@@ -123,12 +144,12 @@ export class EntityTitleService {
|
||||
|
||||
if (number !== LooseLeafOrSpecial) {
|
||||
if (isChapter) {
|
||||
renderText = this.translocoService.translate('entity-title.chapter', {num: number});
|
||||
renderText = this.translocoService.translate('entity-title.chapter-num', {num: number});
|
||||
} else {
|
||||
renderText = volumeTitle;
|
||||
}
|
||||
} else if (fallbackToVolume && isChapter && volumeTitle) {
|
||||
renderText = this.translocoService.translate('entity-title.vol-num', {num: volumeTitle});
|
||||
renderText = this.translocoService.translate('entity-title.volume-num', {num: volumeTitle});
|
||||
} else if (fallbackToVolume && isChapter) {
|
||||
renderText = this.translocoService.translate('entity-title.single-volume');
|
||||
} else {
|
||||
@@ -144,7 +165,7 @@ export class EntityTitleService {
|
||||
|
||||
if (number !== LooseLeafOrSpecial) {
|
||||
if (isChapter) {
|
||||
renderText = this.translocoService.translate('entity-title.chapter', {num: number});
|
||||
renderText = this.translocoService.translate('entity-title.chapter-num', {num: number});
|
||||
} else {
|
||||
renderText = volumeTitle;
|
||||
}
|
||||
|
||||
@@ -15,26 +15,26 @@
|
||||
<i class="fa {{iconClass()}}" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div ngbDropdownMenu attr.aria-labelledby="{{labelId}}">
|
||||
<ng-container *ngTemplateOutlet="submenu; context: { list: filteredActions() }" />
|
||||
<ng-container *ngTemplateOutlet="submenu; context: { list: filteredActions(), topLevel: true }" />
|
||||
</div>
|
||||
</div>
|
||||
<ng-template #submenu let-list="list">
|
||||
<ng-template #submenu let-list="list" let-topLevel="topLevel">
|
||||
@for(action of list; track action.title) {
|
||||
<!-- Non Submenu items -->
|
||||
@if (action.children === undefined || action?.children?.length === 0 || action.dynamicList !== undefined) {
|
||||
@if (action.dynamicList !== undefined && (action.dynamicList | async | dynamicList); as dList) {
|
||||
@for(dynamicItem of dList; track dynamicItem.title) {
|
||||
<button ngbDropdownItem (click)="performDynamicClick($event, action, dynamicItem)">{{dynamicItem.title}}</button>
|
||||
<button ngbDropdownItem (mouseenter)="closeAllSubmenus(topLevel)" (click)="performDynamicClick($event, action, dynamicItem)">{{dynamicItem.title}}</button>
|
||||
}
|
||||
} @else {
|
||||
<button ngbDropdownItem (click)="performAction($event, action)">{{t(action.title)}}</button>
|
||||
<button ngbDropdownItem (mouseenter)="closeAllSubmenus(topLevel)" (click)="performAction($event, action)">{{t(action.title)}}</button>
|
||||
}
|
||||
} @else {
|
||||
@if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async)) {
|
||||
<!-- Submenu items -->
|
||||
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right left"
|
||||
(click)="openSubmenu(action.title, subMenuHover)"
|
||||
(mouseenter)="openSubmenu(action.title, subMenuHover)"
|
||||
(mouseenter)="cancelCloseSubmenus(); openSubmenu(action.title, subMenuHover)"
|
||||
(mouseover)="preventEvent($event)"
|
||||
class="submenu-wrapper">
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
}
|
||||
|
||||
<div ngbDropdownMenu attr.aria-labelledby="actions-{{action.title}}">
|
||||
<ng-container *ngTemplateOutlet="submenu; context: { list: action.children }" />
|
||||
<ng-container *ngTemplateOutlet="submenu; context: { list: action.children, topLevel: false }" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -118,7 +118,8 @@ export class CardActionablesComponent implements OnDestroy {
|
||||
subMenu.open();
|
||||
}
|
||||
|
||||
closeAllSubmenus() {
|
||||
closeAllSubmenus(topLevel = false) {
|
||||
if (!topLevel) return;
|
||||
// Clear any existing timeout to avoid race conditions
|
||||
if (this.closeTimeout) {
|
||||
clearTimeout(this.closeTimeout);
|
||||
|
||||
@@ -26,7 +26,8 @@ import {ImageService} from "../../_services/image.service";
|
||||
import {UploadService} from "../../_services/upload.service";
|
||||
import {MetadataService} from "../../_services/metadata.service";
|
||||
import {ActionService} from "../../_services/action.service";
|
||||
import {DownloadService} from "../../shared/_services/download.service";
|
||||
import {DownloadService} from '../../shared/_services/download.service';
|
||||
import {DownloadEntityType} from '../../shared/_models/download-queue-item';
|
||||
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
||||
import {TypeaheadComponent} from "../../typeahead/_components/typeahead.component";
|
||||
import {concat, forkJoin, Observable, of, tap} from "rxjs";
|
||||
@@ -296,7 +297,7 @@ export class EditChapterModalComponent implements OnInit {
|
||||
});
|
||||
break;
|
||||
case Action.Download:
|
||||
this.downloadService.download('chapter', this.chapter, this.libraryId, this.seriesId);
|
||||
this.downloadService.download(DownloadEntityType.Chapter, this.chapter, this.libraryId, this.seriesId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ import {ImageService} from "../../_services/image.service";
|
||||
import {UploadService} from "../../_services/upload.service";
|
||||
import {AccountService} from "../../_services/account.service";
|
||||
import {ActionService} from "../../_services/action.service";
|
||||
import {DownloadService} from "../../shared/_services/download.service";
|
||||
import {DownloadService} from '../../shared/_services/download.service';
|
||||
import {DownloadEntityType} from '../../shared/_models/download-queue-item';
|
||||
import {LibraryType} from "../../_models/library/library";
|
||||
import {PersonRole} from "../../_models/metadata/person";
|
||||
import {forkJoin} from "rxjs";
|
||||
@@ -165,7 +166,7 @@ export class EditVolumeModalComponent implements OnInit {
|
||||
});
|
||||
break;
|
||||
case Action.Download:
|
||||
this.downloadService.download('volume', this.volume, this.libraryId, this.seriesId);
|
||||
this.downloadService.download(DownloadEntityType.Volume, this.volume, this.libraryId, this.seriesId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import {ServerService} from 'src/app/_services/server.service';
|
||||
import {Job} from 'src/app/_models/job/job';
|
||||
import {DownloadService} from 'src/app/shared/_services/download.service';
|
||||
import {DownloadEntityType} from 'src/app/shared/_models/download-queue-item';
|
||||
import {DefaultValuePipe} from '../../_pipes/default-value.pipe';
|
||||
import {AsyncPipe, TitleCasePipe} from '@angular/common';
|
||||
import {translate, TranslocoModule} from "@jsverse/transloco";
|
||||
@@ -112,7 +113,7 @@ export class ManageTasksSettingsComponent implements OnInit {
|
||||
{
|
||||
name: 'download-logs-task',
|
||||
description: 'download-logs-task-desc',
|
||||
api: defer(() => of(this.downloadService.download('logs', undefined, 0, 0))),
|
||||
api: defer(() => of(this.downloadService.download(DownloadEntityType.Logs, undefined, 0, 0))),
|
||||
successMessage: ''
|
||||
},
|
||||
{
|
||||
|
||||
@@ -71,6 +71,7 @@ import {KeyBindService} from "../../../_services/key-bind.service";
|
||||
import {KeyBindTarget} from "../../../_models/preferences/preferences";
|
||||
import {BreakpointService} from "../../../_services/breakpoint.service";
|
||||
import {KavitaTitleStrategy} from "../../../_services/kavita-title.strategy";
|
||||
import {EntityTitleService} from "../../../_services/entity-title.service";
|
||||
|
||||
|
||||
interface HistoryPoint {
|
||||
@@ -151,6 +152,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
private readonly fontService = inject(FontService);
|
||||
private readonly keyBindService = inject(KeyBindService);
|
||||
protected readonly breakpointService = inject(BreakpointService);
|
||||
private readonly entityTitleService = inject(EntityTitleService);
|
||||
|
||||
libraryId!: number;
|
||||
seriesId!: number;
|
||||
@@ -1147,7 +1149,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
// Load chapter Id onto route but don't reload
|
||||
const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode(), this.readingListMode, this.readingListId);
|
||||
window.history.replaceState({}, '', newRoute);
|
||||
const msg = translate(direction === 'Next' ? 'toasts.load-next-chapter' : 'toasts.load-prev-chapter', {entity: this.utilityService.formatChapterName(this.libraryType).toLowerCase()});
|
||||
const msg = translate(direction === 'Next' ? 'toasts.load-next-chapter' : 'toasts.load-prev-chapter', {entity: this.entityTitleService.formatChapterName(this.libraryType).toLowerCase()});
|
||||
this.toastr.info(msg, '', {timeOut: 3000});
|
||||
this.cdRef.markForCheck();
|
||||
this.init(false);
|
||||
@@ -1155,7 +1157,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
// This will only happen if no actual chapter can be found
|
||||
const msg = translate(direction === 'Next' ? 'toasts.no-next-chapter' : 'toasts.no-prev-chapter', {entity: this.utilityService.formatChapterName(this.libraryType).toLowerCase()});
|
||||
const msg = translate(direction === 'Next' ? 'toasts.no-next-chapter' : 'toasts.no-prev-chapter', {entity: this.entityTitleService.formatChapterName(this.libraryType).toLowerCase()});
|
||||
this.toastr.warning(msg);
|
||||
this.isLoading.set(false);
|
||||
if (direction === 'Prev') {
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
</div>
|
||||
|
||||
<div class="col-auto ms-2 d-none d-md-block">
|
||||
<app-download-button [entity]="chapterValue" entityType="chapter" [libraryId]="libraryId()" [seriesId]="seriesId()" />
|
||||
<app-download-button [entity]="chapterValue" [entityType]="DownloadEntityType.Chapter" [libraryId]="libraryId()" [seriesId]="seriesId()" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {DownloadEntityType} from '../shared/_models/download-queue-item';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
@@ -129,6 +130,7 @@ enum TabID {
|
||||
})
|
||||
export class ChapterDetailComponent implements OnInit {
|
||||
|
||||
protected readonly DownloadEntityType = DownloadEntityType;
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
@@ -158,25 +158,25 @@
|
||||
</div>
|
||||
}
|
||||
<div class="row pt-4 ms-2 me-2 mb-2">
|
||||
<div class="col">
|
||||
<button class="btn btn-icon" (click)="setReadingDirection();resetMenuCloseTimer();" [disabled]="readerMode === ReaderMode.Webtoon || readerMode === ReaderMode.UpDown" aria-describedby="reading-direction" [title]="t('reading-direction-tooltip') + readingDirection === ReadingDirection.LeftToRight ? t('left-to-right-alt') : t('right-to-left-alt')">
|
||||
<div class="col d-flex justify-content-center">
|
||||
<button class="btn btn-icon" (click)="setReadingDirection();resetMenuCloseTimer();" [disabled]="readerMode === ReaderMode.Webtoon || readerMode === ReaderMode.UpDown" aria-describedby="reading-direction" [title]="t('reading-direction-tooltip') + (readingDirection === ReadingDirection.LeftToRight ? t('left-to-right-alt') : t('right-to-left-alt'))">
|
||||
<i class="fa fa-angle-double-{{readingDirection === ReadingDirection.LeftToRight ? 'right' : 'left'}}" aria-hidden="true"></i>
|
||||
<span id="reading-direction" class="visually-hidden">{{readingDirection === ReadingDirection.LeftToRight ? t('left-to-right-alt') : t('right-to-left-alt')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="col d-flex justify-content-center">
|
||||
<button class="btn btn-icon" [title]="t('reading-mode-tooltip')" (click)="toggleReaderMode();resetMenuCloseTimer();">
|
||||
<i class="fa {{readerMode | readerModeIcon}}" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('reading-mode-tooltip')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="col d-flex justify-content-center">
|
||||
<button class="btn btn-icon" title="{{isFullscreen ? t('collapse') : t('fullscreen')}}" (click)="toggleFullscreen();resetMenuCloseTimer();">
|
||||
<i class="fa {{isFullscreen | fullscreenIcon}}" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{isFullscreen ? t('collapse') : t('fullscreen')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="col d-flex justify-content-center">
|
||||
<button class="btn btn-icon" [title]="t('settings-tooltip')" (click)="settingsOpen = !settingsOpen;resetMenuCloseTimer();">
|
||||
<i class="fa fa-sliders-h" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('settings-tooltip')}}</span>
|
||||
|
||||
@@ -82,6 +82,7 @@ import {KeyBindEvent, KeyBindService} from "../../../_services/key-bind.service"
|
||||
import {KeyBindTarget} from "../../../_models/preferences/preferences";
|
||||
import {mediumModal} from "../../../_models/modal/modal-options";
|
||||
import {ModalService, TypedModalRef} from "../../../_services/modal.service";
|
||||
import {EntityTitleService} from "../../../_services/entity-title.service";
|
||||
|
||||
|
||||
const PREFETCH_PAGES = 10;
|
||||
@@ -145,6 +146,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
protected readonly utilityService = inject(UtilityService);
|
||||
protected readonly mangaReaderService = inject(MangaReaderService);
|
||||
private readonly keyBindService = inject(KeyBindService);
|
||||
private readonly entityTitleService = inject(EntityTitleService);
|
||||
|
||||
protected readonly KeyDirection = KeyDirection;
|
||||
protected readonly ReaderMode = ReaderMode;
|
||||
@@ -1517,12 +1519,12 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId);
|
||||
window.history.replaceState({}, '', newRoute);
|
||||
this.init(false);
|
||||
const msg = translate(direction === 'Next' ? 'toasts.load-next-chapter' : 'toasts.load-prev-chapter', {entity: this.utilityService.formatChapterName(this.libraryType).toLowerCase()});
|
||||
const msg = translate(direction === 'Next' ? 'toasts.load-next-chapter' : 'toasts.load-prev-chapter', {entity: this.entityTitleService.formatChapterName(this.libraryType).toLowerCase()});
|
||||
this.toastr.info(msg, '', {timeOut: 3000});
|
||||
} else {
|
||||
// This will only happen if no actual chapter can be found
|
||||
const msg = translate(direction === 'Next' ? 'toasts.no-next-chapter' : 'toasts.no-prev-chapter',
|
||||
{entity: this.utilityService.formatChapterName(this.libraryType).toLowerCase()});
|
||||
{entity: this.entityTitleService.formatChapterName(this.libraryType).toLowerCase()});
|
||||
this.toastr.warning(msg);
|
||||
this.isLoading = false;
|
||||
if (direction === 'Prev') {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {ChangeDetectionStrategy, Component, computed, inject, input, output} from '@angular/core';
|
||||
import {DownloadQueueItem} from '../../../shared/_models/download-queue-item';
|
||||
import {DownloadEntityType, DownloadQueueItem} from '../../../shared/_models/download-queue-item';
|
||||
import {BytesPipe} from '../../../_pipes/bytes.pipe';
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {ImageComponent} from '../../../shared/image/image.component';
|
||||
@@ -30,10 +30,11 @@ export class DownloadQueueItemComponent {
|
||||
const item = this.item();
|
||||
|
||||
switch (item.entityType) {
|
||||
case 'volume': return this.imageService.getVolumeCoverImage(item.entityId);
|
||||
case 'chapter': return this.imageService.getChapterCoverImage(item.entityId);
|
||||
case 'readinglist-item': return this.imageService.getChapterCoverImage(item.chapterId!);
|
||||
case DownloadEntityType.Volume: return this.imageService.getVolumeCoverImage(item.entityId);
|
||||
case DownloadEntityType.Chapter: return this.imageService.getChapterCoverImage(item.entityId);
|
||||
case DownloadEntityType.ReadingListItem: return this.imageService.getChapterCoverImage(item.chapterId!);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
readonly statusBadgeColor = computed<TagBadgeColor>(() => {
|
||||
|
||||
@@ -55,13 +55,18 @@
|
||||
</ng-container>
|
||||
|
||||
<div class="row mt-2">
|
||||
@for (chapter of entry.chapters.slice(0, 5); track chapter.chapterId) {
|
||||
@for (chapter of entry.chapters.slice(0, 5); track chapter.chapterId + '_' + chapter.pagesRead) {
|
||||
<div class="col-auto d-none d-sm-block chapter-cover"
|
||||
[routerLink]="['/library', entry.libraryId, 'series', entry.seriesId, 'chapter', chapter.chapterId]">
|
||||
<app-image [imageUrl]="imageService.getChapterCoverImage(chapter.chapterId)" class="d-block" [ngbTooltip]="chapter.label"
|
||||
style="width: 2.5rem; height: auto;"/>
|
||||
</div>
|
||||
}
|
||||
@let nonSlicedLength = entry.chapters.length - 5;
|
||||
@if (nonSlicedLength > 0) {
|
||||
<i class="fa-solid fa-ellipsis my-auto" tabindex="0" [title]="t('num-more', {num: nonSlicedLength})"></i>
|
||||
<span class="visually-hidden">{{t('num-more', {num: nonSlicedLength})}}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+3
-2
@@ -1,6 +1,7 @@
|
||||
import {ChangeDetectionStrategy, Component, computed, inject, input} from '@angular/core';
|
||||
import {AccountService} from "../../../_services/account.service";
|
||||
import {DownloadService} from "../../../shared/_services/download.service";
|
||||
import {DownloadService} from '../../../shared/_services/download.service';
|
||||
import {DownloadEntityType} from '../../../shared/_models/download-queue-item';
|
||||
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {Chapter} from "../../../_models/chapter";
|
||||
@@ -25,7 +26,7 @@ export class DownloadButtonComponent {
|
||||
entity = input.required<Series | Volume | Chapter>();
|
||||
seriesId = input.required<number>();
|
||||
libraryId = input.required<number>();
|
||||
entityType = input<'series' | 'volume' | 'chapter'>('series');
|
||||
entityType = input<DownloadEntityType>(DownloadEntityType.Series);
|
||||
|
||||
isDownloading = computed(() => {
|
||||
const item = this.downloadService.getItemForEntity(this.entity());
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
</div>
|
||||
|
||||
<div class="col-auto ms-2 d-none d-md-block btn-actions">
|
||||
<app-download-button [entity]="seriesValue" entityType="series" [libraryId]="libraryId()" [seriesId]="seriesValue.id" />
|
||||
<app-download-button [entity]="seriesValue" [entityType]="DownloadEntityType.Series" [libraryId]="libraryId()" [seriesId]="seriesValue.id" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {DOCUMENT, Location, NgClass, NgStyle, NgTemplateOutlet} from '@angular/common';
|
||||
import {DownloadEntityType} from '../../../shared/_models/download-queue-item';
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
@@ -115,6 +116,7 @@ import {patchEntitySignal, patchSignalArray} from "../../../../libs/patch";
|
||||
import {ModalService} from "../../../_services/modal.service";
|
||||
import {getResolvedData} from "../../../../libs/route-util";
|
||||
import {ExternalSeries} from "../../../_models/series-detail/external-series";
|
||||
import {EntityTitleService} from "../../../_services/entity-title.service";
|
||||
|
||||
|
||||
enum TabID {
|
||||
@@ -151,6 +153,7 @@ interface StoryLineItem {
|
||||
})
|
||||
class SeriesDetailComponent implements OnInit, AfterViewInit {
|
||||
|
||||
protected readonly DownloadEntityType = DownloadEntityType;
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly seriesService = inject(SeriesService);
|
||||
@@ -180,6 +183,7 @@ class SeriesDetailComponent implements OnInit, AfterViewInit {
|
||||
private readonly location = inject(Location);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
protected readonly breakpointService = inject(BreakpointService);
|
||||
private readonly entityTitleService = inject(EntityTitleService);
|
||||
|
||||
readonly scrollingBlock = viewChild<ElementRef<HTMLDivElement>>('scrollingBlock');
|
||||
|
||||
@@ -356,7 +360,7 @@ class SeriesDetailComponent implements OnInit, AfterViewInit {
|
||||
|
||||
|
||||
seriesCoverImage = computed(() => this.imageService.getSeriesCoverImage(this.seriesId()));
|
||||
chapterTabName = computed(() => this.utilityService.formatChapterName(this.libraryType()));
|
||||
chapterTabName = computed(() => this.entityTitleService.formatChapterName(this.libraryType(), true));
|
||||
nextExpectedChapter = signal<NextExpectedChapter | null>(null);
|
||||
loadPageSource = new ReplaySubject<boolean>(1);
|
||||
loadPage$ = this.loadPageSource.asObservable();
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
import {Volume} from "../../_models/volume";
|
||||
import {Chapter} from "../../_models/chapter";
|
||||
import {ReadingListItem} from "../../_models/reading-list";
|
||||
import {Volume} from '../../_models/volume';
|
||||
import {Chapter} from '../../_models/chapter';
|
||||
import {ReadingListItem} from '../../_models/reading-list';
|
||||
|
||||
/** All valid entity types for downloading */
|
||||
export enum DownloadEntityType {
|
||||
Series = 'series',
|
||||
Volume = 'volume',
|
||||
Chapter = 'chapter',
|
||||
ReadingListItem = 'readingListItem',
|
||||
ReadingList = 'readingList',
|
||||
Collection = 'collection',
|
||||
Bookmark = 'bookmark',
|
||||
Logs = 'logs',
|
||||
}
|
||||
|
||||
/** Distilled types that actually enter the queue (volumes, chapters, reading list items) */
|
||||
export type DistilledDownloadEntityType =
|
||||
DownloadEntityType.Volume | DownloadEntityType.Chapter | DownloadEntityType.ReadingListItem;
|
||||
|
||||
export type DownloadQueueStatus = 'queued' | 'preparing' | 'downloading' | 'completed' | 'failed' | 'cancelled';
|
||||
|
||||
export interface DownloadQueueItem {
|
||||
id: number;
|
||||
/** Atomic unit of download, series/reading-list/collection always decompose to these */
|
||||
entityType: 'volume' | 'chapter' | 'readinglist-item';
|
||||
entityType: DistilledDownloadEntityType;
|
||||
entityId: number;
|
||||
libraryId: number;
|
||||
seriesId: number;
|
||||
@@ -27,7 +43,7 @@ export interface DownloadQueueItem {
|
||||
queuedAt: string | number;
|
||||
/** Present only for in-memory items; stripped before IndexedDB persistence and absent on restored items. */
|
||||
entity?: Volume | Chapter | ReadingListItem;
|
||||
/** For readinglist-item: the chapter ID to use for the download endpoint. Persisted to IDB. */
|
||||
/** For ReadingListItem: the chapter ID to use for the download endpoint. Persisted to IDB. */
|
||||
chapterId?: number;
|
||||
/** Predicted backend filename used to match SignalR progress events */
|
||||
downloadName: string;
|
||||
|
||||
@@ -20,7 +20,12 @@ import {UtcToLocalDatePipe} from '../../_pipes/utc-to-locale-date.pipe';
|
||||
import {EVENTS, MessageHubService} from "../../_services/message-hub.service";
|
||||
import {NotificationProgressEvent} from "../../_models/events/notification-progress-event";
|
||||
import {SeriesService} from "../../_services/series.service";
|
||||
import {DownloadQueueItem, DownloadQueueStatus} from '../_models/download-queue-item';
|
||||
import {
|
||||
DistilledDownloadEntityType,
|
||||
DownloadEntityType,
|
||||
DownloadQueueItem,
|
||||
DownloadQueueStatus
|
||||
} from '../_models/download-queue-item';
|
||||
import {DownloadStorageService} from './download-storage.service';
|
||||
import {normalizeTimestamp} from "../../../libs/download-timestamp";
|
||||
import {ReadingList, ReadingListItem} from "../../_models/reading-list";
|
||||
@@ -34,13 +39,7 @@ export const DEBOUNCE_TIME = 100;
|
||||
|
||||
const bytesPipe = new BytesPipe();
|
||||
|
||||
/**
|
||||
* Valid entity types for downloading
|
||||
*/
|
||||
export type DownloadEntityType = 'volume' | 'chapter' | 'series' | 'bookmark' | 'logs' | 'readingList' | 'readingListItem' | 'collection';
|
||||
/**
|
||||
* Valid entities for downloading. Undefined exclusively for logs.
|
||||
*/
|
||||
/** Valid entities for downloading. Undefined exclusively for logs */
|
||||
export type DownloadEntity = Series | Volume | Chapter | PageBookmark[] | ReadingList | ReadingListItem | UserCollection | undefined;
|
||||
|
||||
@Injectable({
|
||||
@@ -235,12 +234,12 @@ export class DownloadService {
|
||||
*/
|
||||
downloadSubtitle(downloadEntityType: DownloadEntityType | undefined, downloadEntity: DownloadEntity | undefined) {
|
||||
switch (downloadEntityType) {
|
||||
case 'series': return (downloadEntity as Series).name;
|
||||
case 'volume': return (downloadEntity as Volume).minNumber + '';
|
||||
case 'chapter': return (downloadEntity as Chapter).minNumber + '';
|
||||
case 'bookmark': return '';
|
||||
case 'logs': return '';
|
||||
case 'readingListItem': return (downloadEntity as ReadingListItem).title;
|
||||
case DownloadEntityType.Series: return (downloadEntity as Series).name;
|
||||
case DownloadEntityType.Volume: return (downloadEntity as Volume).minNumber + '';
|
||||
case DownloadEntityType.Chapter: return (downloadEntity as Chapter).minNumber + '';
|
||||
case DownloadEntityType.Bookmark: return '';
|
||||
case DownloadEntityType.Logs: return '';
|
||||
case DownloadEntityType.ReadingListItem: return (downloadEntity as ReadingListItem).title;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -253,25 +252,25 @@ export class DownloadService {
|
||||
*/
|
||||
download(entityType: DownloadEntityType, entity: DownloadEntity, libraryId: number, seriesId: number) {
|
||||
switch (entityType) {
|
||||
case 'series':
|
||||
case DownloadEntityType.Series:
|
||||
this.downloadSeries(entity as Series);
|
||||
break;
|
||||
case 'volume':
|
||||
case DownloadEntityType.Volume:
|
||||
this.downloadVolume(entity as Volume, libraryId, seriesId);
|
||||
break;
|
||||
case 'chapter':
|
||||
this.enqueueSingle(entity as Chapter, 'chapter', '', libraryId, seriesId);
|
||||
case DownloadEntityType.Chapter:
|
||||
this.enqueueSingle(entity as Chapter, DownloadEntityType.Chapter, '', libraryId, seriesId);
|
||||
break;
|
||||
case 'bookmark':
|
||||
case DownloadEntityType.Bookmark:
|
||||
this.downloadBookmarksBlob(entity as PageBookmark[]);
|
||||
break;
|
||||
case 'logs':
|
||||
case DownloadEntityType.Logs:
|
||||
this.downloadLogsBlob();
|
||||
break;
|
||||
case 'readingList':
|
||||
case DownloadEntityType.ReadingList:
|
||||
this.downloadReadingList(entity as ReadingList);
|
||||
break;
|
||||
case 'collection':
|
||||
case DownloadEntityType.Collection:
|
||||
this.downloadCollection(entity as UserCollection);
|
||||
break;
|
||||
}
|
||||
@@ -282,9 +281,9 @@ export class DownloadService {
|
||||
* Downloads multiple volumes and chapters in bulk, using only 2 HTTP size calls total.
|
||||
*/
|
||||
downloadBulk(volumes: Volume[], chapters: Chapter[], libraryId = 0, seriesId = 0) {
|
||||
const items: Array<{ entity: Volume | Chapter; entityType: 'volume' | 'chapter' }> = [
|
||||
...volumes.map(v => ({ entity: v as Volume, entityType: 'volume' as const })),
|
||||
...chapters.map(c => ({ entity: c as Chapter, entityType: 'chapter' as const })),
|
||||
const items: Array<{ entity: Volume | Chapter; entityType: DownloadEntityType.Volume | DownloadEntityType.Chapter }> = [
|
||||
...volumes.map(v => ({ entity: v as Volume, entityType: DownloadEntityType.Volume as const })),
|
||||
...chapters.map(c => ({ entity: c as Chapter, entityType: DownloadEntityType.Chapter as const })),
|
||||
];
|
||||
if (items.length === 0) return;
|
||||
this.enqueueItems(items, '', libraryId, seriesId);
|
||||
@@ -459,7 +458,7 @@ export class DownloadService {
|
||||
}
|
||||
|
||||
// Volume/Chapter: O(1) Map lookup for active
|
||||
const entityType = this.utilityService.isVolume(entity) ? 'volume' : 'chapter';
|
||||
const entityType = this.utilityService.isVolume(entity) ? DownloadEntityType.Volume : DownloadEntityType.Chapter;
|
||||
const key = this._indexKey(entityType, (entity as Volume | Chapter).id);
|
||||
const active = this._activeIndex.get(key);
|
||||
if (active && ['queued', 'preparing', 'downloading'].includes(active.status)) return active;
|
||||
@@ -522,30 +521,30 @@ export class DownloadService {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
private getEntityDownloadSize(entityType: 'series' | 'volume' | 'chapter' | 'readinglist', id: number) {
|
||||
private getEntityDownloadSize(entityType: DownloadEntityType.Series | DownloadEntityType.Volume | DownloadEntityType.Chapter, id: number) {
|
||||
return this.httpClient.get<number>(this.baseUrl + `download/${entityType}-size?${entityType}Id=${id}`);
|
||||
}
|
||||
|
||||
private getBulkEntityDownloadSize(entityType: 'series' | 'volume' | 'chapter' | 'readinglist', ids: number[]) {
|
||||
private getBulkEntityDownloadSize(entityType: DownloadEntityType.Series | DownloadEntityType.Volume | DownloadEntityType.Chapter, ids: number[]) {
|
||||
const data = {} as any;
|
||||
data[entityType + 'Ids'] = ids;
|
||||
return this.httpClient.post<Record<number, number>>(this.baseUrl + `download/bulk-${entityType}-size`, data);
|
||||
}
|
||||
|
||||
private downloadSeriesSize(seriesId: number) {
|
||||
return this.getEntityDownloadSize('series', seriesId);
|
||||
return this.getEntityDownloadSize(DownloadEntityType.Series, seriesId);
|
||||
}
|
||||
|
||||
private downloadBulkVolumeSizes(volumeIds: number[]) {
|
||||
return this.getBulkEntityDownloadSize('volume', volumeIds);
|
||||
return this.getBulkEntityDownloadSize(DownloadEntityType.Volume, volumeIds);
|
||||
}
|
||||
|
||||
private downloadBulkChapterSizes(chapterIds: number[]) {
|
||||
return this.getBulkEntityDownloadSize('chapter', chapterIds);
|
||||
return this.getBulkEntityDownloadSize(DownloadEntityType.Chapter, chapterIds);
|
||||
}
|
||||
|
||||
private downloadVolumeSize(volumeId: number) {
|
||||
return this.getEntityDownloadSize('volume', volumeId);
|
||||
return this.getEntityDownloadSize(DownloadEntityType.Volume, volumeId);
|
||||
}
|
||||
|
||||
|
||||
@@ -554,18 +553,18 @@ export class DownloadService {
|
||||
|
||||
// Volumes can be either a bunch of chapters or just 1
|
||||
if (volume.chapters.length === 1) {
|
||||
this.enqueueSingle(volume, 'volume', '', libraryId, seriesId);
|
||||
this.enqueueSingle(volume, DownloadEntityType.Volume, '', libraryId, seriesId);
|
||||
return;
|
||||
}
|
||||
this.debugLog(`downloadVolume() decomposed into ${volume.chapters.length} items`);
|
||||
|
||||
const items = volume.chapters.map(c => ({ entity: c as Chapter, entityType: 'chapter' as const }));
|
||||
const items = volume.chapters.map(c => ({ entity: c as Chapter, entityType: DownloadEntityType.Chapter as const }));
|
||||
|
||||
const userPrefs = this.accountService.userPreferences();
|
||||
if (userPrefs?.promptForDownloadSize && items.length > 0) {
|
||||
// Single size call for the whole series, single confirm dialog
|
||||
this.downloadVolumeSize(volume.id).pipe(
|
||||
switchMap(async size => this.confirmSize(size, 'volume')),
|
||||
switchMap(async size => this.confirmSize(size, DownloadEntityType.Volume)),
|
||||
filter(confirmed => confirmed),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe(() => this.enqueueItems(items, '', libraryId, seriesId));
|
||||
@@ -589,9 +588,9 @@ export class DownloadService {
|
||||
|
||||
if (userPrefs?.promptForDownloadSize && collectionSeries.result.length > 0) {
|
||||
const seriesIds = collectionSeries.result.map(s => s.id);
|
||||
this.getBulkEntityDownloadSize('series', seriesIds).pipe(
|
||||
this.getBulkEntityDownloadSize(DownloadEntityType.Series, seriesIds).pipe(
|
||||
map(r => Object.values(r).reduce((acc, curr) => acc + curr, 0)),
|
||||
switchMap(async size => this.confirmSize(size, 'series')),
|
||||
switchMap(async size => this.confirmSize(size, DownloadEntityType.Series)),
|
||||
filter(confirmed => confirmed),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe(() => {
|
||||
@@ -616,7 +615,7 @@ export class DownloadService {
|
||||
this.readingListService.getListItems(readingList.id);
|
||||
|
||||
items$.subscribe((items: ReadingListItem[]) => {
|
||||
const rliItems = items.map(item => ({ entity: item as ReadingListItem, entityType: 'readinglist-item' as const }));
|
||||
const rliItems = items.map(item => ({ entity: item as ReadingListItem, entityType: DownloadEntityType.ReadingListItem as const }));
|
||||
this.enqueueItems(rliItems, readingList.title, 0, 0, readingList.id);
|
||||
});
|
||||
}
|
||||
@@ -626,10 +625,10 @@ export class DownloadService {
|
||||
this.seriesService.getSeriesDetail(series.id).pipe(
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe(detail => {
|
||||
const items: Array<{ entity: Volume | Chapter; entityType: 'volume' | 'chapter' }> = [
|
||||
...detail.volumes.map(v => ({ entity: v as Volume, entityType: 'volume' as const })),
|
||||
...detail.chapters.map(c => ({ entity: c as Chapter, entityType: 'chapter' as const })),
|
||||
...detail.specials.map(c => ({ entity: c as Chapter, entityType: 'chapter' as const })),
|
||||
const items: Array<{ entity: Volume | Chapter; entityType: DownloadEntityType.Volume | DownloadEntityType.Chapter }> = [
|
||||
...detail.volumes.map(v => ({ entity: v as Volume, entityType: DownloadEntityType.Volume as const })),
|
||||
...detail.chapters.map(c => ({ entity: c as Chapter, entityType: DownloadEntityType.Chapter as const })),
|
||||
...detail.specials.map(c => ({ entity: c as Chapter, entityType: DownloadEntityType.Chapter as const })),
|
||||
];
|
||||
this.debugLog(`downloadSeries() decomposed into ${items.length} items (${detail.volumes.length} vols, ${detail.chapters.length + detail.specials.length} chapters)`);
|
||||
|
||||
@@ -637,7 +636,7 @@ export class DownloadService {
|
||||
if (!skipSizePrompt && userPrefs?.promptForDownloadSize && items.length > 0) {
|
||||
// Single size call for the whole series, single confirm dialog
|
||||
this.downloadSeriesSize(series.id).pipe(
|
||||
switchMap(async size => this.confirmSize(size, 'series')),
|
||||
switchMap(async size => this.confirmSize(size, DownloadEntityType.Series)),
|
||||
filter(confirmed => confirmed),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe(() => this.enqueueItems(items, series.name, series.libraryId, series.id, 0, collectionId));
|
||||
@@ -647,22 +646,22 @@ export class DownloadService {
|
||||
});
|
||||
}
|
||||
|
||||
private enqueueItems(items: Array<{ entity: Volume | Chapter | ReadingListItem; entityType: 'volume' | 'chapter' | 'readinglist-item' }>, seriesName: string, libraryId: number, seriesId = 0, readingListId = 0, collectionId = 0) {
|
||||
private enqueueItems(items: Array<{ entity: Volume | Chapter | ReadingListItem; entityType: DistilledDownloadEntityType }>, seriesName: string, libraryId: number, seriesId = 0, readingListId = 0, collectionId = 0) {
|
||||
this.debugLog(`enqueueItems() adding ${items.length} items for series "${seriesName}"`);
|
||||
|
||||
const volumeItems = items.filter(i => i.entityType === 'volume');
|
||||
const chapterItems = items.filter(i => i.entityType === 'chapter');
|
||||
const rliItems = items.filter(i => i.entityType === 'readinglist-item');
|
||||
const volumeItems = items.filter(i => i.entityType === DownloadEntityType.Volume);
|
||||
const chapterItems = items.filter(i => i.entityType === DownloadEntityType.Chapter);
|
||||
const rliItems = items.filter(i => i.entityType === DownloadEntityType.ReadingListItem);
|
||||
|
||||
const volSizes$ = volumeItems.length > 0
|
||||
? this.getBulkEntityDownloadSize('volume', volumeItems.map(i => i.entity.id))
|
||||
? this.getBulkEntityDownloadSize(DownloadEntityType.Volume, volumeItems.map(i => i.entity.id))
|
||||
: of({} as Record<number, number>);
|
||||
const chSizes$ = chapterItems.length > 0
|
||||
? this.getBulkEntityDownloadSize('chapter', chapterItems.map(i => i.entity.id))
|
||||
? this.getBulkEntityDownloadSize(DownloadEntityType.Chapter, chapterItems.map(i => i.entity.id))
|
||||
: of({} as Record<number, number>);
|
||||
// ReadingListItems download via the chapter endpoint, so fetch chapter sizes using chapterId
|
||||
const rliSizes$ = rliItems.length > 0
|
||||
? this.getBulkEntityDownloadSize('chapter', rliItems.map(i => (i.entity as ReadingListItem).chapterId))
|
||||
? this.getBulkEntityDownloadSize(DownloadEntityType.Chapter, rliItems.map(i => (i.entity as ReadingListItem).chapterId))
|
||||
: of({} as Record<number, number>);
|
||||
|
||||
forkJoin([volSizes$, chSizes$, rliSizes$]).pipe(
|
||||
@@ -672,13 +671,13 @@ export class DownloadService {
|
||||
let size: number;
|
||||
|
||||
switch (item.entityType) {
|
||||
case "volume":
|
||||
case DownloadEntityType.Volume:
|
||||
size = volMap[item.entity.id] ?? 0;
|
||||
break;
|
||||
case "chapter":
|
||||
case DownloadEntityType.Chapter:
|
||||
size = chMap[item.entity.id] ?? 0;
|
||||
break;
|
||||
case "readinglist-item":
|
||||
case DownloadEntityType.ReadingListItem:
|
||||
size = rlMap[(item.entity as ReadingListItem).chapterId] ?? 0;
|
||||
break;
|
||||
}
|
||||
@@ -689,9 +688,9 @@ export class DownloadService {
|
||||
});
|
||||
}
|
||||
|
||||
private enqueueSingle(entity: Volume | Chapter, entityType: 'volume' | 'chapter', seriesName: string, libraryId: number, seriesId = 0, readingListId = 0, collectionId = 0) {
|
||||
private enqueueSingle(entity: Volume | Chapter, entityType: DownloadEntityType.Volume | DownloadEntityType.Chapter, seriesName: string, libraryId: number, seriesId = 0, readingListId = 0, collectionId = 0) {
|
||||
const user = this.accountService.currentUser();
|
||||
const sizeCall$ = entityType === 'volume'
|
||||
const sizeCall$ = entityType === DownloadEntityType.Volume
|
||||
? this.downloadBulkVolumeSizes([entity.id]).pipe(map(m => m[entity.id] ?? 0))
|
||||
: this.downloadBulkChapterSizes([entity.id]).pipe(map(m => m[entity.id] ?? 0));
|
||||
|
||||
@@ -734,7 +733,7 @@ export class DownloadService {
|
||||
}
|
||||
}
|
||||
|
||||
private async addToQueue(entity: Volume | Chapter | ReadingListItem, entityType: 'volume' | 'chapter' | 'readinglist-item', seriesName: string, libraryId: number, estimatedSize = 0, seriesId = 0, readingListId = 0, collectionId = 0, skipRedownloadPrompt = false) {
|
||||
private async addToQueue(entity: Volume | Chapter | ReadingListItem, entityType: DistilledDownloadEntityType, seriesName: string, libraryId: number, estimatedSize = 0, seriesId = 0, readingListId = 0, collectionId = 0, skipRedownloadPrompt = false) {
|
||||
seriesName = await this.resolveSeriesName(seriesName, seriesId);
|
||||
const entityId = entity.id;
|
||||
const key = this._indexKey(entityType, entityId);
|
||||
@@ -777,11 +776,11 @@ export class DownloadService {
|
||||
let downloadName: string;
|
||||
let chapterId: number | undefined;
|
||||
|
||||
if (entityType === 'volume') {
|
||||
if (entityType === DownloadEntityType.Volume) {
|
||||
const vol = entity as Volume;
|
||||
subLabel = vol.minNumber + '';
|
||||
downloadName = seriesName ? `${seriesName} - Volume ${vol.name}` : `Volume ${vol.name}`;
|
||||
} else if (entityType === 'readinglist-item') {
|
||||
} else if (entityType === DownloadEntityType.ReadingListItem) {
|
||||
const rli = entity as ReadingListItem;
|
||||
subLabel = rli.title;
|
||||
downloadName = seriesName ? `${seriesName} - ${rli.title}` : rli.title;
|
||||
@@ -857,10 +856,10 @@ export class DownloadService {
|
||||
return;
|
||||
}
|
||||
|
||||
// readinglistitem downloads via the chapter endpoint using chapterId
|
||||
const endpoint = item.entityType === 'readinglist-item' ? 'chapter' : item.entityType;
|
||||
const idKey = endpoint === 'volume' ? 'volumeId' : 'chapterId';
|
||||
const idValue = item.entityType === 'readinglist-item' ? item.chapterId! : item.entityId;
|
||||
// readingListItem downloads via the chapter endpoint using chapterId
|
||||
const endpoint = item.entityType === DownloadEntityType.ReadingListItem ? DownloadEntityType.Chapter : item.entityType;
|
||||
const idKey = endpoint === DownloadEntityType.Volume ? 'volumeId' : 'chapterId';
|
||||
const idValue = item.entityType === DownloadEntityType.ReadingListItem ? item.chapterId! : item.entityId;
|
||||
const url = `${this.baseUrl}download/${endpoint}` +
|
||||
`?${idKey}=${idValue}` +
|
||||
`&correlationId=${item.id}` +
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import {HttpParams} from '@angular/common/http';
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {Chapter} from 'src/app/_models/chapter';
|
||||
import {LibraryType} from 'src/app/_models/library/library';
|
||||
import {MangaFormat} from 'src/app/_models/manga-format';
|
||||
import {PaginatedResult} from 'src/app/_models/pagination';
|
||||
import {Series} from 'src/app/_models/series';
|
||||
import {Volume} from 'src/app/_models/volume';
|
||||
import {translate} from "@jsverse/transloco";
|
||||
import {DOCUMENT} from "@angular/common";
|
||||
import {ActionItem} from "../../_models/actionables/action-item";
|
||||
|
||||
@@ -49,34 +47,6 @@ export class UtilityService {
|
||||
return this.mangaFormatKeys.filter(item => MangaFormat[format] === item)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a Chapter name based on the library it's in
|
||||
* @param libraryType
|
||||
* @param includeHash For comics only, includes a # which is used for numbering on cards
|
||||
* @param includeSpace Add a space at the end of the string. if includeHash and includeSpace are true, only hash will be at the end.
|
||||
* @param plural Pluralize word
|
||||
* @returns
|
||||
*/
|
||||
formatChapterName(libraryType: LibraryType, includeHash: boolean = false, includeSpace: boolean = false, plural: boolean = false) {
|
||||
const extra = plural ? 's' : '';
|
||||
|
||||
switch(libraryType) {
|
||||
case LibraryType.Book:
|
||||
case LibraryType.LightNovel:
|
||||
return translate('common.book-num' + extra) + (includeSpace ? ' ' : '');
|
||||
case LibraryType.Comic:
|
||||
case LibraryType.ComicVine:
|
||||
if (includeHash) {
|
||||
return translate('common.issue-hash-num');
|
||||
}
|
||||
return translate('common.issue-num' + extra) + (includeSpace ? ' ' : '');
|
||||
case LibraryType.Images:
|
||||
case LibraryType.Manga:
|
||||
return translate('common.chapter-num' + extra) + (includeSpace ? ' ' : '');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
filter(input: string, filter: string): boolean {
|
||||
if (input === null || filter === null || input === undefined || filter === undefined) return false;
|
||||
const reg = /[_\.\-]/gi;
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
</div>
|
||||
|
||||
<div class="col-auto ms-2 d-none d-md-block">
|
||||
<app-download-button [entity]="volume()" entityType="volume" [libraryId]="libraryId()" [seriesId]="seriesId()" />
|
||||
<app-download-button [entity]="volume()" [entityType]="DownloadEntityType.Volume" [libraryId]="libraryId()" [seriesId]="seriesId()" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {DownloadEntityType} from '../shared/_models/download-queue-item';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
@@ -87,6 +88,7 @@ import {ModalService} from "../_services/modal.service";
|
||||
import {getResolvedData, getWritableResolvedData} from "../../libs/route-util";
|
||||
import {ModalResult} from "../_models/modal/modal-result";
|
||||
import {ChapterCardComponent} from "../cards/chapter-card/chapter-card.component";
|
||||
import {EntityTitleService} from "../_services/entity-title.service";
|
||||
|
||||
enum TabID {
|
||||
Chapters = 'chapters-tab',
|
||||
@@ -168,6 +170,7 @@ interface VolumeCast extends IHasCast {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class VolumeDetailComponent implements OnInit {
|
||||
protected readonly DownloadEntityType = DownloadEntityType;
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
@@ -189,6 +192,7 @@ export class VolumeDetailComponent implements OnInit {
|
||||
private readonly chapterService = inject(ChapterService);
|
||||
private readonly annotationService = inject(AnnotationService);
|
||||
protected readonly breakpointService = inject(BreakpointService);
|
||||
private readonly entityTitleService = inject(EntityTitleService);
|
||||
|
||||
readonly scrollingBlock = viewChild<ElementRef<HTMLDivElement>>('scrollingBlock');
|
||||
|
||||
@@ -324,7 +328,7 @@ export class VolumeDetailComponent implements OnInit {
|
||||
return Math.max(...chapters.map(c => c.ageRating));
|
||||
});
|
||||
|
||||
chapterTabName = computed(() => this.utilityService.formatChapterName(this.libraryType(), false, false, true));
|
||||
chapterTabName = computed(() => this.entityTitleService.formatChapterName(this.libraryType(), true));
|
||||
reviewCount = computed(() => this.userReviews().length + this.plusReviews().length);
|
||||
|
||||
|
||||
|
||||
@@ -173,8 +173,8 @@
|
||||
"delete-selected-label": "Delete selected",
|
||||
"no-data": "{{common.no-data}}",
|
||||
"volume-and-chapter-num": "Volume {{v}} Chapter {{n}}",
|
||||
"volume-num": "Volume {{num}}",
|
||||
"chapter-num": "Chapter {{num}}",
|
||||
"volume-num": "{{entity-title.volume-num}}",
|
||||
"chapter-num": "{{entity-title.chapter-num}}",
|
||||
"rating": "Rating {{r}}",
|
||||
"not-applicable": "Not Applicable",
|
||||
"processed": "Processed",
|
||||
@@ -1594,11 +1594,18 @@
|
||||
|
||||
"entity-title": {
|
||||
"special": "Special",
|
||||
"issue-num": "{{common.issue-hash-num}}",
|
||||
"chapter": "{{common.chapter-num}}",
|
||||
"book-num": "{{common.book-num-shorthand}}",
|
||||
"vol-num": "{{user-scrobble-history.volume-num}}",
|
||||
"single-volume": "Single Volume"
|
||||
"issue-num": "Issue #{{num}}",
|
||||
"chapter-num": "Chapter {{num}}",
|
||||
"book-num": "Book {{num}}",
|
||||
"volume-num": "Volume {{num}}",
|
||||
"single-volume": "Single Volume",
|
||||
|
||||
"book-title": "Book",
|
||||
"book-title-plural": "Books",
|
||||
"issue-title": "Issue",
|
||||
"issue-title-plural": "Issues",
|
||||
"chapter-title": "Chapter",
|
||||
"chapter-title-plural": "Chapters"
|
||||
},
|
||||
|
||||
"external-series-card": {
|
||||
@@ -2915,7 +2922,8 @@
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
"pagination-label": "Activity pagination",
|
||||
"page-info": "Page {{current}} of {{total}} ({{items}} total records)"
|
||||
"page-info": "Page {{current}} of {{total}} ({{items}} total records)",
|
||||
"num-more": "{{num}} more"
|
||||
},
|
||||
|
||||
"user-stats-info-cards": {
|
||||
@@ -3798,11 +3806,6 @@
|
||||
"book-num-shorthand": "Book {{num}}",
|
||||
"issue-num-shorthand": "#{{num}}",
|
||||
"volume-num-shorthand": "Vol {{num}}",
|
||||
"book-nums": "Books",
|
||||
"issue-nums": "Issues",
|
||||
"chapter-nums": "Chapters",
|
||||
"volume-nums": "Volumes",
|
||||
"roles": "Roles",
|
||||
"libraries": "Libraries"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user