v0.8.6.1 - A few small issues Hotfix (#3744)

Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
Co-authored-by: Weblate (bot) <hosted@weblate.org>
Co-authored-by: Lyrq <lyrq.ku@gmail.com>
This commit is contained in:
Joe Milazzo 2025-04-18 05:48:37 -06:00 committed by GitHub
parent 00b759e532
commit ad152aa26a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 159 additions and 38 deletions

View File

@ -28,7 +28,7 @@ body:
label: Kavita Version Number - If you don't see your version number listed, please update Kavita and see if your issue still persists. label: Kavita Version Number - If you don't see your version number listed, please update Kavita and see if your issue still persists.
multiple: false multiple: false
options: options:
- 0.8.6.0 - Stable - 0.8.6.1 - Stable
- Nightly Testing Branch - Nightly Testing Branch
validations: validations:
required: true required: true

View File

@ -1,4 +1,6 @@
using System.Threading.Tasks; using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data; using API.Data;
using API.Data.Repositories; using API.Data.Repositories;
using API.DTOs; using API.DTOs;
@ -54,4 +56,29 @@ public class VolumeController : BaseApiController
return Ok(false); return Ok(false);
} }
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("multiple")]
public async Task<ActionResult<bool>> DeleteMultipleVolumes(int[] volumesIds)
{
var volumes = await _unitOfWork.VolumeRepository.GetVolumesById(volumesIds);
if (volumes.Count != volumesIds.Length)
{
return BadRequest(_localizationService.Translate(User.GetUserId(), "volume-doesnt-exist"));
}
_unitOfWork.VolumeRepository.Remove(volumes);
if (!await _unitOfWork.CommitAsync())
{
return Ok(false);
}
foreach (var volume in volumes)
{
await _eventHub.SendMessageAsync(MessageFactory.VolumeRemoved, MessageFactory.VolumeRemovedEvent(volume.Id, volume.SeriesId), false);
}
return Ok(true);
}
} }

View File

@ -35,6 +35,7 @@ public interface IVolumeRepository
void Add(Volume volume); void Add(Volume volume);
void Update(Volume volume); void Update(Volume volume);
void Remove(Volume volume); void Remove(Volume volume);
void Remove(IList<Volume> volumes);
Task<IList<MangaFile>> GetFilesForVolume(int volumeId); Task<IList<MangaFile>> GetFilesForVolume(int volumeId);
Task<string?> GetVolumeCoverImageAsync(int volumeId); Task<string?> GetVolumeCoverImageAsync(int volumeId);
Task<IList<int>> GetChapterIdsByVolumeIds(IReadOnlyList<int> volumeIds); Task<IList<int>> GetChapterIdsByVolumeIds(IReadOnlyList<int> volumeIds);
@ -43,6 +44,7 @@ public interface IVolumeRepository
Task<VolumeDto?> GetVolumeDtoAsync(int volumeId, int userId); Task<VolumeDto?> GetVolumeDtoAsync(int volumeId, int userId);
Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(IList<int> seriesIds, bool includeChapters = false); Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(IList<int> seriesIds, bool includeChapters = false);
Task<IEnumerable<Volume>> GetVolumes(int seriesId); Task<IEnumerable<Volume>> GetVolumes(int seriesId);
Task<IList<Volume>> GetVolumesById(IList<int> volumeIds, VolumeIncludes includes = VolumeIncludes.None);
Task<Volume?> GetVolumeByIdAsync(int volumeId); Task<Volume?> GetVolumeByIdAsync(int volumeId);
Task<IList<Volume>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat); Task<IList<Volume>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
Task<IEnumerable<string>> GetCoverImagesForLockedVolumesAsync(); Task<IEnumerable<string>> GetCoverImagesForLockedVolumesAsync();
@ -72,6 +74,10 @@ public class VolumeRepository : IVolumeRepository
{ {
_context.Volume.Remove(volume); _context.Volume.Remove(volume);
} }
public void Remove(IList<Volume> volumes)
{
_context.Volume.RemoveRange(volumes);
}
/// <summary> /// <summary>
/// Returns a list of non-tracked files for a given volume. /// Returns a list of non-tracked files for a given volume.
@ -180,6 +186,15 @@ public class VolumeRepository : IVolumeRepository
.OrderBy(vol => vol.MinNumber) .OrderBy(vol => vol.MinNumber)
.ToListAsync(); .ToListAsync();
} }
public async Task<IList<Volume>> GetVolumesById(IList<int> volumeIds, VolumeIncludes includes = VolumeIncludes.None)
{
return await _context.Volume
.Where(vol => volumeIds.Contains(vol.Id))
.Includes(includes)
.AsSplitQuery()
.OrderBy(vol => vol.MinNumber)
.ToListAsync();
}
/// <summary> /// <summary>
/// Returns a single volume with Chapter and Files /// Returns a single volume with Chapter and Files

