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"
};
private static readonly string ConfigDirectory = Path.Join(Directory.GetCurrentDirectory(), "config");
/// <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
@ -66,8 +64,8 @@ namespace API.Data
Console.WriteLine(
"Migrating files from pre-v0.4.8. All Kavita config files are now located in config/");
Console.WriteLine($"Creating {ConfigDirectory}");
DirectoryService.ExistOrCreate(ConfigDirectory);
Console.WriteLine($"Creating {DirectoryService.ConfigDirectory}");
DirectoryService.ExistOrCreate(DirectoryService.ConfigDirectory);
try
{
@ -116,13 +114,13 @@ namespace API.Data
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
{
DirectoryService.CopyDirectoryToDirectory(
Path.Join(Directory.GetCurrentDirectory(), folderToMove),
Path.Join(ConfigDirectory, folderToMove));
Path.Join(DirectoryService.ConfigDirectory, folderToMove));
}
catch (Exception)
{
@ -144,7 +142,7 @@ namespace API.Data
{
try
{
fileInfo.CopyTo(Path.Join(ConfigDirectory, fileInfo.Name));
fileInfo.CopyTo(Path.Join(DirectoryService.ConfigDirectory, fileInfo.Name));
}
catch (Exception)
{

View File

@ -266,7 +266,7 @@ namespace API.Services
/// <param name="directoryPath"></param>
/// <param name="prepend">An optional string to prepend to the target file's name</param>
/// <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);
string currentFile = null;
@ -282,19 +282,24 @@ namespace API.Services
}
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)
{
_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 true;
}
public bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "")
{
return CopyFilesToDirectory(filePaths, directoryPath, prepend, _logger);
}
public IEnumerable<string> ListDirectory(string rootPath)
{
if (!Directory.Exists(rootPath)) return ImmutableList<string>.Empty;

View File

@ -218,14 +218,18 @@ namespace API.Services
var stopwatch = Stopwatch.StartNew();
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);
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;
totalTime += stopwatch.ElapsedMilliseconds;
stopwatch.Restart();
_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);
var nonLibrarySeries = await _unitOfWork.SeriesRepository.GetFullSeriesForLibraryIdAsync(library.Id,
new UserParams()
{
@ -233,6 +237,7 @@ namespace API.Services
PageSize = chunkInfo.ChunkSize
});
_logger.LogDebug("[MetadataService] Fetched {SeriesCount} series for refresh", nonLibrarySeries.Count);
Parallel.ForEach(nonLibrarySeries, series =>
{
try
@ -275,8 +280,14 @@ namespace API.Services
"[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);
}
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);
}

View File

@ -9,7 +9,6 @@ using API.Extensions;
using API.Interfaces;
using API.Interfaces.Services;
using Hangfire;
using Kavita.Common.EnvironmentInfo;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
@ -20,8 +19,6 @@ namespace API.Services.Tasks
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<BackupService> _logger;
private readonly IDirectoryService _directoryService;
private readonly string _tempDirectory = DirectoryService.TempDirectory;
private readonly string _logDirectory = DirectoryService.LogDirectory;
private readonly IList<string> _backupFiles;
@ -35,30 +32,16 @@ namespace API.Services.Tasks
var loggingSection = config.GetLoggingFileName();
var files = LogFiles(maxRollingFiles, loggingSection);
if (new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker)
_backupFiles = new List<string>()
{
_backupFiles = new List<string>()
{
"data/appsettings.json",
"data/Hangfire.db",
"data/Hangfire-log.db",
"data/kavita.db",
"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
};
}
"appsettings.json",
"Hangfire.db", // This is not used atm
"Hangfire-log.db", // This is not used atm
"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())
{
@ -72,7 +55,7 @@ namespace API.Services.Tasks
var fi = new FileInfo(logFileName);
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"};
return files;
}
@ -93,7 +76,7 @@ namespace API.Services.Tasks
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");
if (File.Exists(zipPath))
@ -102,7 +85,7 @@ namespace API.Services.Tasks
return;
}
var tempDirectory = Path.Join(_tempDirectory, dateString);
var tempDirectory = Path.Join(DirectoryService.TempDirectory, dateString);
DirectoryService.ExistOrCreate(tempDirectory);
DirectoryService.ClearDirectory(tempDirectory);

View File

