Backend Bugfixes and Enhanced Selections (#754)

* Updated some signatures to avoid a ToArray() within a loop.

* Use UpdateSeries directly when adding new series, rather than a modified version for new series only.

* Refactored some messages for scanner loop to reduce duplicate code and write messages more clear. Hooked in a RefreshMetadataProgress event (no UI changes).

* Fixed a bug on docker where backup service was using different logic than non-docker, which isn't needed after config change last release.

* Allow user to make more than 1 backup per day

* Implemented a select all checkbox for library access modal
This commit is contained in:
Joseph Milazzo 2021-11-14 10:20:12 -06:00 committed by GitHub
parent 0aff08c9cd
commit f6bfabde4c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 171 additions and 101 deletions

View File

@ -26,8 +26,6 @@ namespace API.Data
"temp" "temp"
}; };
private static readonly string ConfigDirectory = Path.Join(Directory.GetCurrentDirectory(), "config");
/// <summary> /// <summary>
/// In v0.4.8 we moved all config files to config/ to match with how docker was setup. This will move all config files from current directory /// In v0.4.8 we moved all config files to config/ to match with how docker was setup. This will move all config files from current directory
@ -66,8 +64,8 @@ namespace API.Data
Console.WriteLine( Console.WriteLine(
"Migrating files from pre-v0.4.8. All Kavita config files are now located in config/"); "Migrating files from pre-v0.4.8. All Kavita config files are now located in config/");
Console.WriteLine($"Creating {ConfigDirectory}"); Console.WriteLine($"Creating {DirectoryService.ConfigDirectory}");
DirectoryService.ExistOrCreate(ConfigDirectory); DirectoryService.ExistOrCreate(DirectoryService.ConfigDirectory);
try try
{ {
@ -116,13 +114,13 @@ namespace API.Data
foreach (var folderToMove in AppFolders) foreach (var folderToMove in AppFolders)
{ {
if (new DirectoryInfo(Path.Join(ConfigDirectory, folderToMove)).Exists) continue; if (new DirectoryInfo(Path.Join(DirectoryService.ConfigDirectory, folderToMove)).Exists) continue;
try try
{ {
DirectoryService.CopyDirectoryToDirectory( DirectoryService.CopyDirectoryToDirectory(
Path.Join(Directory.GetCurrentDirectory(), folderToMove), Path.Join(Directory.GetCurrentDirectory(), folderToMove),
Path.Join(ConfigDirectory, folderToMove)); Path.Join(DirectoryService.ConfigDirectory, folderToMove));
} }
catch (Exception) catch (Exception)
{ {
@ -144,7 +142,7 @@ namespace API.Data
{ {
try try
{ {
fileInfo.CopyTo(Path.Join(ConfigDirectory, fileInfo.Name)); fileInfo.CopyTo(Path.Join(DirectoryService.ConfigDirectory, fileInfo.Name));
} }
catch (Exception) catch (Exception)
{ {

View File

@ -266,7 +266,7 @@ namespace API.Services
/// <param name="directoryPath"></param> /// <param name="directoryPath"></param>
/// <param name="prepend">An optional string to prepend to the target file's name</param> /// <param name="prepend">An optional string to prepend to the target file's name</param>
/// <returns></returns> /// <returns></returns>
public bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "") public static bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "", ILogger logger = null)
{ {
ExistOrCreate(directoryPath); ExistOrCreate(directoryPath);
string currentFile = null; string currentFile = null;
@ -282,19 +282,24 @@ namespace API.Services
} }
else else
{ {
_logger.LogWarning("Tried to copy {File} but it doesn't exist", file); logger?.LogWarning("Tried to copy {File} but it doesn't exist", file);
} }
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Unable to copy {File} to {DirectoryPath}", currentFile, directoryPath); logger?.LogError(ex, "Unable to copy {File} to {DirectoryPath}", currentFile, directoryPath);
return false; return false;
} }
return true; return true;
} }
public bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "")
{
return CopyFilesToDirectory(filePaths, directoryPath, prepend, _logger);
}
public IEnumerable<string> ListDirectory(string rootPath) public IEnumerable<string> ListDirectory(string rootPath)
{ {
if (!Directory.Exists(rootPath)) return ImmutableList<string>.Empty; if (!Directory.Exists(rootPath)) return ImmutableList<string>.Empty;

View File

@ -218,14 +218,18 @@ namespace API.Services
var stopwatch = Stopwatch.StartNew(); var stopwatch = Stopwatch.StartNew();
var totalTime = 0L; var totalTime = 0L;
_logger.LogInformation("[MetadataService] Refreshing Library {LibraryName}. Total Items: {TotalSize}. Total Chunks: {TotalChunks} with {ChunkSize} size", library.Name, chunkInfo.TotalSize, chunkInfo.TotalChunks, chunkInfo.ChunkSize); _logger.LogInformation("[MetadataService] Refreshing Library {LibraryName}. Total Items: {TotalSize}. Total Chunks: {TotalChunks} with {ChunkSize} size", library.Name, chunkInfo.TotalSize, chunkInfo.TotalChunks, chunkInfo.ChunkSize);
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress,
MessageFactory.RefreshMetadataProgressEvent(library.Id, 0F));
for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++) var i = 0;
for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++, i++)
{ {
if (chunkInfo.TotalChunks == 0) continue; if (chunkInfo.TotalChunks == 0) continue;
totalTime += stopwatch.ElapsedMilliseconds; totalTime += stopwatch.ElapsedMilliseconds;
stopwatch.Restart(); stopwatch.Restart();
_logger.LogInformation("[MetadataService] Processing chunk {ChunkNumber} / {TotalChunks} with size {ChunkSize}. Series ({SeriesStart} - {SeriesEnd}", _logger.LogInformation("[MetadataService] Processing chunk {ChunkNumber} / {TotalChunks} with size {ChunkSize}. Series ({SeriesStart} - {SeriesEnd}",
chunk, chunkInfo.TotalChunks, chunkInfo.ChunkSize, chunk * chunkInfo.ChunkSize, (chunk + 1) * chunkInfo.ChunkSize); chunk, chunkInfo.TotalChunks, chunkInfo.ChunkSize, chunk * chunkInfo.ChunkSize, (chunk + 1) * chunkInfo.ChunkSize);
var nonLibrarySeries = await _unitOfWork.SeriesRepository.GetFullSeriesForLibraryIdAsync(library.Id, var nonLibrarySeries = await _unitOfWork.SeriesRepository.GetFullSeriesForLibraryIdAsync(library.Id,
new UserParams() new UserParams()
{ {
@ -233,6 +237,7 @@ namespace API.Services
PageSize = chunkInfo.ChunkSize PageSize = chunkInfo.ChunkSize
}); });
_logger.LogDebug("[MetadataService] Fetched {SeriesCount} series for refresh", nonLibrarySeries.Count); _logger.LogDebug("[MetadataService] Fetched {SeriesCount} series for refresh", nonLibrarySeries.Count);
Parallel.ForEach(nonLibrarySeries, series => Parallel.ForEach(nonLibrarySeries, series =>
{ {
try try
@ -275,8 +280,14 @@ namespace API.Services
"[MetadataService] Processed {SeriesStart} - {SeriesEnd} out of {TotalSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", "[MetadataService] Processed {SeriesStart} - {SeriesEnd} out of {TotalSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}",
chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, chunkInfo.TotalSize, stopwatch.ElapsedMilliseconds, library.Name); chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, chunkInfo.TotalSize, stopwatch.ElapsedMilliseconds, library.Name);
} }
var progress = Math.Max(0F, Math.Min(100F, i * 1F / chunkInfo.TotalChunks));
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress,
MessageFactory.RefreshMetadataProgressEvent(library.Id, progress));
} }
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress,
MessageFactory.RefreshMetadataProgressEvent(library.Id, 100F));
_logger.LogInformation("[MetadataService] Updated metadata for {SeriesNumber} series in library {LibraryName} in {ElapsedMilliseconds} milliseconds total", chunkInfo.TotalSize, library.Name, totalTime); _logger.LogInformation("[MetadataService] Updated metadata for {SeriesNumber} series in library {LibraryName} in {ElapsedMilliseconds} milliseconds total", chunkInfo.TotalSize, library.Name, totalTime);
} }