View File

@ -204,8 +204,8 @@
"person-image-doesnt-exist": "Osoba nie istnieje w CoversDB", "person-image-doesnt-exist": "Osoba nie istnieje w CoversDB",
"email-taken": "Adres e-mail jest już używany", "email-taken": "Adres e-mail jest już używany",
"kavitaplus-restricted": "Jest to dostępne tylko dla Kavita+", "kavitaplus-restricted": "Jest to dostępne tylko dla Kavita+",
"smart-filter-name-required": "Strona internetowa", "smart-filter-name-required": "Inteligentny filtr wymaga nazwy",
"sidenav-stream-only-delete-smart-filter": "Jedynie filtry filtrowe mogą zostać usunięte z SideNav", "sidenav-stream-only-delete-smart-filter": "Tylko inteligentne filtry mogą zostać usunięte z panelu bocznego",
"dashboard-stream-only-delete-smart-filter": "Tylko inteligentne strumienie filtrów mogą zostać usunięte z rozdzielczości", "dashboard-stream-only-delete-smart-filter": "Tylko inteligentne strumienie filtrów może zostać usunięte z głównego panelu",
"smart-filter-system-name": "Nie można użyć nazwy systemu dostarczanego strumieniem" "smart-filter-system-name": "Nie można użyć nazwy systemu dostarczanego strumieniem"
} }

View File