@ -360,14 +360,13 @@ namespace API.Services.Tasks
Series existingSeries;
try
{
existingSeries = allSeries.SingleOrDefault(s =>
(s.NormalizedName.Equals(key.NormalizedName) || Parser.Parser.Normalize(s.OriginalName).Equals(key.NormalizedName))
&& (s.Format == key.Format || s.Format == MangaFormat.Unknown));
existingSeries = allSeries.SingleOrDefault(s => FindSeries(s, key));
}
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);
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)
{
_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;
existingSeries = DbFactory.Series(infos[0].Series);
existingSeries.Format = key.Format;
newSeries.Add(existingSeries);
var s = DbFactory.Series(infos[0].Series);
s.Format = key.Format;
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;
foreach(var series in newSeries)
{
_logger.LogDebug("[ScannerService] Processing series {SeriesName}", series.OriginalName);
UpdateSeries(series, parsedSeries);
_unitOfWork.SeriesRepository.Attach(series);
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();
_logger.LogInformation(
"[ScannerService] Added {NewSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}",
@ -403,7 +401,7 @@ namespace API.Services.Tasks
}
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}");
}
@ -418,13 +416,19 @@ namespace API.Services.Tasks
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)
{
try
{
_logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName);
var parsedInfos = ParseScannedFiles.GetInfosByName(parsedSeries, series).ToArray();
var parsedInfos = ParseScannedFiles.GetInfosByName(parsedSeries, series);
UpdateVolumes(series, parsedInfos);
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="removeCount"></param>
/// <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 missingList = missingSeries.ToList();
@ -505,7 +509,7 @@ namespace API.Services.Tasks
return existingSeries;
}
private void UpdateVolumes(Series series, ParserInfo[] parsedInfos)
private void UpdateVolumes(Series series, IList<ParserInfo> parsedInfos)
{
var startingVolumeCount = series.Volumes.Count;
// Add new volumes and update chapters per volume
@ -559,7 +563,7 @@ namespace API.Services.Tasks
/// </summary>
/// <param name="volume"></param>
/// <param name="parsedInfos"></param>
private void UpdateChapters(Volume volume, ParserInfo[] parsedInfos)
private void UpdateChapters(Volume volume, IList<ParserInfo> parsedInfos)
{
// Add new chapters
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)

View File

@ -4,7 +4,14 @@
{
public const string UpdateVersion = "UpdateVersion";
public const string ScanSeries = "ScanSeries";
/// <summary>
/// Event during Refresh Metadata for cover image change
/// </summary>
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 SeriesAdded = "SeriesAdded";
public const string SeriesRemoved = "SeriesRemoved";

View File

@ -16,6 +16,7 @@ export enum EVENTS {
UpdateAvailable = 'UpdateAvailable',
ScanSeries = 'ScanSeries',
RefreshMetadata = 'RefreshMetadata',
RefreshMetadataProgress = 'RefreshMetadataProgress',
SeriesAdded = 'SeriesAdded',
SeriesRemoved = 'SeriesRemoved',
ScanLibraryProgress = 'ScanLibraryProgress',
@ -89,6 +90,13 @@ export class MessageHubService {
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.messagesSource.next({
event: EVENTS.SeriesAddedToCollection,

View File

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

View File

@ -1,6 +1,7 @@
import { Component, Input, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { SelectionModel } from 'src/app/typeahead/typeahead.component';
import { Library } from 'src/app/_models/library';
import { Member } from 'src/app/_models/member';
import { LibraryService } from 'src/app/_services/library.service';
@ -15,24 +16,21 @@ export class LibraryAccessModalComponent implements OnInit {
@Input() member: Member | undefined;
allLibraries: 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) { }
ngOnInit(): void {
this.libraryService.getLibraries().subscribe(libs => {
this.allLibraries = libs;
this.selectedLibraries = libs.map(item => {
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;
}
});
}
this.setupSelections();
});
}
@ -45,25 +43,41 @@ export class LibraryAccessModalComponent implements OnInit {
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.modal.close(true);
});
}
reset() {
this.selectedLibraries = this.allLibraries.map(item => {
return {selected: false, data: item};
});
setupSelections() {
this.selections = new SelectionModel<Library>(false, this.allLibraries);
this.isLoading = false;
// If a member is passed in, then auto-select their libraries
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;
}
this.selections.toggle(lib, true, (a, b) => a.name === b.name);
});
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
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;
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>
<div class="form-check">
<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>
</div>
<ul>

View File

@ -35,6 +35,11 @@ export class EditCollectionTagsComponent implements OnInit {
imageUrls: Array<string> = [];
selectedCover: string = '';
get hasSomeSelected() {
return this.selections != null && this.selections.hasSomeSelected();
}
constructor(public modal: NgbActiveModal, private seriesService: SeriesService,
private collectionService: CollectionTagService, private toastr: ToastrService,
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) {
this.collectionTagForm.patchValue({
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 { 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.
* @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.
* @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) {
//const dataItem = this._data.filter(d => d.value == data);
const dataItem = this._data.filter(d => this.shallowEqual(d.value, data));
toggle(data: T, selectedState?: boolean, compareFn?: SelectionCompareFn<T>) {
let lookupMethod = this.shallowEqual;
if (compareFn != undefined || compareFn != null) {
lookupMethod = compareFn;
}
const dataItem = this._data.filter(d => lookupMethod(d.value, data));
if (dataItem.length > 0) {
if (selectedState != undefined) {
dataItem[0].selected = selectedState;
@ -45,6 +53,7 @@ export class SelectionModel<T> {
}
}
/**
* Is the passed item selected
* @param data item to check against
@ -65,6 +74,15 @@ export class SelectionModel<T> {
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