View File

@ -9,7 +9,6 @@ using API.Extensions;
using API.Interfaces; using API.Interfaces;
using API.Interfaces.Services; using API.Interfaces.Services;
using Hangfire; using Hangfire;
using Kavita.Common.EnvironmentInfo;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -20,8 +19,6 @@ namespace API.Services.Tasks
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<BackupService> _logger; private readonly ILogger<BackupService> _logger;
private readonly IDirectoryService _directoryService; private readonly IDirectoryService _directoryService;
private readonly string _tempDirectory = DirectoryService.TempDirectory;
private readonly string _logDirectory = DirectoryService.LogDirectory;
private readonly IList<string> _backupFiles; private readonly IList<string> _backupFiles;
@ -35,30 +32,16 @@ namespace API.Services.Tasks
var loggingSection = config.GetLoggingFileName(); var loggingSection = config.GetLoggingFileName();
var files = LogFiles(maxRollingFiles, loggingSection); var files = LogFiles(maxRollingFiles, loggingSection);
if (new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker)
_backupFiles = new List<string>()
{ {
_backupFiles = new List<string>() "appsettings.json",
{ "Hangfire.db", // This is not used atm
"data/appsettings.json", "Hangfire-log.db", // This is not used atm
"data/Hangfire.db", "kavita.db",
"data/Hangfire-log.db", "kavita.db-shm", // This wont always be there
"data/kavita.db", "kavita.db-wal" // This wont always be there
"data/kavita.db-shm", // This wont always be there };
"data/kavita.db-wal" // This wont always be there
};
}
else
{
_backupFiles = new List<string>()
{
"appsettings.json",
"Hangfire.db",
"Hangfire-log.db",
"kavita.db",
"kavita.db-shm", // This wont always be there
"kavita.db-wal" // This wont always be there
};
}
foreach (var file in files.Select(f => (new FileInfo(f)).Name).ToList()) foreach (var file in files.Select(f => (new FileInfo(f)).Name).ToList())
{ {
@ -72,7 +55,7 @@ namespace API.Services.Tasks
var fi = new FileInfo(logFileName); var fi = new FileInfo(logFileName);
var files = maxRollingFiles > 0 var files = maxRollingFiles > 0
? DirectoryService.GetFiles(_logDirectory, $@"{Path.GetFileNameWithoutExtension(fi.Name)}{multipleFileRegex}\.log") ? DirectoryService.GetFiles(DirectoryService.LogDirectory, $@"{Path.GetFileNameWithoutExtension(fi.Name)}{multipleFileRegex}\.log")
: new[] {"kavita.log"}; : new[] {"kavita.log"};
return files; return files;
} }
@ -93,7 +76,7 @@ namespace API.Services.Tasks
return; return;
} }
var dateString = DateTime.Now.ToShortDateString().Replace("/", "_"); var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace("/", "_").Replace(":", "_");
var zipPath = Path.Join(backupDirectory, $"kavita_backup_{dateString}.zip"); var zipPath = Path.Join(backupDirectory, $"kavita_backup_{dateString}.zip");
if (File.Exists(zipPath)) if (File.Exists(zipPath))
@ -102,7 +85,7 @@ namespace API.Services.Tasks
return; return;
} }
var tempDirectory = Path.Join(_tempDirectory, dateString); var tempDirectory = Path.Join(DirectoryService.TempDirectory, dateString);
DirectoryService.ExistOrCreate(tempDirectory); DirectoryService.ExistOrCreate(tempDirectory);
DirectoryService.ClearDirectory(tempDirectory); DirectoryService.ClearDirectory(tempDirectory);