@ -472,8 +472,19 @@ export class ActionService {
}); });
} }
async deleteMultipleVolumes(volumes: Array<Volume>, callback?: BooleanActionCallback) {
// TODO: Change translation key back to "toasts.confirm-delete-multiple-volumes"
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-chapters', {count: volumes.length}))) return;
this.volumeService.deleteMultipleVolumes(volumes.map(v => v.id)).subscribe((success) => {
if (callback) {
callback(success);
}
})
}
async deleteMultipleChapters(seriesId: number, chapterIds: Array<Chapter>, callback?: BooleanActionCallback) { async deleteMultipleChapters(seriesId: number, chapterIds: Array<Chapter>, callback?: BooleanActionCallback) {
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-chapters'))) return; if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-chapters', {count: chapterIds.length}))) return;
this.chapterService.deleteMultipleChapters(seriesId, chapterIds.map(c => c.id)).subscribe(() => { this.chapterService.deleteMultipleChapters(seriesId, chapterIds.map(c => c.id)).subscribe(() => {
if (callback) { if (callback) {

View File

@ -21,6 +21,10 @@ export class VolumeService {
return this.httpClient.delete<boolean>(this.baseUrl + 'volume?volumeId=' + volumeId); return this.httpClient.delete<boolean>(this.baseUrl + 'volume?volumeId=' + volumeId);
} }
deleteMultipleVolumes(volumeIds: number[]) {
return this.httpClient.post<boolean>(this.baseUrl + "volume/multiple", volumeIds)
}
updateVolume(volume: any) { updateVolume(volume: any) {
return this.httpClient.post(this.baseUrl + 'volume/update', volume, TextResonse); return this.httpClient.post(this.baseUrl + 'volume/update', volume, TextResonse);
} }

View File

@ -1,7 +1,7 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core'; import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {ToastrService} from 'ngx-toastr'; import {ToastrService} from 'ngx-toastr';
import {debounceTime, distinctUntilChanged, filter, switchMap, tap} from 'rxjs'; import {catchError, debounceTime, distinctUntilChanged, filter, of, switchMap, tap} from 'rxjs';
import {SettingsService} from '../settings.service'; import {SettingsService} from '../settings.service';
import {ServerSettings} from '../_models/server-settings'; import {ServerSettings} from '../_models/server-settings';
import {translate, TranslocoModule} from "@jsverse/transloco"; import {translate, TranslocoModule} from "@jsverse/transloco";
@ -46,15 +46,21 @@ export class ManageEmailSettingsComponent implements OnInit {
// Automatically save settings as we edit them // Automatically save settings as we edit them
this.settingsForm.valueChanges.pipe( this.settingsForm.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(), distinctUntilChanged(),
debounceTime(300),
filter(_ => this.settingsForm.valid), filter(_ => this.settingsForm.valid),
takeUntilDestroyed(this.destroyRef), takeUntilDestroyed(this.destroyRef),
switchMap(_ => { switchMap(_ => {
const data = this.packData(); const data = this.packData();
return this.settingsService.updateServerSettings(data); return this.settingsService.updateServerSettings(data).pipe(catchError(err => {
console.error(err);
return of(null);
}));
}), }),
tap(settings => { tap(settings => {
if (!settings) {
return;
}
this.serverSettings = settings; this.serverSettings = settings;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}) })

View File

@ -1,7 +1,7 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core'; import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {ToastrService} from 'ngx-toastr'; import {ToastrService} from 'ngx-toastr';
import {debounceTime, distinctUntilChanged, filter, switchMap, take, tap} from 'rxjs'; import {catchError, debounceTime, distinctUntilChanged, filter, of, switchMap, take, tap} from 'rxjs';
import {SettingsService} from '../settings.service'; import {SettingsService} from '../settings.service';
import {ServerSettings} from '../_models/server-settings'; import {ServerSettings} from '../_models/server-settings';
import {DirectoryPickerComponent, DirectoryPickerResult} from '../_modals/directory-picker/directory-picker.component'; import {DirectoryPickerComponent, DirectoryPickerResult} from '../_modals/directory-picker/directory-picker.component';
@ -55,9 +55,15 @@ export class ManageMediaSettingsComponent implements OnInit {
takeUntilDestroyed(this.destroyRef), takeUntilDestroyed(this.destroyRef),
switchMap(_ => { switchMap(_ => {
const data = this.packData(); const data = this.packData();
return this.settingsService.updateServerSettings(data); return this.settingsService.updateServerSettings(data).pipe(catchError(err => {
console.error(err);
return of(null);
}));
}), }),
tap(settings => { tap(settings => {
if (!settings) {
return;
}
const encodingChanged = this.serverSettings.encodeMediaAs !== settings.encodeMediaAs; const encodingChanged = this.serverSettings.encodeMediaAs !== settings.encodeMediaAs;
if (encodingChanged) { if (encodingChanged) {

View File

@ -10,7 +10,7 @@ import {WikiLink} from "../../_models/wiki";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component"; import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
import {ConfirmService} from "../../shared/confirm.service"; import {ConfirmService} from "../../shared/confirm.service";
import {debounceTime, distinctUntilChanged, filter, switchMap, tap} from "rxjs"; import {catchError, debounceTime, distinctUntilChanged, filter, of, switchMap, tap} from "rxjs";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {EnterBlurDirective} from "../../_directives/enter-blur.directive"; import {EnterBlurDirective} from "../../_directives/enter-blur.directive";
@ -40,6 +40,7 @@ export class ManageSettingsComponent implements OnInit {
settingsForm: FormGroup = new FormGroup({}); settingsForm: FormGroup = new FormGroup({});
taskFrequencies: Array<string> = []; taskFrequencies: Array<string> = [];
logLevels: Array<string> = []; logLevels: Array<string> = [];
isDocker: boolean = false;
allowStatsTooltip = translate('manage-settings.allow-stats-tooltip-part-1') + ' <a href="' + allowStatsTooltip = translate('manage-settings.allow-stats-tooltip-part-1') + ' <a href="' +
WikiLink.DataCollection + WikiLink.DataCollection +
@ -84,9 +85,16 @@ export class ManageSettingsComponent implements OnInit {
takeUntilDestroyed(this.destroyRef), takeUntilDestroyed(this.destroyRef),
switchMap(_ => { switchMap(_ => {
const data = this.packData(); const data = this.packData();
return this.settingsService.updateServerSettings(data); return this.settingsService.updateServerSettings(data).pipe(catchError(err => {
console.error(err);
return of(null);
}));
}), }),
tap(settings => { tap(settings => {
if (!settings) {
return
}
this.serverSettings = settings; this.serverSettings = settings;
this.resetForm(); this.resetForm();
this.cdRef.markForCheck(); this.cdRef.markForCheck();
@ -94,6 +102,7 @@ export class ManageSettingsComponent implements OnInit {
).subscribe(); ).subscribe();
this.serverService.getServerInfo().subscribe(info => { this.serverService.getServerInfo().subscribe(info => {
this.isDocker = info.isDocker;
if (info.isDocker) { if (info.isDocker) {
this.settingsForm.get('ipAddresses')?.disable(); this.settingsForm.get('ipAddresses')?.disable();
this.settingsForm.get('port')?.disable(); this.settingsForm.get('port')?.disable();
@ -130,12 +139,18 @@ export class ManageSettingsComponent implements OnInit {
} }
packData() { packData() {
const modelSettings = this.settingsForm.value; const modelSettings: ServerSettings = this.settingsForm.value;
modelSettings.bookmarksDirectory = this.serverSettings.bookmarksDirectory; modelSettings.bookmarksDirectory = this.serverSettings.bookmarksDirectory;
modelSettings.smtpConfig = this.serverSettings.smtpConfig; modelSettings.smtpConfig = this.serverSettings.smtpConfig;
modelSettings.installId = this.serverSettings.installId; modelSettings.installId = this.serverSettings.installId;
modelSettings.installVersion = this.serverSettings.installVersion; modelSettings.installVersion = this.serverSettings.installVersion;
// Disabled FormControls are not added to the value
if (this.isDocker) {
modelSettings.ipAddresses = this.serverSettings.ipAddresses;
modelSettings.port = this.serverSettings.port;
}
return modelSettings; return modelSettings;
} }

View File

@ -4,7 +4,18 @@ import {ToastrService} from 'ngx-toastr';
import {SettingsService} from '../settings.service'; import {SettingsService} from '../settings.service';
import {ServerSettings} from '../_models/server-settings'; import {ServerSettings} from '../_models/server-settings';
import {shareReplay} from 'rxjs/operators'; import {shareReplay} from 'rxjs/operators';
import {debounceTime, defer, distinctUntilChanged, filter, forkJoin, Observable, of, switchMap, tap} from 'rxjs'; import {
catchError,
debounceTime,
defer,
distinctUntilChanged,
filter,
forkJoin,
Observable,
of,
switchMap,
tap
} from 'rxjs';
import {ServerService} from 'src/app/_services/server.service'; import {ServerService} from 'src/app/_services/server.service';
import {Job} from 'src/app/_models/job/job'; import {Job} from 'src/app/_models/job/job';
import {UpdateNotificationModalComponent} from 'src/app/announcements/_components/update-notification/update-notification-modal.component'; import {UpdateNotificationModalComponent} from 'src/app/announcements/_components/update-notification/update-notification-modal.component';
@ -173,9 +184,15 @@ export class ManageTasksSettingsComponent implements OnInit {
// }), // }),
switchMap(_ => { switchMap(_ => {
const data = this.packData(); const data = this.packData();
return this.settingsService.updateServerSettings(data); return this.settingsService.updateServerSettings(data).pipe(catchError(err => {
console.error(err);
return of(null);
}));
}), }),
tap(settings => { tap(settings => {
if (!settings) {
return;
}
this.serverSettings = settings; this.serverSettings = settings;
this.recurringTasks$ = this.serverService.getRecurringJobs().pipe(shareReplay()); this.recurringTasks$ = this.serverService.getRecurringJobs().pipe(shareReplay());

View File

@ -53,23 +53,22 @@
</div> </div>
@if (libraryType !== LibraryType.Images) { @if (libraryType !== LibraryType.Images) {
@if ((libraryType === LibraryType.LightNovel || libraryType === LibraryType.Book)) {
@if (volume.name) {
<div class="card-body meta-title"> <div class="card-body meta-title">
<span class="card-format"></span>
<div class="card-content d-flex justify-content-center align-items-center text-center" style="width:100%;min-height:58px;"> <div class="card-content d-flex justify-content-center align-items-center text-center" style="width:100%;min-height:58px;">
@if (libraryType === LibraryType.LightNovel || libraryType === LibraryType.Book) {
{{volume.name}} {{volume.name}}
} @else { </div>
</div>
}
} @else if (volume.chapters[0].titleName) {
<div class="card-body meta-title">
<div class="card-content d-flex justify-content-center align-items-center text-center" style="width:100%;min-height:58px;">
{{volume.chapters[0].titleName}} {{volume.chapters[0].titleName}}
}
</div> </div>
@if (actions && actions.length > 0) {
<span class="card-actions">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="volume.name"></app-card-actionables>
</span>
}
</div> </div>
} }
}
<div class="card-title-container"> <div class="card-title-container">
<span class="card-format"> <span class="card-format">

View File

@ -82,7 +82,7 @@ export class DraggableOrderedListComponent {
get BufferAmount() { get BufferAmount() {
return Math.min(this.items.length / 20, 20); return Math.floor(Math.min(this.items.length / 20, 20));
} }
constructor() { constructor() {

View File

@ -115,6 +115,7 @@ import {CoverImageComponent} from "../../../_single-module/cover-image/cover-ima
import {DefaultModalOptions} from "../../../_models/default-modal-options"; import {DefaultModalOptions} from "../../../_models/default-modal-options";
import {LicenseService} from "../../../_services/license.service"; import {LicenseService} from "../../../_services/license.service";
import {PageBookmark} from "../../../_models/readers/page-bookmark"; import {PageBookmark} from "../../../_models/readers/page-bookmark";
import {VolumeRemovedEvent} from "../../../_models/events/volume-removed-event";
enum TabID { enum TabID {
@ -353,11 +354,25 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.cdRef.markForCheck(); this.cdRef.markForCheck();
break; break;
case Action.Delete: case Action.Delete:
if (chapters.length > 0) {
await this.actionService.deleteMultipleChapters(seriesId, chapters, () => { await this.actionService.deleteMultipleChapters(seriesId, chapters, () => {
// No need to update the page as the backend will spam volume/chapter deletions // No need to update the page as the backend will spam volume/chapter deletions
this.bulkSelectionService.deselectAll(); this.bulkSelectionService.deselectAll();
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });
// It's not possible to select both chapters and volumes
break;
}
if (selectedVolumeIds.length > 0) {
await this.actionService.deleteMultipleVolumes(selectedVolumeIds, () => {
// No need to update the page as the backend will spam volume deletions
this.bulkSelectionService.deselectAll();
this.cdRef.markForCheck();
});
}
break; break;
} }
} }
@ -486,6 +501,11 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
const removedEvent = event.payload as ChapterRemovedEvent; const removedEvent = event.payload as ChapterRemovedEvent;
if (removedEvent.seriesId !== this.seriesId) return; if (removedEvent.seriesId !== this.seriesId) return;
this.loadPageSource.next(false); this.loadPageSource.next(false);
} else if (event.event === EVENTS.VolumeRemoved) {
const volumeRemoveEvent = event.payload as VolumeRemovedEvent;
if (volumeRemoveEvent.seriesId === this.seriesId) {
this.loadPageSource.next(false);
}
} }
}); });

View File

@ -2628,6 +2628,7 @@
"alert-long-running": "This is a long running process. Please give it the time to complete before invoking again.", "alert-long-running": "This is a long running process. Please give it the time to complete before invoking again.",
"confirm-delete-multiple-series": "Are you sure you want to delete {{count}} series? It will not modify files on disk.", "confirm-delete-multiple-series": "Are you sure you want to delete {{count}} series? It will not modify files on disk.",
"confirm-delete-multiple-chapters": "Are you sure you want to delete {{count}} chapter/volumes? It will not modify files on disk.", "confirm-delete-multiple-chapters": "Are you sure you want to delete {{count}} chapter/volumes? It will not modify files on disk.",
"confirm-delete-multiple-volumes": "Are you sure you want to delete {{count}} volumes? It will not modify files on disk.",
"confirm-delete-series": "Are you sure you want to delete this series? It will not modify files on disk.", "confirm-delete-series": "Are you sure you want to delete this series? It will not modify files on disk.",
"confirm-delete-chapter": "Are you sure you want to delete this chapter? It will not modify files on disk.", "confirm-delete-chapter": "Are you sure you want to delete this chapter? It will not modify files on disk.",
"confirm-delete-volume": "Are you sure you want to delete this volume? It will not modify files on disk.", "confirm-delete-volume": "Are you sure you want to delete this volume? It will not modify files on disk.",