View File

@ -360,14 +360,13 @@ namespace API.Services.Tasks
Series existingSeries; Series existingSeries;
try try
{ {
existingSeries = allSeries.SingleOrDefault(s => existingSeries = allSeries.SingleOrDefault(s => FindSeries(s, key));
(s.NormalizedName.Equals(key.NormalizedName) || Parser.Parser.Normalize(s.OriginalName).Equals(key.NormalizedName))
&& (s.Format == key.Format || s.Format == MangaFormat.Unknown));
} }
catch (Exception e) catch (Exception e)
{ {
// NOTE: If I ever want to put Duplicates table, this is where it can go
_logger.LogCritical(e, "[ScannerService] There are multiple series that map to normalized key {Key}. You can manually delete the entity via UI and rescan to fix it. This will be skipped", key.NormalizedName); _logger.LogCritical(e, "[ScannerService] There are multiple series that map to normalized key {Key}. You can manually delete the entity via UI and rescan to fix it. This will be skipped", key.NormalizedName);
var duplicateSeries = allSeries.Where(s => s.NormalizedName == key.NormalizedName || Parser.Parser.Normalize(s.OriginalName) == key.NormalizedName).ToList(); var duplicateSeries = allSeries.Where(s => FindSeries(s, key));
foreach (var series in duplicateSeries) foreach (var series in duplicateSeries)
{ {
_logger.LogCritical("[ScannerService] Duplicate Series Found: {Key} maps with {Series}", key.Name, series.OriginalName); _logger.LogCritical("[ScannerService] Duplicate Series Found: {Key} maps with {Series}", key.Name, series.OriginalName);
@ -378,21 +377,20 @@ namespace API.Services.Tasks
if (existingSeries != null) continue; if (existingSeries != null) continue;
existingSeries = DbFactory.Series(infos[0].Series); var s = DbFactory.Series(infos[0].Series);
existingSeries.Format = key.Format; s.Format = key.Format;
newSeries.Add(existingSeries); s.LibraryId = library.Id; // We have to manually set this since we aren't adding the series to the Library's series.
newSeries.Add(s);
} }
var i = 0; var i = 0;
foreach(var series in newSeries) foreach(var series in newSeries)
{ {
_logger.LogDebug("[ScannerService] Processing series {SeriesName}", series.OriginalName);
UpdateSeries(series, parsedSeries);
_unitOfWork.SeriesRepository.Attach(series);
try try
{ {
_logger.LogDebug("[ScannerService] Processing series {SeriesName}", series.OriginalName);
UpdateVolumes(series, ParseScannedFiles.GetInfosByName(parsedSeries, series).ToArray());
series.Pages = series.Volumes.Sum(v => v.Pages);
series.LibraryId = library.Id; // We have to manually set this since we aren't adding the series to the Library's series.
_unitOfWork.SeriesRepository.Attach(series);
await _unitOfWork.CommitAsync(); await _unitOfWork.CommitAsync();
_logger.LogInformation( _logger.LogInformation(
"[ScannerService] Added {NewSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", "[ScannerService] Added {NewSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}",
@ -403,7 +401,7 @@ namespace API.Services.Tasks
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogCritical(ex, "[ScannerService] There was a critical exception adding new series entry for {SeriesName} with a duplicate index key: {IndexKey}", _logger.LogCritical(ex, "[ScannerService] There was a critical exception adding new series entry for {SeriesName} with a duplicate index key: {IndexKey} ",
series.Name, $"{series.Name}_{series.NormalizedName}_{series.LocalizedName}_{series.LibraryId}_{series.Format}"); series.Name, $"{series.Name}_{series.NormalizedName}_{series.LocalizedName}_{series.LibraryId}_{series.Format}");
} }
@ -418,13 +416,19 @@ namespace API.Services.Tasks
newSeries.Count, stopwatch.ElapsedMilliseconds, library.Name); newSeries.Count, stopwatch.ElapsedMilliseconds, library.Name);
} }
private static bool FindSeries(Series series, ParsedSeries parsedInfoKey)
{
return (series.NormalizedName.Equals(parsedInfoKey.NormalizedName) || Parser.Parser.Normalize(series.OriginalName).Equals(parsedInfoKey.NormalizedName))
&& (series.Format == parsedInfoKey.Format || series.Format == MangaFormat.Unknown);
}
private void UpdateSeries(Series series, Dictionary<ParsedSeries, List<ParserInfo>> parsedSeries) private void UpdateSeries(Series series, Dictionary<ParsedSeries, List<ParserInfo>> parsedSeries)
{ {
try try
{ {
_logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName); _logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName);
var parsedInfos = ParseScannedFiles.GetInfosByName(parsedSeries, series).ToArray(); var parsedInfos = ParseScannedFiles.GetInfosByName(parsedSeries, series);
UpdateVolumes(series, parsedInfos); UpdateVolumes(series, parsedInfos);
series.Pages = series.Volumes.Sum(v => v.Pages); series.Pages = series.Volumes.Sum(v => v.Pages);
@ -491,7 +495,7 @@ namespace API.Services.Tasks
/// <param name="missingSeries">Series not found on disk or can't be parsed</param> /// <param name="missingSeries">Series not found on disk or can't be parsed</param>
/// <param name="removeCount"></param> /// <param name="removeCount"></param>
/// <returns>the updated existingSeries</returns> /// <returns>the updated existingSeries</returns>
public static IList<Series> RemoveMissingSeries(IList<Series> existingSeries, IEnumerable<Series> missingSeries, out int removeCount) public static IEnumerable<Series> RemoveMissingSeries(IList<Series> existingSeries, IEnumerable<Series> missingSeries, out int removeCount)
{ {
var existingCount = existingSeries.Count; var existingCount = existingSeries.Count;
var missingList = missingSeries.ToList(); var missingList = missingSeries.ToList();
@ -505,7 +509,7 @@ namespace API.Services.Tasks
return existingSeries; return existingSeries;
} }
private void UpdateVolumes(Series series, ParserInfo[] parsedInfos) private void UpdateVolumes(Series series, IList<ParserInfo> parsedInfos)
{ {
var startingVolumeCount = series.Volumes.Count; var startingVolumeCount = series.Volumes.Count;
// Add new volumes and update chapters per volume // Add new volumes and update chapters per volume
@ -559,7 +563,7 @@ namespace API.Services.Tasks
/// </summary> /// </summary>
/// <param name="volume"></param> /// <param name="volume"></param>
/// <param name="parsedInfos"></param> /// <param name="parsedInfos"></param>
private void UpdateChapters(Volume volume, ParserInfo[] parsedInfos) private void UpdateChapters(Volume volume, IList<ParserInfo> parsedInfos)
{ {
// Add new chapters // Add new chapters
foreach (var info in parsedInfos) foreach (var info in parsedInfos)

View File

@ -60,6 +60,20 @@ namespace API.SignalR
}; };
} }
public static SignalRMessage RefreshMetadataProgressEvent(int libraryId, float progress)
{
return new SignalRMessage()
{
Name = SignalREvents.RefreshMetadataProgress,
Body = new
{
LibraryId = libraryId,
Progress = progress,
EventTime = DateTime.Now
}
};
}
public static SignalRMessage RefreshMetadataEvent(int libraryId, int seriesId) public static SignalRMessage RefreshMetadataEvent(int libraryId, int seriesId)

View File

@ -4,7 +4,14 @@
{ {
public const string UpdateVersion = "UpdateVersion"; public const string UpdateVersion = "UpdateVersion";
public const string ScanSeries = "ScanSeries"; public const string ScanSeries = "ScanSeries";
/// <summary>
/// Event during Refresh Metadata for cover image change
/// </summary>
public const string RefreshMetadata = "RefreshMetadata"; public const string RefreshMetadata = "RefreshMetadata";
/// <summary>
/// Event sent out during Refresh Metadata for progress tracking
/// </summary>
public const string RefreshMetadataProgress = "RefreshMetadataProgress";
public const string ScanLibrary = "ScanLibrary"; public const string ScanLibrary = "ScanLibrary";
public const string SeriesAdded = "SeriesAdded"; public const string SeriesAdded = "SeriesAdded";
public const string SeriesRemoved = "SeriesRemoved"; public const string SeriesRemoved = "SeriesRemoved";

View File

@ -16,6 +16,7 @@ export enum EVENTS {
UpdateAvailable = 'UpdateAvailable', UpdateAvailable = 'UpdateAvailable',
ScanSeries = 'ScanSeries', ScanSeries = 'ScanSeries',
RefreshMetadata = 'RefreshMetadata', RefreshMetadata = 'RefreshMetadata',
RefreshMetadataProgress = 'RefreshMetadataProgress',
SeriesAdded = 'SeriesAdded', SeriesAdded = 'SeriesAdded',
SeriesRemoved = 'SeriesRemoved', SeriesRemoved = 'SeriesRemoved',
ScanLibraryProgress = 'ScanLibraryProgress', ScanLibraryProgress = 'ScanLibraryProgress',
@ -89,6 +90,13 @@ export class MessageHubService {
this.scanLibrary.emit(resp.body); this.scanLibrary.emit(resp.body);
}); });
this.hubConnection.on(EVENTS.RefreshMetadataProgress, resp => {
this.messagesSource.next({
event: EVENTS.RefreshMetadataProgress,
payload: resp.body
});
});
this.hubConnection.on(EVENTS.SeriesAddedToCollection, resp => { this.hubConnection.on(EVENTS.SeriesAddedToCollection, resp => {
this.messagesSource.next({ this.messagesSource.next({
event: EVENTS.SeriesAddedToCollection, event: EVENTS.SeriesAddedToCollection,

View File

@ -6,17 +6,24 @@
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="list-group"> <div class="list-group" *ngIf="!isLoading">
<li class="list-group-item" *ngFor="let library of selectedLibraries; let i = index"> <div class="form-check">
<div class="form-check"> <input id="selectall" type="checkbox" class="form-check-input"
<input id="library-{{i}}" type="checkbox" attr.aria-label="Library {{library.data.name}}" class="form-check-input" [ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
[(ngModel)]="library.selected" name="library"> <label for="selectall" class="form-check-label">{{selectAll ? 'Deselect' : 'Select'}} All</label>
<label attr.for="library-{{i}}" class="form-check-label">{{library.data.name}}</label> </div>
</div> <ul>
</li> <li class="list-group-item" *ngFor="let library of allLibraries; let i = index">
<li class="list-group-item" *ngIf="selectedLibraries.length === 0"> <div class="form-check">
There are no libraries setup yet. <input id="library-{{i}}" type="checkbox" class="form-check-input" attr.aria-label="Library {{library.name}}"
</li> [ngModel]="selections.isSelected(library)" (change)="handleSelection(library)">
<label attr.for="library-{{i}}" class="form-check-label">{{library.name}}</label>
</div>
</li>
<li class="list-group-item" *ngIf="allLibraries.length === 0">
There are no libraries setup yet.
</li>
</ul>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

View File

@ -1,6 +1,7 @@
import { Component, Input, OnInit } from '@angular/core'; import { Component, Input, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms'; import { FormBuilder } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { SelectionModel } from 'src/app/typeahead/typeahead.component';
import { Library } from 'src/app/_models/library'; import { Library } from 'src/app/_models/library';
import { Member } from 'src/app/_models/member'; import { Member } from 'src/app/_models/member';
import { LibraryService } from 'src/app/_services/library.service'; import { LibraryService } from 'src/app/_services/library.service';
@ -15,24 +16,21 @@ export class LibraryAccessModalComponent implements OnInit {
@Input() member: Member | undefined; @Input() member: Member | undefined;
allLibraries: Library[] = []; allLibraries: Library[] = [];
selectedLibraries: Array<{selected: boolean, data: Library}> = []; selectedLibraries: Array<{selected: boolean, data: Library}> = [];
selections!: SelectionModel<Library>;
selectAll: boolean = false;
isLoading: boolean = false;
get hasSomeSelected() {
console.log(this.selections != null && this.selections.hasSomeSelected());
return this.selections != null && this.selections.hasSomeSelected();
}
constructor(public modal: NgbActiveModal, private libraryService: LibraryService, private fb: FormBuilder) { } constructor(public modal: NgbActiveModal, private libraryService: LibraryService, private fb: FormBuilder) { }
ngOnInit(): void { ngOnInit(): void {
this.libraryService.getLibraries().subscribe(libs => { this.libraryService.getLibraries().subscribe(libs => {
this.allLibraries = libs; this.allLibraries = libs;
this.selectedLibraries = libs.map(item => { this.setupSelections();
return {selected: false, data: item};
});
if (this.member !== undefined) {
this.member.libraries.forEach(lib => {
const foundLibrary = this.selectedLibraries.filter(item => item.data.name === lib.name);
if (foundLibrary.length > 0) {
foundLibrary[0].selected = true;
}
});
}
}); });
} }
@ -45,25 +43,41 @@ export class LibraryAccessModalComponent implements OnInit {
return; return;
} }
const selectedLibraries = this.selectedLibraries.filter(item => item.selected).map(item => item.data); const selectedLibraries = this.selections.selected();
this.libraryService.updateLibrariesForMember(this.member?.username, selectedLibraries).subscribe(() => { this.libraryService.updateLibrariesForMember(this.member?.username, selectedLibraries).subscribe(() => {
this.modal.close(true); this.modal.close(true);
}); });
} }
reset() { setupSelections() {
this.selectedLibraries = this.allLibraries.map(item => { this.selections = new SelectionModel<Library>(false, this.allLibraries);
return {selected: false, data: item}; this.isLoading = false;
});
// If a member is passed in, then auto-select their libraries
if (this.member !== undefined) { if (this.member !== undefined) {
this.member.libraries.forEach(lib => { this.member.libraries.forEach(lib => {
const foundLibrary = this.selectedLibraries.filter(item => item.data.name === lib.name); this.selections.toggle(lib, true, (a, b) => a.name === b.name);
if (foundLibrary.length > 0) {
foundLibrary[0].selected = true;
}
}); });
this.selectAll = this.selections.selected().length === this.allLibraries.length;
}
}
reset() {
this.setupSelections();
}
toggleAll() {
this.selectAll = !this.selectAll;
this.allLibraries.forEach(s => this.selections.toggle(s, this.selectAll));
}
handleSelection(item: Library) {
this.selections.toggle(item);
const numberOfSelected = this.selections.selected().length;
if (numberOfSelected == 0) {
this.selectAll = false;
} else if (numberOfSelected == this.selectedLibraries.length) {
this.selectAll = true;
} }
} }

View File

@ -38,7 +38,7 @@ export class ManageLibraryComponent implements OnInit, OnDestroy {
// when a progress event comes in, show it on the UI next to library // when a progress event comes in, show it on the UI next to library
this.hubService.messages$.pipe(takeUntil(this.onDestroy)).subscribe((event) => { this.hubService.messages$.pipe(takeUntil(this.onDestroy)).subscribe((event) => {
if (event.event != EVENTS.ScanLibraryProgress) return; if (event.event !== EVENTS.ScanLibraryProgress) return;
const scanEvent = event.payload as ScanLibraryProgressEvent; const scanEvent = event.payload as ScanLibraryProgressEvent;
this.scanInProgress[scanEvent.libraryId] = {progress: scanEvent.progress !== 100}; this.scanInProgress[scanEvent.libraryId] = {progress: scanEvent.progress !== 100};
@ -55,6 +55,7 @@ export class ManageLibraryComponent implements OnInit, OnDestroy {
} }
}); });
} }
}); });
} }

View File

@ -26,7 +26,7 @@
<h6>Applies to Series</h6> <h6>Applies to Series</h6>
<div class="form-check"> <div class="form-check">
<input id="selectall" type="checkbox" class="form-check-input" <input id="selectall" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="someSelected"> [ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
<label for="selectall" class="form-check-label">{{selectAll ? 'Deselect' : 'Select'}} All</label> <label for="selectall" class="form-check-label">{{selectAll ? 'Deselect' : 'Select'}} All</label>
</div> </div>
<ul> <ul>

View File

@ -35,6 +35,11 @@ export class EditCollectionTagsComponent implements OnInit {
imageUrls: Array<string> = []; imageUrls: Array<string> = [];
selectedCover: string = ''; selectedCover: string = '';
get hasSomeSelected() {
return this.selections != null && this.selections.hasSomeSelected();
}
constructor(public modal: NgbActiveModal, private seriesService: SeriesService, constructor(public modal: NgbActiveModal, private seriesService: SeriesService,
private collectionService: CollectionTagService, private toastr: ToastrService, private collectionService: CollectionTagService, private toastr: ToastrService,
private confirmSerivce: ConfirmService, private libraryService: LibraryService, private confirmSerivce: ConfirmService, private libraryService: LibraryService,
@ -133,11 +138,6 @@ export class EditCollectionTagsComponent implements OnInit {
}); });
} }
get someSelected() {
const selected = this.selections.selected();
return (selected.length !== this.series.length && selected.length !== 0);
}
updateSelectedIndex(index: number) { updateSelectedIndex(index: number) {
this.collectionTagForm.patchValue({ this.collectionTagForm.patchValue({
coverImageIndex: index coverImageIndex: index

View File

@ -5,6 +5,8 @@ import { debounceTime, filter, map, shareReplay, switchMap, take, takeUntil, tap
import { KEY_CODES } from '../shared/_services/utility.service'; import { KEY_CODES } from '../shared/_services/utility.service';
import { TypeaheadSettings } from './typeahead-settings'; import { TypeaheadSettings } from './typeahead-settings';
export type SelectionCompareFn<T> = (a: T, b: T) => boolean;
/** /**
* SelectionModel<T> is used for keeping track of multiple selections. Simple interface with ability to toggle. * SelectionModel<T> is used for keeping track of multiple selections. Simple interface with ability to toggle.
* @param selectedState Optional state to set selectedOptions to. If not passed, defaults to false. * @param selectedState Optional state to set selectedOptions to. If not passed, defaults to false.
@ -30,10 +32,16 @@ export class SelectionModel<T> {
/** /**
* Will toggle if the data item is selected or not. If data option is not tracked, will add it and set state to true. * Will toggle if the data item is selected or not. If data option is not tracked, will add it and set state to true.
* @param data Item to toggle * @param data Item to toggle
* @param selectedState Force the state
* @param compareFn An optional function to use for the lookup, else will use shallowEqual implementation
*/ */
toggle(data: T, selectedState?: boolean) { toggle(data: T, selectedState?: boolean, compareFn?: SelectionCompareFn<T>) {
//const dataItem = this._data.filter(d => d.value == data); let lookupMethod = this.shallowEqual;
const dataItem = this._data.filter(d => this.shallowEqual(d.value, data)); if (compareFn != undefined || compareFn != null) {
lookupMethod = compareFn;
}
const dataItem = this._data.filter(d => lookupMethod(d.value, data));
if (dataItem.length > 0) { if (dataItem.length > 0) {
if (selectedState != undefined) { if (selectedState != undefined) {
dataItem[0].selected = selectedState; dataItem[0].selected = selectedState;
@ -45,6 +53,7 @@ export class SelectionModel<T> {
} }
} }
/** /**
* Is the passed item selected * Is the passed item selected
* @param data item to check against * @param data item to check against
@ -65,6 +74,15 @@ export class SelectionModel<T> {
return false; return false;
} }
/**
*
* @returns If some of the items are selected, but not all
*/
hasSomeSelected(): boolean {
const selectedCount = this._data.filter(d => d.selected).length;
return (selectedCount !== this._data.length && selectedCount !== 0)
}
/** /**
* *
* @returns All Selected items * @returns All Selected items