mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2025-12-24 06:28:55 -05:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4045eebd3 | ||
|
|
a57cbccbb4 | ||
|
|
2221f118bb | ||
|
|
2cc3eb4ebb | ||
|
|
3a064a22bd | ||
|
|
ee764ff215 |
@@ -62,65 +62,13 @@ public sealed class DownloadCleanerConfigController : ControllerBase
|
||||
throw new ValidationException("Request body cannot be null");
|
||||
}
|
||||
|
||||
// Validate cron expression format
|
||||
if (!string.IsNullOrEmpty(newConfigDto.CronExpression))
|
||||
{
|
||||
CronValidationHelper.ValidateCronExpression(newConfigDto.CronExpression);
|
||||
}
|
||||
|
||||
if (newConfigDto.Enabled && newConfigDto.Categories.Any())
|
||||
{
|
||||
if (newConfigDto.Categories.GroupBy(x => x.Name).Any(x => x.Count() > 1))
|
||||
{
|
||||
throw new ValidationException("Duplicate category names found");
|
||||
}
|
||||
|
||||
foreach (var categoryDto in newConfigDto.Categories)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(categoryDto.Name))
|
||||
{
|
||||
throw new ValidationException("Category name cannot be empty");
|
||||
}
|
||||
|
||||
if (categoryDto is { MaxRatio: < 0, MaxSeedTime: < 0 })
|
||||
{
|
||||
throw new ValidationException("Either max ratio or max seed time must be enabled");
|
||||
}
|
||||
|
||||
if (categoryDto.MinSeedTime < 0)
|
||||
{
|
||||
throw new ValidationException("Min seed time cannot be negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newConfigDto.UnlinkedEnabled)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newConfigDto.UnlinkedTargetCategory))
|
||||
{
|
||||
throw new ValidationException("Unlinked target category cannot be empty");
|
||||
}
|
||||
|
||||
if (newConfigDto.UnlinkedCategories?.Count is null or 0)
|
||||
{
|
||||
throw new ValidationException("Unlinked categories cannot be empty");
|
||||
}
|
||||
|
||||
if (newConfigDto.UnlinkedCategories.Contains(newConfigDto.UnlinkedTargetCategory))
|
||||
{
|
||||
throw new ValidationException("The unlinked target category should not be present in unlinked categories");
|
||||
}
|
||||
|
||||
if (newConfigDto.UnlinkedCategories.Any(string.IsNullOrWhiteSpace))
|
||||
{
|
||||
throw new ValidationException("Empty unlinked category filter found");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(newConfigDto.UnlinkedIgnoredRootDir) && !Directory.Exists(newConfigDto.UnlinkedIgnoredRootDir))
|
||||
{
|
||||
throw new ValidationException($"{newConfigDto.UnlinkedIgnoredRootDir} root directory does not exist");
|
||||
}
|
||||
}
|
||||
|
||||
// Get existing configuration
|
||||
var oldConfig = await _dataContext.DownloadCleanerConfigs
|
||||
.Include(x => x.Categories)
|
||||
.FirstAsync();
|
||||
@@ -135,6 +83,7 @@ public sealed class DownloadCleanerConfigController : ControllerBase
|
||||
oldConfig.UnlinkedIgnoredRootDir = newConfigDto.UnlinkedIgnoredRootDir;
|
||||
oldConfig.UnlinkedCategories = newConfigDto.UnlinkedCategories;
|
||||
oldConfig.IgnoredDownloads = newConfigDto.IgnoredDownloads;
|
||||
oldConfig.Categories.Clear();
|
||||
|
||||
_dataContext.CleanCategories.RemoveRange(oldConfig.Categories);
|
||||
_dataContext.DownloadCleanerConfigs.Update(oldConfig);
|
||||
@@ -151,6 +100,8 @@ public sealed class DownloadCleanerConfigController : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
oldConfig.Validate();
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
await UpdateJobSchedule(oldConfig, JobType.DownloadCleaner);
|
||||
|
||||
@@ -59,8 +59,6 @@ public sealed class QueueCleanerConfigController : ControllerBase
|
||||
CronValidationHelper.ValidateCronExpression(newConfigDto.CronExpression);
|
||||
}
|
||||
|
||||
newConfigDto.FailedImport.Validate();
|
||||
|
||||
var oldConfig = await _dataContext.QueueCleanerConfigs
|
||||
.FirstAsync();
|
||||
|
||||
@@ -70,6 +68,8 @@ public sealed class QueueCleanerConfigController : ControllerBase
|
||||
oldConfig.FailedImport = newConfigDto.FailedImport;
|
||||
oldConfig.DownloadingMetadataMaxStrikes = newConfigDto.DownloadingMetadataMaxStrikes;
|
||||
oldConfig.IgnoredDownloads = newConfigDto.IgnoredDownloads;
|
||||
|
||||
oldConfig.Validate();
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
|
||||
@@ -70,11 +71,26 @@ public sealed class DelugeItem : ITorrentItem
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (string pattern in ignoredDownloads)
|
||||
{
|
||||
if (Hash?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return ignoredDownloads.Any(pattern =>
|
||||
Name.Contains(pattern, StringComparison.InvariantCultureIgnoreCase) ||
|
||||
Hash.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) ||
|
||||
Trackers.Any(tracker => tracker.EndsWith(pattern, StringComparison.InvariantCultureIgnoreCase)));
|
||||
if (Category?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_downloadStatus.Trackers.Any(x => UriService.GetDomain(x.Url)?.EndsWith(pattern, StringComparison.InvariantCultureIgnoreCase) is true))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
|
||||
using QBittorrent.Client;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
@@ -73,10 +75,30 @@ public sealed class QBitItem : ITorrentItem
|
||||
return false;
|
||||
}
|
||||
|
||||
return ignoredDownloads.Any(pattern =>
|
||||
Name.Contains(pattern, StringComparison.InvariantCultureIgnoreCase) ||
|
||||
Hash.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) ||
|
||||
Trackers.Any(tracker => tracker.EndsWith(pattern, StringComparison.InvariantCultureIgnoreCase)));
|
||||
foreach (string pattern in ignoredDownloads)
|
||||
{
|
||||
if (Hash.Equals(pattern, StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Category?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_torrentInfo.Tags.Contains(pattern, StringComparer.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_trackers.Any(tracker => tracker.ShouldIgnore(ignoredDownloads)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Transmission.API.RPC.Entity;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
|
||||
@@ -55,13 +57,13 @@ public sealed class TransmissionItem : ITorrentItem
|
||||
public long SeedingTimeSeconds => _torrentInfo.SecondsSeeding ?? 0;
|
||||
|
||||
// Categories and tags
|
||||
public string? Category => _torrentInfo.Labels?.FirstOrDefault();
|
||||
public string? Category => _torrentInfo.GetCategory();
|
||||
public IReadOnlyList<string> Tags => _torrentInfo.Labels?.ToList().AsReadOnly() ?? (IReadOnlyList<string>)Array.Empty<string>();
|
||||
|
||||
// State checking methods
|
||||
// Transmission status: 0=stopped, 1=check pending, 2=checking, 3=download pending, 4=downloading, 5=seed pending, 6=seeding
|
||||
public bool IsDownloading() => _torrentInfo.Status == 4;
|
||||
public bool IsStalled() => _torrentInfo.Status == 4 && (_torrentInfo.RateDownload ?? 0) == 0 && (_torrentInfo.Eta ?? 0) == 0;
|
||||
public bool IsStalled() => _torrentInfo is { Status: 4, RateDownload: <= 0, Eta: <= 0 };
|
||||
public bool IsSeeding() => _torrentInfo.Status == 6;
|
||||
public bool IsCompleted() => CompletionPercentage >= 100.0;
|
||||
public bool IsPaused() => _torrentInfo.Status == 0;
|
||||
@@ -78,10 +80,28 @@ public sealed class TransmissionItem : ITorrentItem
|
||||
return false;
|
||||
}
|
||||
|
||||
return ignoredDownloads.Any(pattern =>
|
||||
Name.Contains(pattern, StringComparison.InvariantCultureIgnoreCase) ||
|
||||
Hash.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) ||
|
||||
Trackers.Any(tracker => tracker.EndsWith(pattern, StringComparison.InvariantCultureIgnoreCase)));
|
||||
foreach (string pattern in ignoredDownloads)
|
||||
{
|
||||
if (Hash?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Category?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
bool? hasIgnoredTracker = _torrentInfo.Trackers?
|
||||
.Any(x => UriService.GetDomain(x.Announce)?.EndsWith(pattern, StringComparison.InvariantCultureIgnoreCase) ?? false);
|
||||
|
||||
if (hasIgnoredTracker is true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities.UTorrent.Response;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
|
||||
|
||||
@@ -82,11 +83,26 @@ public sealed class UTorrentItemWrapper : ITorrentItem
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (string value in ignoredDownloads)
|
||||
{
|
||||
if (Hash.Equals(value, StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Category?.Equals(value, StringComparison.InvariantCultureIgnoreCase) is true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return ignoredDownloads.Any(pattern =>
|
||||
Name.Contains(pattern, StringComparison.InvariantCultureIgnoreCase) ||
|
||||
Hash.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) ||
|
||||
Trackers.Any(tracker => tracker.EndsWith(pattern, StringComparison.InvariantCultureIgnoreCase)));
|
||||
if (_torrentProperties.TrackerList.Any(x => x.ShouldIgnore(ignoredDownloads)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -52,10 +52,10 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
|
||||
bool isUnlinkedEnabled = config.UnlinkedEnabled && !string.IsNullOrEmpty(config.UnlinkedTargetCategory) && config.UnlinkedCategories.Count > 0;
|
||||
bool isCleaningEnabled = config.Categories.Count > 0;
|
||||
|
||||
|
||||
if (!isUnlinkedEnabled && !isCleaningEnabled)
|
||||
{
|
||||
_logger.LogWarning("{name} is not configured properly", nameof(DownloadCleaner));
|
||||
_logger.LogWarning("No features are enabled for {name}", nameof(DownloadCleaner));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -133,9 +133,9 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr)), InstanceType.Readarr, true);
|
||||
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr)), InstanceType.Whisparr, true);
|
||||
|
||||
if (isUnlinkedEnabled && downloadServiceWithDownloads.Count > 0)
|
||||
if (isUnlinkedEnabled && downloadServiceWithDownloads.Sum(x => x.Item2.Count) > 0)
|
||||
{
|
||||
_logger.LogInformation("Found {count} potential downloads to change category", downloadServiceWithDownloads.Sum(x => x.Item2.Count));
|
||||
_logger.LogInformation("Evaluating {count} downloads for hardlinks", downloadServiceWithDownloads.Sum(x => x.Item2.Count));
|
||||
|
||||
// Process each client with its own filtered downloads
|
||||
foreach (var (downloadService, downloadsToChangeCategory) in downloadServiceWithDownloads)
|
||||
@@ -150,7 +150,7 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Finished changing category");
|
||||
_logger.LogInformation("Finished hardlinks evaluation");
|
||||
}
|
||||
|
||||
if (config.Categories.Count is 0)
|
||||
@@ -175,7 +175,7 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("found {count} potential downloads to clean", downloadServiceWithDownloads.Sum(x => x.Item2.Count));
|
||||
_logger.LogInformation("Evaluating {count} downloads for cleanup", downloadServiceWithDownloads.Sum(x => x.Item2.Count));
|
||||
|
||||
// Process cleaning for each client
|
||||
foreach (var (downloadService, downloadsToClean) in downloadServiceWithDownloads)
|
||||
@@ -190,7 +190,7 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("finished cleaning downloads");
|
||||
_logger.LogInformation("Finished cleanup evaluation");
|
||||
|
||||
foreach (var downloadService in downloadServices)
|
||||
{
|
||||
|
||||
@@ -22,7 +22,6 @@ public sealed class AppStatusRefreshService : BackgroundService
|
||||
private static readonly Uri StatusUri = new("https://cleanuparr-status.pages.dev/status.json");
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromMinutes(10);
|
||||
private static readonly TimeSpan StartupDelay = TimeSpan.FromSeconds(5);
|
||||
private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(3);
|
||||
|
||||
public AppStatusRefreshService(
|
||||
ILogger<AppStatusRefreshService> logger,
|
||||
@@ -70,7 +69,6 @@ public sealed class AppStatusRefreshService : BackgroundService
|
||||
try
|
||||
{
|
||||
using var client = _httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
|
||||
client.Timeout = RequestTimeout;
|
||||
|
||||
using var response = await client.GetAsync(StatusUri, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
@@ -40,7 +40,7 @@ public sealed record CleanCategory : IConfig
|
||||
|
||||
if (MaxRatio < 0 && MaxSeedTime < 0)
|
||||
{
|
||||
throw new ValidationException("Both max ratio and max seed time are disabled");
|
||||
throw new ValidationException("Either max ratio or max seed time must be set to a non-negative value");
|
||||
}
|
||||
|
||||
if (MinSeedTime < 0)
|
||||
|
||||
@@ -44,10 +44,19 @@ public sealed record DownloadCleanerConfig : IJobConfig
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Validate that at least one feature is configured
|
||||
bool hasSeedingCategories = Categories.Count > 0;
|
||||
bool hasUnlinkedFeature = UnlinkedEnabled && UnlinkedCategories.Count > 0 && !string.IsNullOrWhiteSpace(UnlinkedTargetCategory);
|
||||
|
||||
if (!hasSeedingCategories && !hasUnlinkedFeature)
|
||||
{
|
||||
throw new ValidationException("No features are enabled");
|
||||
}
|
||||
|
||||
if (Categories.GroupBy(x => x.Name).Any(x => x.Count() > 1))
|
||||
{
|
||||
throw new ValidationException("duplicated clean categories found");
|
||||
throw new ValidationException("Duplicated clean categories found");
|
||||
}
|
||||
|
||||
Categories.ForEach(x => x.Validate());
|
||||
@@ -63,19 +72,19 @@ public sealed record DownloadCleanerConfig : IJobConfig
|
||||
throw new ValidationException("unlinked target category is required");
|
||||
}
|
||||
|
||||
if (UnlinkedCategories?.Count is null or 0)
|
||||
if (UnlinkedCategories.Count is 0)
|
||||
{
|
||||
throw new ValidationException("no unlinked categories configured");
|
||||
throw new ValidationException("No unlinked categories configured");
|
||||
}
|
||||
|
||||
if (UnlinkedCategories.Contains(UnlinkedTargetCategory))
|
||||
{
|
||||
throw new ValidationException($"The unlinked target category should not be present in unlinked categories");
|
||||
throw new ValidationException("The unlinked target category should not be present in unlinked categories");
|
||||
}
|
||||
|
||||
if (UnlinkedCategories.Any(string.IsNullOrEmpty))
|
||||
{
|
||||
throw new ValidationException("empty unlinked category filter found");
|
||||
throw new ValidationException("Empty unlinked category filter found");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(UnlinkedIgnoredRootDir) && !Directory.Exists(UnlinkedIgnoredRootDir))
|
||||
|
||||
@@ -36,6 +36,16 @@ public sealed record ContentBlockerConfig : IJobConfig
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Sonarr.Enabled && !Radarr.Enabled && !Lidarr.Enabled && !Readarr.Enabled && !Whisparr.Enabled)
|
||||
{
|
||||
throw new ValidationException("At least one blocklist must be configured when Malware Blocker is enabled");
|
||||
}
|
||||
|
||||
ValidateBlocklistSettings(Sonarr, "Sonarr");
|
||||
ValidateBlocklistSettings(Radarr, "Radarr");
|
||||
ValidateBlocklistSettings(Lidarr, "Lidarr");
|
||||
@@ -45,9 +55,25 @@ public sealed record ContentBlockerConfig : IJobConfig
|
||||
|
||||
private static void ValidateBlocklistSettings(BlocklistSettings settings, string context)
|
||||
{
|
||||
if (settings.Enabled && string.IsNullOrWhiteSpace(settings.BlocklistPath))
|
||||
if (!settings.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(settings.BlocklistPath))
|
||||
{
|
||||
throw new ValidationException($"{context} blocklist is enabled but path is not specified");
|
||||
}
|
||||
|
||||
if (Uri.TryCreate(settings.BlocklistPath, UriKind.Absolute, out var uri) &&
|
||||
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!File.Exists(settings.BlocklistPath))
|
||||
{
|
||||
throw new ValidationException($"{context} blocklist does not exist: {settings.BlocklistPath}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,12 @@ public sealed record FailedImportConfig
|
||||
{
|
||||
if (MaxStrikes is > 0 and < 3)
|
||||
{
|
||||
throw new ValidationException("the minimum value for failed imports max strikes must be 3");
|
||||
throw new ValidationException("The minimum value for failed imports max strikes must be 3");
|
||||
}
|
||||
|
||||
if (MaxStrikes >= 3 && PatternMode is PatternMode.Include && Patterns.Count is 0)
|
||||
{
|
||||
throw new ValidationException("At least one pattern must be specified when using the Include pattern mode");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export class DocumentationService {
|
||||
'failedImport.maxStrikes': 'failed-import-max-strikes',
|
||||
'failedImport.ignorePrivate': 'failed-import-ignore-private',
|
||||
'failedImport.deletePrivate': 'failed-import-delete-private',
|
||||
'failedImport.skipIfNotFoundInClient': 'failed-import-skip-if-not-found-in-client',
|
||||
'failedImport.pattern-mode': 'failed-import-pattern-mode',
|
||||
'failedImport.patterns': 'failed-import-patterns',
|
||||
'downloadingMetadataMaxStrikes': 'stalled-downloading-metadata-max-strikes',
|
||||
|
||||
133
code/frontend/src/app/core/utils/form-validation.util.ts
Normal file
133
code/frontend/src/app/core/utils/form-validation.util.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { AbstractControl, FormGroup, FormArray } from '@angular/forms';
|
||||
|
||||
/**
|
||||
* Utility functions for form validation
|
||||
*/
|
||||
|
||||
/**
|
||||
* Recursively checks if a form control or form group has any validation errors
|
||||
* @param control The form control, form group, or form array to check
|
||||
* @returns True if there are any errors in the control or its children
|
||||
*/
|
||||
export function hasFormErrors(control: AbstractControl | null): boolean {
|
||||
if (!control) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the control itself has errors
|
||||
if (control.errors && Object.keys(control.errors).length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If it's a FormGroup, check all its controls
|
||||
if (control instanceof FormGroup) {
|
||||
const controls = control.controls;
|
||||
for (const key in controls) {
|
||||
if (controls.hasOwnProperty(key)) {
|
||||
if (hasFormErrors(controls[key])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If it's a FormArray, check all its controls
|
||||
if (control instanceof FormArray) {
|
||||
for (let i = 0; i < control.length; i++) {
|
||||
if (hasFormErrors(control.at(i))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a form control or any of its children have been touched
|
||||
* @param control The form control, form group, or form array to check
|
||||
* @returns True if the control or any of its children have been touched
|
||||
*/
|
||||
export function isFormTouched(control: AbstractControl | null): boolean {
|
||||
if (!control) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the control itself has been touched
|
||||
if (control.touched) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If it's a FormGroup, check all its controls
|
||||
if (control instanceof FormGroup) {
|
||||
const controls = control.controls;
|
||||
for (const key in controls) {
|
||||
if (controls.hasOwnProperty(key)) {
|
||||
if (isFormTouched(controls[key])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If it's a FormArray, check all its controls
|
||||
if (control instanceof FormArray) {
|
||||
for (let i = 0; i < control.length; i++) {
|
||||
if (isFormTouched(control.at(i))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a form section has validation errors and has been touched
|
||||
* This is useful for showing validation errors only after user interaction
|
||||
* @param control The form control or group to check
|
||||
* @returns True if there are errors and the control has been touched
|
||||
*/
|
||||
export function hasTouchedFormErrors(control: AbstractControl | null): boolean {
|
||||
return hasFormErrors(control) && isFormTouched(control);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a form control or group has validation errors where each errored field has been touched
|
||||
* Only returns true if the specific invalid fields have been touched, not just any sibling
|
||||
* @param control The form control or group to check
|
||||
* @returns True if there are errors in fields that have been individually touched
|
||||
*/
|
||||
export function hasIndividuallyDirtyFormErrors(control: AbstractControl | null): boolean {
|
||||
if (!control) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For a single control, check if it has errors AND is touched
|
||||
if (!(control instanceof FormGroup) && !(control instanceof FormArray)) {
|
||||
return control.invalid && control.dirty;
|
||||
}
|
||||
|
||||
// For FormGroup, check each child recursively
|
||||
if (control instanceof FormGroup) {
|
||||
const controls = control.controls;
|
||||
for (const key in controls) {
|
||||
if (controls.hasOwnProperty(key)) {
|
||||
if (hasIndividuallyDirtyFormErrors(controls[key])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For FormArray, check each element recursively
|
||||
if (control instanceof FormArray) {
|
||||
for (let i = 0; i < control.length; i++) {
|
||||
if (hasIndividuallyDirtyFormErrors(control.at(i))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -51,16 +51,14 @@
|
||||
</i>
|
||||
Blacklist Path
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<input
|
||||
type="text"
|
||||
pInputText
|
||||
formControlName="blacklistPath"
|
||||
placeholder="File path or http(s) URL"
|
||||
id="blacklistPath" />
|
||||
</div>
|
||||
<small *ngIf="hasError('blacklistPath', 'required')" class="p-error">This field is required when blacklist sync is enabled</small>
|
||||
<div class="field-input">
|
||||
<input
|
||||
type="text"
|
||||
pInputText
|
||||
formControlName="blacklistPath"
|
||||
placeholder="Local file path or URL"
|
||||
id="blacklistPath" />
|
||||
<small *ngIf="hasError('blacklistPath', 'required')" class="form-error-text">This field is required when blacklist sync is enabled</small>
|
||||
<small class="form-helper-text">Path to blacklist file or HTTP(S) URL containing blacklist patterns</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,13 +29,14 @@
|
||||
<!-- Main Settings -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('enabled')"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('enabled')"
|
||||
title="Click for documentation"></i>
|
||||
Enable Download Cleaner
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="enabled" [binary]="true" inputId="dcEnabled"></p-checkbox>
|
||||
<small *ngIf="hasNoFeaturesConfiguredError()" class="form-error-text">At least one feature must be configured</small>
|
||||
<small class="form-helper-text">When enabled, the download cleaner will run according to the schedule</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,8 +68,8 @@
|
||||
<label class="field-label">
|
||||
Run Schedule
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input schedule-input flex flex-wrap">
|
||||
<div class="field-input">
|
||||
<div class="schedule-input flex flex-wrap">
|
||||
<span class="schedule-label">Every</span>
|
||||
<p-select
|
||||
formControlName="every"
|
||||
@@ -88,7 +89,7 @@
|
||||
>
|
||||
</p-selectButton>
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('jobSchedule', 'every', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasNestedError('jobSchedule', 'every', 'required')" class="form-error-text">This field is required</small>
|
||||
<small class="form-helper-text">How often the download cleaner should run</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,11 +102,9 @@
|
||||
title="Click for documentation"></i>
|
||||
Cron Expression
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="cronExpression" placeholder="0 0/5 * ? * * *" />
|
||||
</div>
|
||||
<small *ngIf="hasError('cronExpression', 'required')" class="p-error">Cron expression is required</small>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="cronExpression" placeholder="0 0/5 * ? * * *" />
|
||||
<small *ngIf="hasError('cronExpression', 'required')" class="form-error-text">Cron expression is required</small>
|
||||
<small class="form-helper-text">Enter a valid Quartz cron expression (e.g., "0 0/5 * ? * * *" runs every 5 minutes)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,28 +112,16 @@
|
||||
<!-- Ignored Downloads Field -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('ignoredDownloads')"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('ignoredDownloads')"
|
||||
title="Click for documentation"></i>
|
||||
Ignored Downloads
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<!-- Mobile-friendly autocomplete -->
|
||||
<app-mobile-autocomplete
|
||||
formControlName="ignoredDownloads"
|
||||
placeholder="Add download pattern"
|
||||
></app-mobile-autocomplete>
|
||||
|
||||
<!-- Desktop autocomplete -->
|
||||
<p-autocomplete
|
||||
formControlName="ignoredDownloads"
|
||||
inputId="dc-ignoredDownloads"
|
||||
multiple
|
||||
fluid
|
||||
[typeahead]="false"
|
||||
placeholder="Add download pattern and press enter"
|
||||
class="desktop-only"
|
||||
></p-autocomplete>
|
||||
<small class="form-helper-text">Downloads matching these patterns will be ignored by the download cleaner</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,7 +138,12 @@
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Seeding Settings
|
||||
<span class="accordion-header-title">
|
||||
Seeding Settings
|
||||
@if (sectionHasErrors(0)) {
|
||||
<i class="pi pi-exclamation-circle accordion-error-icon" title="This section has validation errors"></i>
|
||||
}
|
||||
</span>
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<!-- Delete Private Option -->
|
||||
@@ -186,16 +178,15 @@
|
||||
<div *ngFor="let category of categoriesFormArray.controls; let i = index" class="category-item" [formGroup]="getCategoryAsFormGroup(i)">
|
||||
<div class="category-header">
|
||||
<div class="category-title">
|
||||
<i class="pi pi-tag category-icon"></i>
|
||||
<input type="text" pInputText formControlName="name" placeholder="Category name" class="category-name-input" />
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('name')"
|
||||
title="Click for documentation"></i>
|
||||
<input type="text" pInputText formControlName="name" placeholder="Category name" class="category-name-input" />
|
||||
</div>
|
||||
<button pButton type="button" icon="pi pi-trash" class="p-button-danger p-button-sm"
|
||||
(click)="removeCategory(i)" [disabled]="downloadCleanerForm.disabled"></button>
|
||||
</div>
|
||||
<small *ngIf="hasCategoryError(i, 'name', 'required')" class="p-error block">Name is required</small>
|
||||
<small *ngIf="hasCategoryError(i, 'name', 'required')" class="form-error-text">Name is required</small>
|
||||
|
||||
<div class="category-content">
|
||||
<div class="category-field">
|
||||
@@ -211,7 +202,8 @@
|
||||
decrementButtonClass="p-button-danger" incrementButtonClass="p-button-success" incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus">
|
||||
</p-inputNumber>
|
||||
<small *ngIf="hasCategoryError(i, 'maxRatio', 'min')" class="p-error block">Min value is -1</small>
|
||||
<small *ngIf="hasCategoryError(i, 'maxRatio', 'required')" class="form-error-text">This input is required</small>
|
||||
<small *ngIf="hasCategoryError(i, 'maxRatio', 'min')" class="form-error-text">Min value is -1</small>
|
||||
<small class="form-helper-text">Maximum ratio to seed before removing (<code>-1</code> means disabled)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,7 +220,8 @@
|
||||
decrementButtonClass="p-button-danger" incrementButtonClass="p-button-success" incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus">
|
||||
</p-inputNumber>
|
||||
<small *ngIf="hasCategoryError(i, 'minSeedTime', 'min')" class="p-error block">Min value is 0</small>
|
||||
<small *ngIf="hasCategoryError(i, 'minSeedTime', 'required')" class="form-error-text">This input is required</small>
|
||||
<small *ngIf="hasCategoryError(i, 'minSeedTime', 'min')" class="form-error-text">Min value is 0</small>
|
||||
<small class="form-helper-text">Minimum time to seed before removing a download that has reached the max ratio (<code>0</code> means disabled)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -240,20 +233,19 @@
|
||||
title="Click for documentation"></i>
|
||||
Max Seed Time (hours)
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber formControlName="maxSeedTime" [min]="-1" [showButtons]="true" buttonLayout="horizontal"
|
||||
decrementButtonClass="p-button-danger" incrementButtonClass="p-button-success" incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus">
|
||||
</p-inputNumber>
|
||||
</div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber formControlName="maxSeedTime" [min]="-1" [showButtons]="true" buttonLayout="horizontal"
|
||||
decrementButtonClass="p-button-danger" incrementButtonClass="p-button-success" incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus">
|
||||
</p-inputNumber>
|
||||
<small *ngIf="hasCategoryError(i, 'maxSeedTime', 'required')" class="form-error-text">This input is required</small>
|
||||
<small *ngIf="hasCategoryError(i, 'maxSeedTime', 'min')" class="form-error-text">Min value is -1</small>
|
||||
<small class="form-helper-text">Maximum time to seed before removing (<code>-1</code> means disabled)</small>
|
||||
<small *ngIf="hasCategoryError(i, 'maxSeedTime', 'min')" class="p-error block">Min value is -1</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Error for both maxRatio and maxSeedTime disabled -->
|
||||
<small *ngIf="hasCategoryGroupError(i, 'bothDisabled')" class="p-error block">
|
||||
<small *ngIf="hasCategoryGroupError(i, 'bothDisabled')" class="form-error-text">
|
||||
Both max ratio and max seed time cannot be disabled at the same time
|
||||
</small>
|
||||
</div>
|
||||
@@ -277,7 +269,12 @@
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Unlinked Download Settings
|
||||
<span class="accordion-header-title">
|
||||
Unlinked Download Settings
|
||||
@if (sectionHasErrors(1)) {
|
||||
<i class="pi pi-exclamation-circle accordion-error-icon" title="This section has validation errors"></i>
|
||||
}
|
||||
</span>
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<div class="field-row">
|
||||
@@ -301,11 +298,9 @@
|
||||
title="Click for documentation"></i>
|
||||
Target Category
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="unlinkedTargetCategory" placeholder="Target category name" />
|
||||
</div>
|
||||
<small *ngIf="hasError('unlinkedTargetCategory', 'required')" class="p-error">Target category is required</small>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="unlinkedTargetCategory" placeholder="Target category name" />
|
||||
<small *ngIf="hasError('unlinkedTargetCategory', 'required')" class="form-error-text">Target category is required</small>
|
||||
<small class="form-helper-text">Category to move unlinked downloads to</small>
|
||||
<small class="form-helper-text">You have to create a seeding rule for this category if you want to remove the downloads</small>
|
||||
</div>
|
||||
@@ -333,10 +328,8 @@
|
||||
title="Click for documentation"></i>
|
||||
Ignored Root Directory
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="unlinkedIgnoredRootDir" placeholder="/path/to/directory" />
|
||||
</div>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="unlinkedIgnoredRootDir" placeholder="/path/to/directory" />
|
||||
<small class="form-helper-text">Root directory to ignore when checking for unlinked downloads (used for cross-seed)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -344,33 +337,19 @@
|
||||
<!-- Unlinked Categories -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('unlinkedCategories')"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('unlinkedCategories')"
|
||||
title="Click for documentation"></i>
|
||||
Unlinked Categories
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<!-- Mobile-friendly autocomplete -->
|
||||
<app-mobile-autocomplete
|
||||
formControlName="unlinkedCategories"
|
||||
placeholder="Add category"
|
||||
></app-mobile-autocomplete>
|
||||
|
||||
<!-- Desktop autocomplete -->
|
||||
<p-autocomplete
|
||||
formControlName="unlinkedCategories"
|
||||
multiple
|
||||
fluid
|
||||
[typeahead]="false"
|
||||
placeholder="Add category and press Enter"
|
||||
class="desktop-only"
|
||||
>
|
||||
</p-autocomplete>
|
||||
<small *ngIf="hasUnlinkedCategoriesError()" class="form-error-text">At least one category is required when unlinked download handling is enabled</small>
|
||||
<small class="form-helper-text">Categories to check for unlinked downloads</small>
|
||||
</div>
|
||||
<small *ngIf="hasUnlinkedCategoriesError()" class="p-error">At least one category is required when unlinked download handling is enabled</small>
|
||||
<small class="form-helper-text">Categories to check for unlinked downloads</small>
|
||||
</div>
|
||||
</div>
|
||||
</p-accordion-content>
|
||||
</p-accordion-panel>
|
||||
@@ -402,9 +381,7 @@
|
||||
</form>
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<p-confirmDialog
|
||||
[style]="{ width: '450px' }"
|
||||
[baseZIndex]="10000"
|
||||
<p-confirmDialog
|
||||
rejectButtonStyleClass="p-button-text">
|
||||
</p-confirmDialog>
|
||||
</div>
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
@use '../styles/settings-shared.scss';
|
||||
@use '../styles/arr-shared.scss';
|
||||
@use '../styles/accordion-error-indicator.scss';
|
||||
@use '../settings-page/settings-page.component.scss';
|
||||
|
||||
.section-header {
|
||||
@@ -89,15 +90,6 @@
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.field-input {
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-categories-message {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "../../shared/models/download-cleaner-config.model";
|
||||
import { ScheduleUnit, ScheduleOptions } from "../../shared/models/queue-cleaner-config.model";
|
||||
import { MobileAutocompleteComponent } from "../../shared/components/mobile-autocomplete/mobile-autocomplete.component";
|
||||
import { hasIndividuallyDirtyFormErrors } from "../../core/utils/form-validation.util";
|
||||
|
||||
// PrimeNG Components
|
||||
import { CardModule } from "primeng/card";
|
||||
@@ -24,7 +25,6 @@ import { SelectButtonModule } from "primeng/selectbutton";
|
||||
import { ToastModule } from "primeng/toast";
|
||||
import { NotificationService } from "../../core/services/notification.service";
|
||||
import { SelectModule } from "primeng/select";
|
||||
import { AutoCompleteModule } from "primeng/autocomplete";
|
||||
import { DropdownModule } from "primeng/dropdown";
|
||||
import { TableModule } from "primeng/table";
|
||||
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
|
||||
@@ -48,7 +48,6 @@ import { DocumentationService } from "../../core/services/documentation.service"
|
||||
SelectButtonModule,
|
||||
ToastModule,
|
||||
SelectModule,
|
||||
AutoCompleteModule,
|
||||
DropdownModule,
|
||||
TableModule,
|
||||
LoadingErrorStateComponent,
|
||||
@@ -87,7 +86,10 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
|
||||
// Track the previous enabled state to detect when user is trying to enable
|
||||
private previousEnabledState = false;
|
||||
|
||||
|
||||
// Track the previous unlinked enabled state to detect when user is trying to enable
|
||||
private previousUnlinkedEnabledState = false;
|
||||
|
||||
// Flag to track if form has been initially loaded to avoid showing dialog on page load
|
||||
private formInitialized = false;
|
||||
|
||||
@@ -150,7 +152,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
unlinkedUseTag: [{ value: false, disabled: true }],
|
||||
unlinkedIgnoredRootDir: [{ value: '', disabled: true }],
|
||||
unlinkedCategories: [{ value: [], disabled: true }]
|
||||
}, { validators: this.validateUnlinkedCategories });
|
||||
}, { validators: [this.validateUnlinkedCategories, this.validateAtLeastOneFeature] });
|
||||
|
||||
// Load the current configuration
|
||||
effect(() => {
|
||||
@@ -190,9 +192,16 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
addCategory(category: CleanCategory = createDefaultCategory()): void {
|
||||
// Create a form group for the category with validation and add it to the form array
|
||||
const categoryGroup = this.createCategoryFormGroup(category);
|
||||
|
||||
|
||||
this.categoriesFormArray.push(categoryGroup);
|
||||
this.downloadCleanerForm.markAsDirty();
|
||||
|
||||
// Mark all controls in the new category as dirty to trigger validation immediately
|
||||
Object.keys(categoryGroup.controls).forEach(key => {
|
||||
categoryGroup.get(key)?.markAsDirty();
|
||||
});
|
||||
// Also mark the group itself as dirty to trigger group-level validators
|
||||
categoryGroup.markAsDirty();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,9 +210,9 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
private createCategoryFormGroup(category: CleanCategory): FormGroup {
|
||||
return this.formBuilder.group({
|
||||
name: [category.name, Validators.required],
|
||||
maxRatio: [category.maxRatio],
|
||||
minSeedTime: [category.minSeedTime, [Validators.min(0)]],
|
||||
maxSeedTime: [category.maxSeedTime],
|
||||
maxRatio: [category.maxRatio, [Validators.min(-1), Validators.required]],
|
||||
minSeedTime: [category.minSeedTime, [Validators.min(0), Validators.required]],
|
||||
maxSeedTime: [category.maxSeedTime, [Validators.min(-1), Validators.required]],
|
||||
}, { validators: this.validateCategory });
|
||||
}
|
||||
|
||||
@@ -225,8 +234,16 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
* Custom validator for unlinked categories - requires categories when unlinked handling is enabled
|
||||
*/
|
||||
private validateUnlinkedCategories(group: FormGroup): ValidationErrors | null {
|
||||
const unlinkedEnabled = group.get('unlinkedEnabled')?.value;
|
||||
const unlinkedCategories = group.get('unlinkedCategories')?.value;
|
||||
const unlinkedEnabledControl = group.get('unlinkedEnabled');
|
||||
const unlinkedCategoriesControl = group.get('unlinkedCategories');
|
||||
|
||||
// Don't validate if controls don't exist or if unlinkedCategories is disabled
|
||||
if (!unlinkedEnabledControl || !unlinkedCategoriesControl || unlinkedCategoriesControl.disabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unlinkedEnabled = unlinkedEnabledControl.value;
|
||||
const unlinkedCategories = unlinkedCategoriesControl.value;
|
||||
|
||||
if (unlinkedEnabled && (!unlinkedCategories || unlinkedCategories.length === 0)) {
|
||||
return { unlinkedCategoriesRequired: true };
|
||||
@@ -234,6 +251,39 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom validator to ensure at least one feature is configured when Download Cleaner is enabled
|
||||
*/
|
||||
private validateAtLeastOneFeature(group: FormGroup): ValidationErrors | null {
|
||||
const enabled = group.get('enabled')?.value;
|
||||
|
||||
// If not enabled, validation passes
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if seeding categories are configured
|
||||
const categories = group.get('categories')?.value;
|
||||
const hasSeedingCategories = categories && categories.length > 0;
|
||||
|
||||
// Check if unlinked feature is properly configured
|
||||
const unlinkedEnabled = group.get('unlinkedEnabled')?.value;
|
||||
const unlinkedCategories = group.get('unlinkedCategories')?.value;
|
||||
const unlinkedTargetCategory = group.get('unlinkedTargetCategory')?.value;
|
||||
const hasUnlinkedFeature = unlinkedEnabled &&
|
||||
unlinkedCategories &&
|
||||
unlinkedCategories.length > 0 &&
|
||||
unlinkedTargetCategory &&
|
||||
unlinkedTargetCategory.trim() !== '';
|
||||
|
||||
// At least one feature must be configured
|
||||
if (!hasSeedingCategories && !hasUnlinkedFeature) {
|
||||
return { noFeaturesConfigured: true };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to get a category control as FormGroup for the template
|
||||
@@ -305,10 +355,11 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
|
||||
// Store original values for change detection
|
||||
this.storeOriginalValues();
|
||||
|
||||
|
||||
// Track the enabled state for confirmation dialog logic
|
||||
this.previousEnabledState = config.enabled;
|
||||
|
||||
this.previousUnlinkedEnabledState = config.unlinkedEnabled;
|
||||
|
||||
// Mark form as initialized to enable confirmation dialogs for user actions
|
||||
this.formInitialized = true;
|
||||
|
||||
@@ -383,7 +434,14 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
unlinkedEnabledControl.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(enabled => {
|
||||
this.updateUnlinkedControlsState(enabled);
|
||||
// Only show confirmation dialog if form is initialized and user is trying to enable
|
||||
if (this.formInitialized && enabled && !this.previousUnlinkedEnabledState) {
|
||||
this.showUnlinkedEnableConfirmationDialog();
|
||||
} else {
|
||||
// Update control states normally
|
||||
this.updateUnlinkedControlsState(enabled);
|
||||
this.previousUnlinkedEnabledState = enabled;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -664,6 +722,13 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
hasUnlinkedCategoriesError(): boolean {
|
||||
return this.downloadCleanerForm.dirty && this.downloadCleanerForm.hasError('unlinkedCategoriesRequired');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the form has the no features configured validation error
|
||||
*/
|
||||
hasNoFeaturesConfiguredError(): boolean {
|
||||
return this.downloadCleanerForm.dirty && this.downloadCleanerForm.hasError('noFeaturesConfigured');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get schedule value options based on the current schedule unit type
|
||||
@@ -748,6 +813,37 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an accordion section has validation errors
|
||||
* @param sectionIndex The accordion panel index
|
||||
* @returns True if the section has validation errors
|
||||
*/
|
||||
sectionHasErrors(sectionIndex: number): boolean {
|
||||
switch (sectionIndex) {
|
||||
case 0: // Seeding Settings
|
||||
const categoriesArray = this.downloadCleanerForm.get('categories') as FormArray;
|
||||
// Check if categories array has errors or if any category has errors
|
||||
if (hasIndividuallyDirtyFormErrors(categoriesArray)) {
|
||||
return true;
|
||||
}
|
||||
// Also check for group-level errors on category form groups (like bothDisabled)
|
||||
for (let i = 0; i < categoriesArray.length; i++) {
|
||||
const categoryGroup = categoriesArray.at(i) as FormGroup;
|
||||
if (categoryGroup.dirty && categoryGroup.errors && Object.keys(categoryGroup.errors).length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
case 1: // Unlinked Download Settings
|
||||
return hasIndividuallyDirtyFormErrors(this.downloadCleanerForm.get('unlinkedEnabled')) ||
|
||||
hasIndividuallyDirtyFormErrors(this.downloadCleanerForm.get('unlinkedTargetCategory')) ||
|
||||
hasIndividuallyDirtyFormErrors(this.downloadCleanerForm.get('unlinkedCategories')) ||
|
||||
this.hasUnlinkedCategoriesError();
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show confirmation dialog when enabling the download cleaner
|
||||
*/
|
||||
@@ -776,8 +872,33 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Add any other necessary methods here
|
||||
/**
|
||||
* Show confirmation dialog when enabling unlinked download handling
|
||||
*/
|
||||
private showUnlinkedEnableConfirmationDialog(): void {
|
||||
this.confirmationService.confirm({
|
||||
header: 'Enable Unlinked Download Handling',
|
||||
message: 'This feature requires your downloads directory to be accessible (and mounted if using Docker).<br/><br/>Are you sure you want to proceed?',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptIcon: 'pi pi-check',
|
||||
rejectIcon: 'pi pi-times',
|
||||
acceptLabel: 'Yes, Enable',
|
||||
rejectLabel: 'Cancel',
|
||||
acceptButtonStyleClass: 'p-button-warning',
|
||||
accept: () => {
|
||||
// User confirmed, update control states and track state
|
||||
this.updateUnlinkedControlsState(true);
|
||||
this.previousUnlinkedEnabledState = true;
|
||||
},
|
||||
reject: () => {
|
||||
// User cancelled, revert the checkbox without triggering value change
|
||||
const unlinkedEnabledControl = this.downloadCleanerForm.get('unlinkedEnabled');
|
||||
if (unlinkedEnabledControl) {
|
||||
unlinkedEnabledControl.setValue(false, { emitEvent: false });
|
||||
this.previousUnlinkedEnabledState = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
placeholder="My Download Client"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(clientForm, 'name', 'required')" class="p-error">Name is required</small>
|
||||
<small *ngIf="hasError(clientForm, 'name', 'required')" class="form-error-text">Name is required</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -169,7 +169,7 @@
|
||||
appendTo="body"
|
||||
class="w-full"
|
||||
></p-select>
|
||||
<small *ngIf="hasError(clientForm, 'typeName', 'required')" class="p-error">Client type is required</small>
|
||||
<small *ngIf="hasError(clientForm, 'typeName', 'required')" class="form-error-text">Client type is required</small>
|
||||
</div>
|
||||
|
||||
<ng-container>
|
||||
@@ -188,9 +188,9 @@
|
||||
placeholder="http://localhost:8080"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(clientForm, 'host', 'required')" class="p-error">Host is required</small>
|
||||
<small *ngIf="hasError(clientForm, 'host', 'invalidUri')" class="p-error">Host must be a valid URL</small>
|
||||
<small *ngIf="hasError(clientForm, 'host', 'invalidProtocol')" class="p-error">Host must use http or https protocol</small>
|
||||
<small *ngIf="hasError(clientForm, 'host', 'required')" class="form-error-text">Host is required</small>
|
||||
<small *ngIf="hasError(clientForm, 'host', 'invalidUri')" class="form-error-text">Host must be a valid URL</small>
|
||||
<small *ngIf="hasError(clientForm, 'host', 'invalidProtocol')" class="form-error-text">Host must use http or https protocol</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="dryRun" [binary]="true" inputId="dryRun"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, no changes will be made to the system</small>
|
||||
<small class="form-helper-text">When enabled, actions will be logged without being executed (e.g. download removal)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -66,18 +66,16 @@
|
||||
</i>
|
||||
Maximum HTTP Retries
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="httpMaxRetries"
|
||||
inputId="httpMaxRetries"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasError('httpMaxRetries', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasError('httpMaxRetries', 'max')" class="p-error">Maximum value is 5</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="httpMaxRetries"
|
||||
inputId="httpMaxRetries"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasError('httpMaxRetries', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasError('httpMaxRetries', 'max')" class="form-error-text">Maximum value is 5</small>
|
||||
<small class="form-helper-text">Number of retry attempts for failed HTTPS requests</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,18 +88,16 @@
|
||||
</i>
|
||||
HTTP Timeout (seconds)
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="httpTimeout"
|
||||
inputId="httpTimeout"
|
||||
[showButtons]="true"
|
||||
[min]="1"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasError('httpTimeout', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasError('httpTimeout', 'max')" class="p-error">Maximum value is 100</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="httpTimeout"
|
||||
inputId="httpTimeout"
|
||||
[showButtons]="true"
|
||||
[min]="1"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasError('httpTimeout', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasError('httpTimeout', 'max')" class="form-error-text">Maximum value is 100</small>
|
||||
<small class="form-helper-text">Timeout duration for HTTP requests in seconds</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -149,19 +145,17 @@
|
||||
</i>
|
||||
Search Delay (seconds)
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="searchDelay"
|
||||
inputId="searchDelay"
|
||||
[showButtons]="true"
|
||||
[min]="1"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasError('searchDelay', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasError('searchDelay', 'min')" class="p-error">Minimum value is 60</small>
|
||||
<small *ngIf="hasError('searchDelay', 'max')" class="p-error">Maximum value is 300</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="searchDelay"
|
||||
inputId="searchDelay"
|
||||
[showButtons]="true"
|
||||
[min]="1"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasError('searchDelay', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasError('searchDelay', 'min')" class="form-error-text">Minimum value is 60</small>
|
||||
<small *ngIf="hasError('searchDelay', 'max')" class="form-error-text">Maximum value is 300</small>
|
||||
<small class="form-helper-text">Delay between search operations in seconds</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,29 +163,17 @@
|
||||
<!-- Ignored Downloads -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('ignoredDownloads')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
Ignored Downloads
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<!-- Mobile-friendly autocomplete -->
|
||||
<app-mobile-autocomplete
|
||||
formControlName="ignoredDownloads"
|
||||
placeholder="Add download pattern"
|
||||
></app-mobile-autocomplete>
|
||||
|
||||
<!-- Desktop autocomplete -->
|
||||
<p-autocomplete
|
||||
formControlName="ignoredDownloads"
|
||||
inputId="ignoredDownloads"
|
||||
multiple
|
||||
fluid
|
||||
[typeahead]="false"
|
||||
placeholder="Add download pattern and press enter"
|
||||
class="desktop-only"
|
||||
></p-autocomplete>
|
||||
<small class="form-helper-text">Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,18 +223,16 @@
|
||||
</i>
|
||||
Rolling Size (MB)
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="rollingSizeMB"
|
||||
inputId="rollingSizeMB"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('log', 'rollingSizeMB', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasNestedError('log', 'rollingSizeMB', 'max')" class="p-error">Maximum value is 100 MB</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="rollingSizeMB"
|
||||
inputId="rollingSizeMB"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasNestedError('log', 'rollingSizeMB', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasNestedError('log', 'rollingSizeMB', 'max')" class="form-error-text">Maximum value is 100 MB</small>
|
||||
<small class="form-helper-text">Maximum size of each log file in megabytes (0 = disabled)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -266,18 +246,16 @@
|
||||
</i>
|
||||
Retained File Count
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="retainedFileCount"
|
||||
inputId="retainedFileCount"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('log', 'retainedFileCount', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasNestedError('log', 'retainedFileCount', 'max')" class="p-error">Maximum value is 50</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="retainedFileCount"
|
||||
inputId="retainedFileCount"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasNestedError('log', 'retainedFileCount', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasNestedError('log', 'retainedFileCount', 'max')" class="form-error-text">Maximum value is 50</small>
|
||||
<small class="form-helper-text">Number of old log files to retain (0 = unlimited)</small>
|
||||
<small class="form-helper-text">Files exceeding this limit will be deleted or archived</small>
|
||||
</div>
|
||||
@@ -292,18 +270,16 @@
|
||||
</i>
|
||||
Time Limit (hours)
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="timeLimitHours"
|
||||
inputId="timeLimitHours"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('log', 'timeLimitHours', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasNestedError('log', 'timeLimitHours', 'max')" class="p-error">Maximum value is 1440 hours (60 days)</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="timeLimitHours"
|
||||
inputId="timeLimitHours"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasNestedError('log', 'timeLimitHours', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasNestedError('log', 'timeLimitHours', 'max')" class="form-error-text">Maximum value is 1440 hours (60 days)</small>
|
||||
<small class="form-helper-text">Maximum age of old log files in hours (0 = unlimited)</small>
|
||||
<small class="form-helper-text">Files exceeding this limit will be deleted or archived</small>
|
||||
</div>
|
||||
@@ -333,18 +309,16 @@
|
||||
</i>
|
||||
Archive Retained Count
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="archiveRetainedCount"
|
||||
inputId="archiveRetainedCount"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('log', 'archiveRetainedCount', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasNestedError('log', 'archiveRetainedCount', 'max')" class="p-error">Maximum value is 100</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="archiveRetainedCount"
|
||||
inputId="archiveRetainedCount"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasNestedError('log', 'archiveRetainedCount', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasNestedError('log', 'archiveRetainedCount', 'max')" class="form-error-text">Maximum value is 100</small>
|
||||
<small class="form-helper-text">Number of archive files to retain (0 = unlimited)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -358,18 +332,16 @@
|
||||
</i>
|
||||
Archive Time Limit (hours)
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="archiveTimeLimitHours"
|
||||
inputId="archiveTimeLimitHours"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('log', 'archiveTimeLimitHours', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasNestedError('log', 'archiveTimeLimitHours', 'max')" class="p-error">Maximum value is 1440 hours (60 days)</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="archiveTimeLimitHours"
|
||||
inputId="archiveTimeLimitHours"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasNestedError('log', 'archiveTimeLimitHours', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasNestedError('log', 'archiveTimeLimitHours', 'max')" class="form-error-text">Maximum value is 1440 hours (60 days)</small>
|
||||
<small class="form-helper-text">Maximum age of archive files in hours (0 = unlimited)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -399,8 +371,7 @@
|
||||
</div>
|
||||
</form>
|
||||
<!-- Confirmation Dialog -->
|
||||
<p-confirmDialog
|
||||
[style]="{ width: '500px', maxWidth: '90vw' }"
|
||||
[baseZIndex]="10000">
|
||||
<p-confirmDialog
|
||||
rejectButtonStyleClass="p-button-text">
|
||||
</p-confirmDialog>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,6 @@ import { DocumentationService } from '../../core/services/documentation.service'
|
||||
import { SelectModule } from "primeng/select";
|
||||
import { ChipsModule } from "primeng/chips";
|
||||
import { ChipModule } from "primeng/chip";
|
||||
import { AutoCompleteModule } from "primeng/autocomplete";
|
||||
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
|
||||
import { ConfirmDialogModule } from "primeng/confirmdialog";
|
||||
import { ConfirmationService } from "primeng/api";
|
||||
@@ -43,7 +42,6 @@ import { ErrorHandlerUtil } from "../../core/utils/error-handler.util";
|
||||
ChipModule,
|
||||
ToastModule,
|
||||
SelectModule,
|
||||
AutoCompleteModule,
|
||||
LoadingErrorStateComponent,
|
||||
ConfirmDialogModule,
|
||||
MobileAutocompleteComponent,
|
||||
|
||||
@@ -31,20 +31,18 @@
|
||||
<form [formGroup]="globalForm" class="p-fluid">
|
||||
<div class="field-row">
|
||||
<label class="field-label">Failed Import Max Strikes</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="failedImportMaxStrikes"
|
||||
[min]="-1"
|
||||
[showButtons]="true"
|
||||
buttonLayout="horizontal"
|
||||
incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="p-error">Value cannot be less than -1</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="failedImportMaxStrikes"
|
||||
[min]="-1"
|
||||
[showButtons]="true"
|
||||
buttonLayout="horizontal"
|
||||
incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="form-error-text">Value cannot be less than -1</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="form-error-text">Value cannot exceed 5000</small>
|
||||
<small class="form-helper-text">Maximum number of strikes before removing a failed import (-1 to use global setting; 0 to disable)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,7 +182,7 @@
|
||||
placeholder="My Lidarr Instance"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'name', 'required')" class="p-error">Name is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'name', 'required')" class="form-error-text">Name is required</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -197,9 +195,9 @@
|
||||
placeholder="http://localhost:8686"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'required')" class="p-error">URL is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidUri')" class="p-error">URL must be a valid URL</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidProtocol')" class="p-error">URL must use http or https protocol</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'required')" class="form-error-text">URL is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidUri')" class="form-error-text">URL must be a valid URL</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidProtocol')" class="form-error-text">URL must use http or https protocol</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -212,7 +210,7 @@
|
||||
placeholder="Your Lidarr API key"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="p-error">API key is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="form-error-text">API key is required</small>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -29,13 +29,14 @@
|
||||
<!-- Main Settings -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('enabled')"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('enabled')"
|
||||
title="Click for documentation"></i>
|
||||
Enable Malware Blocker
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="enabled" [binary]="true" inputId="cbEnabled"></p-checkbox>
|
||||
<small *ngIf="hasNoBlocklistConfiguredError()" class="form-error-text">At least one blocklist must be configured</small>
|
||||
<small class="form-helper-text">When enabled, the Malware blocker will run according to the schedule</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,8 +68,8 @@
|
||||
<label class="field-label">
|
||||
Run Schedule
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input schedule-input flex flex-wrap">
|
||||
<div class="field-input">
|
||||
<div class="schedule-input flex flex-wrap">
|
||||
<span class="schedule-label">Every</span>
|
||||
<p-select
|
||||
formControlName="every"
|
||||
@@ -88,7 +89,7 @@
|
||||
>
|
||||
</p-selectButton>
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('jobSchedule', 'every', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasNestedError('jobSchedule', 'every', 'required')" class="form-error-text">This field is required</small>
|
||||
<small class="form-helper-text">How often the Malware Blocker should run</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,11 +102,9 @@
|
||||
title="Click for documentation"></i>
|
||||
Cron Expression
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="cronExpression" placeholder="0 0/5 * ? * * *" />
|
||||
</div>
|
||||
<small *ngIf="hasError('cronExpression', 'required')" class="p-error">Cron expression is required</small>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="cronExpression" placeholder="0 0/5 * ? * * *" />
|
||||
<small *ngIf="hasError('cronExpression', 'required')" class="form-error-text">Cron expression is required</small>
|
||||
<small class="form-helper-text">Enter a valid Quartz cron expression (e.g., "0 0/5 * ? * * *" runs every 5 minutes)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,29 +112,17 @@
|
||||
<!-- Ignored Downloads -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('ignoredDownloads')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
Ignored Downloads
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<!-- Mobile-friendly autocomplete -->
|
||||
<app-mobile-autocomplete
|
||||
formControlName="ignoredDownloads"
|
||||
placeholder="Add download pattern"
|
||||
></app-mobile-autocomplete>
|
||||
|
||||
<!-- Desktop autocomplete -->
|
||||
<p-autocomplete
|
||||
formControlName="ignoredDownloads"
|
||||
inputId="mb-ignoredDownloads"
|
||||
multiple
|
||||
fluid
|
||||
[typeahead]="false"
|
||||
placeholder="Add download pattern and press enter"
|
||||
class="desktop-only"
|
||||
></p-autocomplete>
|
||||
<small class="form-helper-text">Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,7 +179,12 @@
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Sonarr Settings
|
||||
<span class="accordion-header-title">
|
||||
Sonarr Settings
|
||||
@if (sectionHasErrors(0)) {
|
||||
<i class="pi pi-exclamation-circle accordion-error-icon" title="This section has validation errors"></i>
|
||||
}
|
||||
</span>
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<div formGroupName="sonarr">
|
||||
@@ -216,13 +208,11 @@
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Path
|
||||
</label>
|
||||
<p-fluid>
|
||||
<div class="field-input">
|
||||
<input pInputText formControlName="blocklistPath" placeholder="Path to blocklist file or URL" />
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('sonarr', 'blocklistPath', 'required')" class="p-error">Path is required when Sonarr blocklist is enabled</small>
|
||||
<div class="field-input">
|
||||
<input fluid pInputText formControlName="blocklistPath" placeholder="Local file path or URL" />
|
||||
<small *ngIf="hasNestedError('sonarr', 'blocklistPath', 'required')" class="form-error-text">Path is required when Sonarr blocklist is enabled</small>
|
||||
<small class="form-helper-text">Path to the blocklist file or URL</small>
|
||||
</p-fluid>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
@@ -262,7 +252,12 @@
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Radarr Settings
|
||||
<span class="accordion-header-title">
|
||||
Radarr Settings
|
||||
@if (sectionHasErrors(1)) {
|
||||
<i class="pi pi-exclamation-circle accordion-error-icon" title="This section has validation errors"></i>
|
||||
}
|
||||
</span>
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<div formGroupName="radarr">
|
||||
@@ -286,13 +281,11 @@
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Path
|
||||
</label>
|
||||
<p-fluid>
|
||||
<div class="field-input">
|
||||
<input pInputText formControlName="blocklistPath" placeholder="Path to blocklist file or URL" />
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('radarr', 'blocklistPath', 'required')" class="p-error">Path is required when Radarr blocklist is enabled</small>
|
||||
<div class="field-input">
|
||||
<input fluid pInputText formControlName="blocklistPath" placeholder="Local file path or URL" />
|
||||
<small *ngIf="hasNestedError('radarr', 'blocklistPath', 'required')" class="form-error-text">Path is required when Radarr blocklist is enabled</small>
|
||||
<small class="form-helper-text">Path to the blocklist file or URL</small>
|
||||
</p-fluid>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
@@ -332,7 +325,12 @@
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Lidarr Settings
|
||||
<span class="accordion-header-title">
|
||||
Lidarr Settings
|
||||
@if (sectionHasErrors(2)) {
|
||||
<i class="pi pi-exclamation-circle accordion-error-icon" title="This section has validation errors"></i>
|
||||
}
|
||||
</span>
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<div formGroupName="lidarr">
|
||||
@@ -356,13 +354,11 @@
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Path
|
||||
</label>
|
||||
<p-fluid>
|
||||
<div class="field-input">
|
||||
<input pInputText formControlName="blocklistPath" placeholder="Path to blocklist file or URL" />
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('lidarr', 'blocklistPath', 'required')" class="p-error">Path is required when Lidarr blocklist is enabled</small>
|
||||
<div class="field-input">
|
||||
<input fluid pInputText formControlName="blocklistPath" placeholder="Local file path or URL" />
|
||||
<small *ngIf="hasNestedError('lidarr', 'blocklistPath', 'required')" class="form-error-text">Path is required when Lidarr blocklist is enabled</small>
|
||||
<small class="form-helper-text">Path to the blocklist file or URL</small>
|
||||
</p-fluid>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
@@ -402,7 +398,12 @@
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Readarr Settings
|
||||
<span class="accordion-header-title">
|
||||
Readarr Settings
|
||||
@if (sectionHasErrors(3)) {
|
||||
<i class="pi pi-exclamation-circle accordion-error-icon" title="This section has validation errors"></i>
|
||||
}
|
||||
</span>
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<div formGroupName="readarr">
|
||||
@@ -426,13 +427,11 @@
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Path
|
||||
</label>
|
||||
<p-fluid>
|
||||
<div class="field-input">
|
||||
<input pInputText formControlName="blocklistPath" placeholder="Path to blocklist file or URL" />
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('readarr', 'blocklistPath', 'required')" class="p-error">Path is required when Readarr blocklist is enabled</small>
|
||||
<div class="field-input">
|
||||
<input fluid pInputText formControlName="blocklistPath" placeholder="Local file path or URL" />
|
||||
<small *ngIf="hasNestedError('readarr', 'blocklistPath', 'required')" class="form-error-text">Path is required when Readarr blocklist is enabled</small>
|
||||
<small class="form-helper-text">Path to the blocklist file or URL</small>
|
||||
</p-fluid>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
@@ -472,7 +471,12 @@
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Whisparr Settings
|
||||
<span class="accordion-header-title">
|
||||
Whisparr Settings
|
||||
@if (sectionHasErrors(4)) {
|
||||
<i class="pi pi-exclamation-circle accordion-error-icon" title="This section has validation errors"></i>
|
||||
}
|
||||
</span>
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<div formGroupName="whisparr">
|
||||
@@ -496,13 +500,11 @@
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Path
|
||||
</label>
|
||||
<p-fluid>
|
||||
<div class="field-input">
|
||||
<input pInputText formControlName="blocklistPath" placeholder="Path to blocklist file or URL" />
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('whisparr', 'blocklistPath', 'required')" class="p-error">Path is required when Whisparr blocklist is enabled</small>
|
||||
<div class="field-input">
|
||||
<input fluid pInputText formControlName="blocklistPath" placeholder="Local file path or URL" />
|
||||
<small *ngIf="hasNestedError('whisparr', 'blocklistPath', 'required')" class="form-error-text">Path is required when Whisparr blocklist is enabled</small>
|
||||
<small class="form-helper-text">Path to the blocklist file or URL</small>
|
||||
</p-fluid>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
|
||||
@use '../styles/settings-shared.scss';
|
||||
@use '../styles/arr-shared.scss';
|
||||
@use '../styles/accordion-error-indicator.scss';
|
||||
@use '../settings-page/settings-page.component.scss';
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, EventEmitter, OnDestroy, Output, effect, inject } from "@angular/core";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, ValidationErrors, Validators } from "@angular/forms";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
import { MalwareBlockerConfigStore } from "./malware-blocker-config.store";
|
||||
import { CanComponentDeactivate } from "../../core/guards";
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ScheduleOptions
|
||||
} from "../../shared/models/malware-blocker-config.model";
|
||||
import { FluidModule } from 'primeng/fluid';
|
||||
import { hasIndividuallyDirtyFormErrors } from "../../core/utils/form-validation.util";
|
||||
|
||||
|
||||
// PrimeNG Components
|
||||
@@ -29,7 +30,6 @@ import { LoadingErrorStateComponent } from "../../shared/components/loading-erro
|
||||
import { ErrorHandlerUtil } from "../../core/utils/error-handler.util";
|
||||
import { DocumentationService } from "../../core/services/documentation.service";
|
||||
import { MobileAutocompleteComponent } from "../../shared/components/mobile-autocomplete/mobile-autocomplete.component";
|
||||
import { AutoCompleteModule } from "primeng/autocomplete";
|
||||
|
||||
@Component({
|
||||
selector: "app-malware-blocker-settings",
|
||||
@@ -49,7 +49,6 @@ import { AutoCompleteModule } from "primeng/autocomplete";
|
||||
LoadingErrorStateComponent,
|
||||
FluidModule,
|
||||
MobileAutocompleteComponent,
|
||||
AutoCompleteModule,
|
||||
],
|
||||
providers: [MalwareBlockerConfigStore],
|
||||
templateUrl: "./malware-blocker-settings.component.html",
|
||||
@@ -123,6 +122,34 @@ export class MalwareBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
this.documentationService.openFieldDocumentation('malware-blocker', fieldName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom validator to ensure at least one blocklist is configured when Malware Blocker is enabled
|
||||
*/
|
||||
private validateAtLeastOneBlocklist(group: FormGroup): ValidationErrors | null {
|
||||
const enabled = group.get('enabled')?.value;
|
||||
|
||||
// If not enabled, validation passes
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if at least one *arr blocklist is enabled
|
||||
const sonarrEnabled = group.get('sonarr.enabled')?.value;
|
||||
const radarrEnabled = group.get('radarr.enabled')?.value;
|
||||
const lidarrEnabled = group.get('lidarr.enabled')?.value;
|
||||
const readarrEnabled = group.get('readarr.enabled')?.value;
|
||||
const whisparrEnabled = group.get('whisparr.enabled')?.value;
|
||||
|
||||
const hasBlocklist = sonarrEnabled || radarrEnabled || lidarrEnabled || readarrEnabled || whisparrEnabled;
|
||||
|
||||
// At least one blocklist must be configured
|
||||
if (!hasBlocklist) {
|
||||
return { noBlocklistConfigured: true };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Initialize the content blocker form with proper disabled states
|
||||
this.malwareBlockerForm = this.formBuilder.group({
|
||||
@@ -165,7 +192,7 @@ export class MalwareBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
blocklistPath: [{ value: "", disabled: true }],
|
||||
blocklistType: [{ value: BlocklistType.Blacklist, disabled: true }],
|
||||
}),
|
||||
});
|
||||
}, { validators: [this.validateAtLeastOneBlocklist] });
|
||||
|
||||
// Create an effect to update the form when the configuration changes
|
||||
effect(() => {
|
||||
@@ -409,13 +436,23 @@ export class MalwareBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
pathControl?.enable(options);
|
||||
typeControl?.enable(options);
|
||||
pathControl?.setValidators([Validators.required]);
|
||||
pathControl?.updateValueAndValidity();
|
||||
|
||||
// Mark as dirty to trigger validation display immediately
|
||||
// This ensures users see the required field error right away
|
||||
if (pathControl && !pathControl.value) {
|
||||
pathControl.markAsDirty();
|
||||
}
|
||||
} else {
|
||||
// Disable dependent controls and clear validation
|
||||
pathControl?.disable(options);
|
||||
typeControl?.disable(options);
|
||||
pathControl?.clearValidators();
|
||||
pathControl?.updateValueAndValidity();
|
||||
|
||||
// Clear dirty state when disabling
|
||||
pathControl?.markAsPristine();
|
||||
}
|
||||
pathControl?.updateValueAndValidity();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -712,6 +749,33 @@ export class MalwareBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
const control = parentControl.get(controlName);
|
||||
return control ? control.dirty && control.hasError(errorName) : false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if the form has the no blocklist configured validation error
|
||||
*/
|
||||
hasNoBlocklistConfiguredError(): boolean {
|
||||
return this.malwareBlockerForm.dirty && this.malwareBlockerForm.hasError('noBlocklistConfigured');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an accordion section has validation errors
|
||||
* @param sectionIndex The accordion panel index
|
||||
* @returns True if the section has validation errors
|
||||
*/
|
||||
sectionHasErrors(sectionIndex: number): boolean {
|
||||
switch (sectionIndex) {
|
||||
case 0: // Sonarr Settings
|
||||
return hasIndividuallyDirtyFormErrors(this.malwareBlockerForm.get('sonarr'));
|
||||
case 1: // Radarr Settings
|
||||
return hasIndividuallyDirtyFormErrors(this.malwareBlockerForm.get('radarr'));
|
||||
case 2: // Lidarr Settings
|
||||
return hasIndividuallyDirtyFormErrors(this.malwareBlockerForm.get('lidarr'));
|
||||
case 3: // Readarr Settings
|
||||
return hasIndividuallyDirtyFormErrors(this.malwareBlockerForm.get('readarr'));
|
||||
case 4: // Whisparr Settings
|
||||
return hasIndividuallyDirtyFormErrors(this.malwareBlockerForm.get('whisparr'));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,9 +28,9 @@
|
||||
placeholder="http://localhost:8000"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasFieldError(urlControl, 'required')" class="p-error">URL is required</small>
|
||||
<small *ngIf="hasFieldError(urlControl, 'invalidUri')" class="p-error">Must be a valid URL</small>
|
||||
<small *ngIf="hasFieldError(urlControl, 'invalidProtocol')" class="p-error">Must use http or https protocol</small>
|
||||
<small *ngIf="hasFieldError(urlControl, 'required')" class="form-error-text">URL is required</small>
|
||||
<small *ngIf="hasFieldError(urlControl, 'invalidUri')" class="form-error-text">Must be a valid URL</small>
|
||||
<small *ngIf="hasFieldError(urlControl, 'invalidProtocol')" class="form-error-text">Must use http or https protocol</small>
|
||||
<small class="form-helper-text">The URL of your Apprise server where notifications will be sent.</small>
|
||||
</div>
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
Configuration Key *
|
||||
</label>
|
||||
<input id="key" type="text" pInputText [formControl]="keyControl" placeholder="my-config-key" class="w-full" />
|
||||
<small *ngIf="hasFieldError(keyControl, 'required')" class="p-error">Configuration key is required</small>
|
||||
<small *ngIf="hasFieldError(keyControl, 'minlength')" class="p-error">Key must be at least 2 characters</small>
|
||||
<small *ngIf="hasFieldError(keyControl, 'required')" class="form-error-text">Configuration key is required</small>
|
||||
<small *ngIf="hasFieldError(keyControl, 'minlength')" class="form-error-text">Key must be at least 2 characters</small>
|
||||
<small class="form-helper-text">The key that identifies your Apprise configuration on the server.</small>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
class="w-full"
|
||||
/>
|
||||
<small class="form-helper-text">A unique name to identify this provider</small>
|
||||
<small *ngIf="hasError('name', 'required')" class="p-error"> Provider name is required </small>
|
||||
<small *ngIf="hasError('name', 'required')" class="form-error-text"> Provider name is required </small>
|
||||
</div>
|
||||
|
||||
<!-- Provider-Specific Configuration (Content Projection) -->
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
[formControl]="apiKeyControl"
|
||||
placeholder="Enter your Notifiarr API key"
|
||||
class="w-full" />
|
||||
<small *ngIf="hasFieldError(apiKeyControl, 'required')" class="p-error">API Key is required</small>
|
||||
<small *ngIf="hasFieldError(apiKeyControl, 'minlength')" class="p-error">API Key must be at least 10 characters</small>
|
||||
<small *ngIf="hasFieldError(apiKeyControl, 'required')" class="form-error-text">API Key is required</small>
|
||||
<small *ngIf="hasFieldError(apiKeyControl, 'minlength')" class="form-error-text">API Key must be at least 10 characters</small>
|
||||
<small class="form-helper-text">Your Notifiarr API key from your dashboard. Requires Passthrough integration.</small>
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
[formControl]="channelIdControl"
|
||||
placeholder="Enter Discord channel ID"
|
||||
class="w-full" />
|
||||
<small *ngIf="hasFieldError(channelIdControl, 'required')" class="p-error">Channel ID is required</small>
|
||||
<small *ngIf="hasFieldError(channelIdControl, 'required')" class="form-error-text">Channel ID is required</small>
|
||||
<small class="form-helper-text">The Discord channel ID where notifications will be sent.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,9 +28,9 @@
|
||||
placeholder="https://ntfy.sh"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasFieldError(serverUrlControl, 'required')" class="p-error">Server URL is required</small>
|
||||
<small *ngIf="hasFieldError(serverUrlControl, 'invalidUri')" class="p-error">Must be a valid URL</small>
|
||||
<small *ngIf="hasFieldError(serverUrlControl, 'invalidProtocol')" class="p-error">Must use http or https protocol</small>
|
||||
<small *ngIf="hasFieldError(serverUrlControl, 'required')" class="form-error-text">Server URL is required</small>
|
||||
<small *ngIf="hasFieldError(serverUrlControl, 'invalidUri')" class="form-error-text">Must be a valid URL</small>
|
||||
<small *ngIf="hasFieldError(serverUrlControl, 'invalidProtocol')" class="form-error-text">Must use http or https protocol</small>
|
||||
<small class="form-helper-text">The URL of your ntfy server. Use https://ntfy.sh for the public service or your self-hosted instance.</small>
|
||||
</div>
|
||||
|
||||
@@ -61,8 +61,8 @@
|
||||
class="desktop-only w-full"
|
||||
></p-autocomplete>
|
||||
|
||||
<small *ngIf="hasFieldError(topicsControl, 'required')" class="p-error">At least one topic is required</small>
|
||||
<small *ngIf="hasFieldError(topicsControl, 'minlength')" class="p-error">At least one topic is required</small>
|
||||
<small *ngIf="hasFieldError(topicsControl, 'required')" class="form-error-text">At least one topic is required</small>
|
||||
<small *ngIf="hasFieldError(topicsControl, 'minlength')" class="form-error-text">At least one topic is required</small>
|
||||
<small class="form-helper-text">Enter the ntfy topics you want to publish to. Press Enter or comma to add each topic.</small>
|
||||
</div>
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
class="w-full"
|
||||
[showClear]="false"
|
||||
></p-select>
|
||||
<small *ngIf="hasFieldError(authenticationTypeControl, 'required')" class="p-error">Authentication type is required</small>
|
||||
<small *ngIf="hasFieldError(authenticationTypeControl, 'required')" class="form-error-text">Authentication type is required</small>
|
||||
<small class="form-helper-text">Choose how to authenticate with the ntfy server.</small>
|
||||
</div>
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
class="w-full"
|
||||
autocomplete="username"
|
||||
/>
|
||||
<small *ngIf="hasFieldError(usernameControl, 'required')" class="p-error">Username is required for Basic Auth</small>
|
||||
<small *ngIf="hasFieldError(usernameControl, 'required')" class="form-error-text">Username is required for Basic Auth</small>
|
||||
<small class="form-helper-text">Your username for basic authentication.</small>
|
||||
</div>
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
class="w-full"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<small *ngIf="hasFieldError(passwordControl, 'required')" class="p-error">Password is required for Basic Auth</small>
|
||||
<small *ngIf="hasFieldError(passwordControl, 'required')" class="form-error-text">Password is required for Basic Auth</small>
|
||||
<small class="form-helper-text">Your password for basic authentication.</small>
|
||||
</div>
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
placeholder="Enter access token"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasFieldError(accessTokenControl, 'required')" class="p-error">Access token is required</small>
|
||||
<small *ngIf="hasFieldError(accessTokenControl, 'required')" class="form-error-text">Access token is required</small>
|
||||
<small class="form-helper-text">Your access token for bearer token authentication.</small>
|
||||
</div>
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
class="w-full"
|
||||
[showClear]="false"
|
||||
></p-select>
|
||||
<small *ngIf="hasFieldError(priorityControl, 'required')" class="p-error">Priority is required</small>
|
||||
<small *ngIf="hasFieldError(priorityControl, 'required')" class="form-error-text">Priority is required</small>
|
||||
<small class="form-helper-text">The priority level for notifications (1=min, 5=max).</small>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -67,8 +67,8 @@
|
||||
<label class="field-label">
|
||||
Run Schedule
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input schedule-input flex flex-wrap">
|
||||
<div class="field-input">
|
||||
<div class="schedule-input flex flex-wrap">
|
||||
<span class="schedule-label">Every</span>
|
||||
<p-select
|
||||
formControlName="every"
|
||||
@@ -88,7 +88,7 @@
|
||||
>
|
||||
</p-selectButton>
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('jobSchedule', 'every', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasNestedError('jobSchedule', 'every', 'required')" class="form-error-text">This field is required</small>
|
||||
<small class="form-helper-text">How often the queue cleaner should run</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,11 +101,9 @@
|
||||
title="Click for documentation"></i>
|
||||
Cron Expression
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="cronExpression" placeholder="0 0/5 * ? * * *" />
|
||||
</div>
|
||||
<small *ngIf="hasMainFormError('cronExpression', 'required')" class="p-error">Cron expression is required</small>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="cronExpression" placeholder="0 0/5 * ? * * *" />
|
||||
<small *ngIf="hasMainFormError('cronExpression', 'required')" class="form-error-text">Cron expression is required</small>
|
||||
<small class="form-helper-text">Enter a valid Quartz cron expression (e.g., "0 0/5 * ? * * *" runs every 5 minutes)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,29 +111,17 @@
|
||||
<!-- Ignored Downloads -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('ignoredDownloads')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
Ignored Downloads
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<!-- Mobile-friendly autocomplete -->
|
||||
<app-mobile-autocomplete
|
||||
formControlName="ignoredDownloads"
|
||||
placeholder="Add download pattern"
|
||||
></app-mobile-autocomplete>
|
||||
|
||||
<!-- Desktop autocomplete -->
|
||||
<p-autocomplete
|
||||
formControlName="ignoredDownloads"
|
||||
inputId="qc-ignoredDownloads"
|
||||
multiple
|
||||
fluid
|
||||
[typeahead]="false"
|
||||
placeholder="Add download pattern and press enter"
|
||||
class="desktop-only"
|
||||
></p-autocomplete>
|
||||
<small class="form-helper-text">Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,7 +138,12 @@
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Failed Import Settings
|
||||
<span class="accordion-header-title">
|
||||
Failed Import Settings
|
||||
@if (sectionHasErrors(0)) {
|
||||
<i class="pi pi-exclamation-circle accordion-error-icon" title="This section has validation errors"></i>
|
||||
}
|
||||
</span>
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<div class="field-row" formGroupName="failedImport">
|
||||
@@ -162,24 +153,20 @@
|
||||
title="Click for documentation"></i>
|
||||
Max Strikes
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="maxStrikes"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
[step]="1"
|
||||
[minFractionDigits]="0"
|
||||
[maxFractionDigits]="0"
|
||||
buttonLayout="horizontal"
|
||||
>
|
||||
</p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('failedImport', 'maxStrikes', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasNestedError('failedImport', 'maxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
|
||||
<small class="form-helper-text"
|
||||
>Number of strikes before action is taken (0 to disable, min 3 to enable)</small
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="maxStrikes"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
[step]="1"
|
||||
[minFractionDigits]="0"
|
||||
[maxFractionDigits]="0"
|
||||
buttonLayout="horizontal"
|
||||
>
|
||||
</p-inputNumber>
|
||||
<small *ngIf="hasNestedError('failedImport', 'maxStrikes', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasNestedError('failedImport', 'maxStrikes', 'max')" class="form-error-text">Value cannot exceed 5000</small>
|
||||
<small class="form-helper-text">Number of strikes before action is taken (0 to disable, min 3 to enable)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -244,28 +231,17 @@
|
||||
|
||||
<div class="field-row" formGroupName="failedImport">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('failedImport.patterns')"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('failedImport.patterns')"
|
||||
title="Click for documentation"></i>
|
||||
{{ queueCleanerForm.get('failedImport.patternMode')?.value === PatternMode.Include ? 'Included Patterns' : 'Excluded Patterns' }}
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<!-- Mobile-friendly autocomplete -->
|
||||
<app-mobile-autocomplete
|
||||
formControlName="patterns"
|
||||
placeholder="Add pattern"
|
||||
></app-mobile-autocomplete>
|
||||
|
||||
<!-- Desktop autocomplete -->
|
||||
<p-autocomplete
|
||||
formControlName="patterns"
|
||||
multiple
|
||||
fluid
|
||||
[typeahead]="false"
|
||||
placeholder="Add pattern and press Enter"
|
||||
class="desktop-only"
|
||||
>
|
||||
</p-autocomplete>
|
||||
<small *ngIf="hasNestedError('failedImport', 'patterns', 'patternsRequired')" class="form-error-text">At least one pattern is required when using Include mode</small>
|
||||
<small class="form-helper-text">
|
||||
{{ queueCleanerForm.get('failedImport.patternMode')?.value === PatternMode.Include ?
|
||||
'Only failed imports containing these patterns will be removed and everything else will be skipped' :
|
||||
@@ -287,7 +263,12 @@
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Downloading Metadata Settings (qBittorrent only)
|
||||
<span class="accordion-header-title">
|
||||
Downloading Metadata Settings (qBittorrent only)
|
||||
@if (sectionHasErrors(2)) {
|
||||
<i class="pi pi-exclamation-circle accordion-error-icon" title="This section has validation errors"></i>
|
||||
}
|
||||
</span>
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<div class="field-row">
|
||||
@@ -297,24 +278,20 @@
|
||||
title="Click for documentation"></i>
|
||||
Max Strikes for Downloading Metadata
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="downloadingMetadataMaxStrikes"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
[step]="1"
|
||||
[minFractionDigits]="0"
|
||||
[maxFractionDigits]="0"
|
||||
buttonLayout="horizontal"
|
||||
>
|
||||
</p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasMainFormError('downloadingMetadataMaxStrikes', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasMainFormError('downloadingMetadataMaxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
|
||||
<small class="form-helper-text"
|
||||
>Number of strikes before action is taken (0 to disable, min 3 to enable)</small
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="downloadingMetadataMaxStrikes"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
[step]="1"
|
||||
[minFractionDigits]="0"
|
||||
[maxFractionDigits]="0"
|
||||
buttonLayout="horizontal"
|
||||
>
|
||||
</p-inputNumber>
|
||||
<small *ngIf="hasMainFormError('downloadingMetadataMaxStrikes', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasMainFormError('downloadingMetadataMaxStrikes', 'max')" class="form-error-text">Value cannot exceed 5000</small>
|
||||
<small class="form-helper-text">Number of strikes before action is taken (0 to disable, min 3 to enable)</small>
|
||||
</div>
|
||||
</div>
|
||||
</p-accordion-content>
|
||||
@@ -330,7 +307,12 @@
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Stalled Download Rules
|
||||
<span class="accordion-header-title">
|
||||
Stalled Download Rules
|
||||
@if (sectionHasErrors(4)) {
|
||||
<i class="pi pi-exclamation-triangle accordion-error-icon" title="Coverage gaps detected"></i>
|
||||
}
|
||||
</span>
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<!-- Coverage Analysis Warning -->
|
||||
@@ -441,7 +423,12 @@
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Slow Download Rules
|
||||
<span class="accordion-header-title">
|
||||
Slow Download Rules
|
||||
@if (sectionHasErrors(5)) {
|
||||
<i class="pi pi-exclamation-triangle accordion-error-icon" title="Coverage gaps detected"></i>
|
||||
}
|
||||
</span>
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<!-- Coverage Analysis Warning -->
|
||||
@@ -599,8 +586,8 @@
|
||||
placeholder="My Stall Rule"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'name', 'required')" class="p-error">Name is required</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'name', 'maxlength')" class="p-error">Name cannot exceed 100 characters</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'name', 'required')" class="form-error-text">Name is required</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'name', 'maxlength')" class="form-error-text">Name cannot exceed 100 characters</small>
|
||||
</div>
|
||||
|
||||
<div class="field flex flex-row">
|
||||
@@ -630,9 +617,9 @@
|
||||
buttonLayout="horizontal"
|
||||
class="w-full"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxStrikes', 'required')" class="p-error">Max strikes is required</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxStrikes', 'min')" class="p-error">Min value is 3</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxStrikes', 'max')" class="p-error">Max value is 5000</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxStrikes', 'required')" class="form-error-text">Max strikes is required</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxStrikes', 'min')" class="form-error-text">Min value is 3</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxStrikes', 'max')" class="form-error-text">Max value is 5000</small>
|
||||
<small class="form-helper-text">Number of strikes before action is taken</small>
|
||||
</div>
|
||||
|
||||
@@ -652,7 +639,7 @@
|
||||
placeholder="Select privacy type"
|
||||
class="w-full"
|
||||
></p-select>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'privacyType', 'required')" class="p-error">Privacy type is required</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'privacyType', 'required')" class="form-error-text">Privacy type is required</small>
|
||||
<small class="form-helper-text">Which torrent types this rule applies to</small>
|
||||
</div>
|
||||
|
||||
@@ -673,9 +660,9 @@
|
||||
placeholder="Percentage"
|
||||
class="w-full"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'minCompletionPercentage', 'required')" class="p-error">Percentage is required</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'minCompletionPercentage', 'min')" class="p-error">Percentage cannot be negative</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'minCompletionPercentage', 'max')" class="p-error">Percentage cannot exceed 100</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'minCompletionPercentage', 'required')" class="form-error-text">Percentage is required</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'minCompletionPercentage', 'min')" class="form-error-text">Percentage cannot be negative</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'minCompletionPercentage', 'max')" class="form-error-text">Percentage cannot exceed 100</small>
|
||||
<small class="form-helper-text">Apply the rule once completion percentage exceeds this value</small>
|
||||
<small class="form-helper-text">Example: A value of 20 includes items above 20% (20 is not included)</small>
|
||||
<small class="form-helper-text">A value of 0 includes items at 0% and above</small>
|
||||
@@ -698,10 +685,10 @@
|
||||
placeholder="Percentage"
|
||||
class="w-full"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxCompletionPercentage', 'required')" class="p-error">Percentage is required</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxCompletionPercentage', 'min')" class="p-error">Percentage cannot be negative</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxCompletionPercentage', 'max')" class="p-error">Percentage cannot exceed 100</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxCompletionPercentage', 'minGreaterThanMax')" class="p-error">Max percentage must be greater than or equal to Min percentage</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxCompletionPercentage', 'required')" class="form-error-text">Percentage is required</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxCompletionPercentage', 'min')" class="form-error-text">Percentage cannot be negative</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxCompletionPercentage', 'max')" class="form-error-text">Percentage cannot exceed 100</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxCompletionPercentage', 'minGreaterThanMax')" class="form-error-text">Max percentage must be greater than or equal to Min percentage</small>
|
||||
<small class="form-helper-text">Apply the rule to items with a completion percentage less than or equal to this value</small>
|
||||
<small class="form-helper-text">Example: A value of 80 includes items at 80% and below</small>
|
||||
</div>
|
||||
@@ -800,8 +787,8 @@
|
||||
placeholder="My Slow Rule"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'name', 'required')" class="p-error">Name is required</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'name', 'maxlength')" class="p-error">Name cannot exceed 100 characters</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'name', 'required')" class="form-error-text">Name is required</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'name', 'maxlength')" class="form-error-text">Name cannot exceed 100 characters</small>
|
||||
</div>
|
||||
|
||||
<div class="field flex flex-row">
|
||||
@@ -831,9 +818,9 @@
|
||||
buttonLayout="horizontal"
|
||||
class="w-full"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxStrikes', 'required')" class="p-error">Max strikes is required</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxStrikes', 'min')" class="p-error">Min value is 3</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxStrikes', 'max')" class="p-error">Max value is 5000</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxStrikes', 'required')" class="form-error-text">Max strikes is required</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxStrikes', 'min')" class="form-error-text">Min value is 3</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxStrikes', 'max')" class="form-error-text">Max value is 5000</small>
|
||||
<small class="form-helper-text">Number of strikes before action is taken</small>
|
||||
</div>
|
||||
|
||||
@@ -851,7 +838,7 @@
|
||||
type="speed"
|
||||
>
|
||||
</app-byte-size-input>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'minSpeed', 'required')" class="p-error">Minimum speed is required</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'minSpeed', 'required')" class="form-error-text">Minimum speed is required</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -869,8 +856,8 @@
|
||||
[step]="1"
|
||||
class="w-full"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxTimeHours', 'required')" class="p-error">Maximum time is required</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxTimeHours', 'min')" class="p-error">Min value is 0</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxTimeHours', 'required')" class="form-error-text">Maximum time is required</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxTimeHours', 'min')" class="form-error-text">Min value is 0</small>
|
||||
<small class="form-helper-text">Maximum time allowed for slow downloads (0 means disabled)</small>
|
||||
</div>
|
||||
|
||||
@@ -890,7 +877,7 @@
|
||||
placeholder="Select privacy type"
|
||||
class="w-full"
|
||||
></p-select>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'privacyType', 'required')" class="p-error">Privacy type is required</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'privacyType', 'required')" class="form-error-text">Privacy type is required</small>
|
||||
<small class="form-helper-text">Which torrent types this rule applies to</small>
|
||||
</div>
|
||||
|
||||
@@ -910,9 +897,9 @@
|
||||
suffix="%"
|
||||
class="w-full"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'minCompletionPercentage', 'required')" class="p-error">Percentage is required</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'minCompletionPercentage', 'min')" class="p-error">Percentage cannot be negative</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'minCompletionPercentage', 'max')" class="p-error">Percentage cannot exceed 100</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'minCompletionPercentage', 'required')" class="form-error-text">Percentage is required</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'minCompletionPercentage', 'min')" class="form-error-text">Percentage cannot be negative</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'minCompletionPercentage', 'max')" class="form-error-text">Percentage cannot exceed 100</small>
|
||||
<small class="form-helper-text">Apply the rule once completion percentage exceeds this value (0 still includes exactly 0%)</small>
|
||||
</div>
|
||||
|
||||
@@ -932,10 +919,10 @@
|
||||
suffix="%"
|
||||
class="w-full"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxCompletionPercentage', 'required')" class="p-error">Percentage is required</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxCompletionPercentage', 'min')" class="p-error">Percentage cannot be negative</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxCompletionPercentage', 'max')" class="p-error">Percentage cannot exceed 100</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxCompletionPercentage', 'minGreaterThanMax')" class="p-error">Max percentage must be greater than or equal to Min percentage</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxCompletionPercentage', 'required')" class="form-error-text">Percentage is required</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxCompletionPercentage', 'min')" class="form-error-text">Percentage cannot be negative</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxCompletionPercentage', 'max')" class="form-error-text">Percentage cannot exceed 100</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxCompletionPercentage', 'minGreaterThanMax')" class="form-error-text">Max percentage must be greater than or equal to Min percentage</small>
|
||||
<small class="form-helper-text">Apply the rule up to and including this completion percentage</small>
|
||||
</div>
|
||||
|
||||
@@ -1007,4 +994,6 @@
|
||||
</p-dialog>
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<p-confirmDialog></p-confirmDialog>
|
||||
<p-confirmDialog
|
||||
rejectButtonStyleClass="p-button-text">
|
||||
</p-confirmDialog>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@use '../styles/settings-shared.scss';
|
||||
@use '../styles/arr-shared.scss';
|
||||
@use '../styles/item-list-styles.scss';
|
||||
@use '../styles/accordion-error-indicator.scss';
|
||||
@use '../settings-page/settings-page.component.scss';
|
||||
|
||||
:host ::ng-deep {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { PatternMode } from "../../shared/models/queue-cleaner-config.model";
|
||||
import { SettingsCardComponent } from "../components/settings-card/settings-card.component";
|
||||
import { ByteSizeInputComponent } from "../../shared/components/byte-size-input/byte-size-input.component";
|
||||
import { MobileAutocompleteComponent } from "../../shared/components/mobile-autocomplete/mobile-autocomplete.component";
|
||||
import { hasIndividuallyDirtyFormErrors } from "../../core/utils/form-validation.util";
|
||||
|
||||
// PrimeNG Components
|
||||
import { CardModule } from "primeng/card";
|
||||
@@ -30,7 +31,6 @@ import { MessageService, ConfirmationService } from "primeng/api";
|
||||
import { NotificationService } from "../../core/services/notification.service";
|
||||
import { DocumentationService } from "../../core/services/documentation.service";
|
||||
import { SelectModule } from "primeng/select";
|
||||
import { AutoCompleteModule } from "primeng/autocomplete";
|
||||
import { DropdownModule } from "primeng/dropdown";
|
||||
import { TooltipModule } from "primeng/tooltip";
|
||||
import { DialogModule } from "primeng/dialog";
|
||||
@@ -70,7 +70,6 @@ interface RuleCoverage {
|
||||
TagModule,
|
||||
ByteSizeInputComponent,
|
||||
SelectModule,
|
||||
AutoCompleteModule,
|
||||
DropdownModule,
|
||||
TooltipModule,
|
||||
DialogModule,
|
||||
@@ -155,6 +154,15 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
stallRuleModalVisible = false;
|
||||
slowRuleModalVisible = false;
|
||||
|
||||
// Track the previous pattern mode state to detect when user is trying to change to Exclude
|
||||
private previousPatternMode = PatternMode.Include;
|
||||
|
||||
// Track the previous failed import max strikes value to detect when user is trying to enable it
|
||||
private previousFailedImportMaxStrikes = 0;
|
||||
|
||||
// Flag to track if form has been initially loaded to avoid showing dialog on page load
|
||||
private formInitialized = false;
|
||||
|
||||
// Rule forms
|
||||
stallRuleForm: FormGroup;
|
||||
slowRuleForm: FormGroup;
|
||||
@@ -560,7 +568,7 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
skipIfNotFoundInClient: [{ value: true, disabled: true }],
|
||||
patterns: [{ value: [], disabled: true }],
|
||||
patternMode: [{ value: PatternMode.Include, disabled: true }],
|
||||
}),
|
||||
}, { validators: this.includePatternsRequiredValidator() }),
|
||||
|
||||
downloadingMetadataMaxStrikes: [{ value: 0, disabled: true }, [Validators.required, Validators.min(0), Validators.max(5000)]],
|
||||
});
|
||||
@@ -625,12 +633,33 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
|
||||
// Then update all other dependent form control states
|
||||
this.updateFormControlDisabledStates(correctedConfig);
|
||||
|
||||
|
||||
// Store original values for dirty checking
|
||||
this.storeOriginalValues();
|
||||
|
||||
// Track the pattern mode for confirmation dialog logic
|
||||
this.previousPatternMode = correctedConfig.failedImport?.patternMode || PatternMode.Include;
|
||||
|
||||
// Track the failed import max strikes for confirmation dialog logic
|
||||
this.previousFailedImportMaxStrikes = correctedConfig.failedImport?.maxStrikes || 0;
|
||||
|
||||
// Mark form as initialized to enable confirmation dialogs for user actions
|
||||
this.formInitialized = true;
|
||||
|
||||
// Mark form as pristine since we've just loaded the data
|
||||
this.queueCleanerForm.markAsPristine();
|
||||
|
||||
// Immediately show validation errors for patterns if Include mode is selected with no patterns
|
||||
const failedImportGroup = this.queueCleanerForm.get('failedImport');
|
||||
const patternsControl = this.queueCleanerForm.get('failedImport.patterns');
|
||||
if (failedImportGroup && patternsControl) {
|
||||
// Trigger validation
|
||||
failedImportGroup.updateValueAndValidity();
|
||||
// If there's a validation error, mark the field as touched to display it immediately
|
||||
if (patternsControl.errors?.['patternsRequired']) {
|
||||
patternsControl.markAsTouched();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -817,10 +846,53 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
if (failedImportMaxStrikesControl) {
|
||||
failedImportMaxStrikesControl.valueChanges.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((strikes) => {
|
||||
this.updateFailedImportDependentControls(strikes);
|
||||
// Only show confirmation dialog if form is initialized and user is trying to enable (>= 3)
|
||||
if (this.formInitialized && strikes >= 3 && this.previousFailedImportMaxStrikes < 3) {
|
||||
this.showFailedImportMaxStrikesConfirmationDialog(strikes);
|
||||
} else {
|
||||
// Update tracked state normally
|
||||
this.previousFailedImportMaxStrikes = strikes;
|
||||
this.updateFailedImportDependentControls(strikes);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for changes to the 'failedImport.patternMode' control
|
||||
const patternModeControl = this.queueCleanerForm.get('failedImport.patternMode');
|
||||
if (patternModeControl) {
|
||||
patternModeControl.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((patternMode: PatternMode) => {
|
||||
// Only show confirmation dialog if form is initialized and user is trying to change to Exclude
|
||||
if (this.formInitialized && patternMode === PatternMode.Exclude && this.previousPatternMode !== PatternMode.Exclude) {
|
||||
this.showPatternModeExcludeConfirmationDialog();
|
||||
} else {
|
||||
// Update tracked state normally
|
||||
this.previousPatternMode = patternMode;
|
||||
}
|
||||
|
||||
// Trigger validation on the failedImport form group to update patterns validation
|
||||
const failedImportGroup = this.queueCleanerForm.get('failedImport');
|
||||
if (failedImportGroup) {
|
||||
failedImportGroup.updateValueAndValidity();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for changes to the 'failedImport.patterns' control to trigger validation
|
||||
const patternsControl = this.queueCleanerForm.get('failedImport.patterns');
|
||||
if (patternsControl) {
|
||||
patternsControl.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
// Trigger validation on the failedImport form group
|
||||
const failedImportGroup = this.queueCleanerForm.get('failedImport');
|
||||
if (failedImportGroup) {
|
||||
failedImportGroup.updateValueAndValidity();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for changes to the schedule type to ensure dropdown isn't empty
|
||||
const scheduleTypeControl = this.queueCleanerForm.get('jobSchedule.type');
|
||||
if (scheduleTypeControl) {
|
||||
@@ -1041,6 +1113,19 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
this.queueCleanerForm.get("failedImport")?.get("patterns")?.disable(options);
|
||||
this.queueCleanerForm.get("failedImport")?.get("patternMode")?.disable(options);
|
||||
}
|
||||
|
||||
// Trigger validation on the failedImport form group after enabling/disabling controls
|
||||
const failedImportGroup = this.queueCleanerForm.get('failedImport');
|
||||
const patternsControl = this.queueCleanerForm.get('failedImport.patterns');
|
||||
if (failedImportGroup) {
|
||||
failedImportGroup.updateValueAndValidity();
|
||||
|
||||
// If we just enabled the patterns control and it has a validation error, mark it as touched
|
||||
// so the error appears immediately
|
||||
if (enable && patternsControl?.errors?.['patternsRequired']) {
|
||||
patternsControl.markAsTouched();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1226,11 +1311,13 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
}
|
||||
|
||||
if (typeof minValue === 'number' && typeof maxValue === 'number' && maxValue < minValue) {
|
||||
// Set error on the max control only (for UI display)
|
||||
const existingErrors = maxControl.errors ?? {};
|
||||
if (!existingErrors['minGreaterThanMax']) {
|
||||
maxControl.setErrors({ ...existingErrors, minGreaterThanMax: true });
|
||||
}
|
||||
return { minGreaterThanMax: true };
|
||||
// Don't return an error - we've already set it on the control directly
|
||||
return null;
|
||||
}
|
||||
|
||||
this.clearMinMaxError(maxControl);
|
||||
@@ -1247,6 +1334,59 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
control.setErrors(Object.keys(remaining).length ? remaining : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validator to ensure patterns array is not empty when patternMode is Include
|
||||
*/
|
||||
private includePatternsRequiredValidator(): ValidatorFn {
|
||||
return (group: AbstractControl): ValidationErrors | null => {
|
||||
const patternModeControl = group.get('patternMode');
|
||||
const patternsControl = group.get('patterns');
|
||||
|
||||
if (!patternModeControl || !patternsControl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't validate disabled controls - clear any existing errors
|
||||
if (patternsControl.disabled) {
|
||||
this.clearPatternsRequiredError(patternsControl);
|
||||
return null;
|
||||
}
|
||||
|
||||
const patternMode = patternModeControl.value;
|
||||
const patterns = patternsControl.value;
|
||||
|
||||
// Only validate if pattern mode is Include
|
||||
if (patternMode === PatternMode.Include) {
|
||||
// Check if patterns array is empty or null
|
||||
if (!patterns || !Array.isArray(patterns) || patterns.length === 0) {
|
||||
// Set error on the patterns control only
|
||||
const existingErrors = patternsControl.errors ?? {};
|
||||
if (!existingErrors['patternsRequired']) {
|
||||
patternsControl.setErrors({ ...existingErrors, patternsRequired: true });
|
||||
}
|
||||
// Don't return an error - we've already set it on the control directly
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the error if validation passes
|
||||
this.clearPatternsRequiredError(patternsControl);
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the patternsRequired error from the control
|
||||
*/
|
||||
private clearPatternsRequiredError(control: AbstractControl): void {
|
||||
if (!control.errors || !control.errors['patternsRequired']) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { patternsRequired, ...remaining } = control.errors;
|
||||
control.setErrors(Object.keys(remaining).length ? remaining : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the default values for the stall rule form
|
||||
*/
|
||||
@@ -1298,4 +1438,79 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
return 'Public and Private';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an accordion section has validation errors
|
||||
* @param sectionIndex The accordion panel index
|
||||
* @returns True if the section has validation errors
|
||||
*/
|
||||
sectionHasErrors(sectionIndex: number): boolean {
|
||||
switch (sectionIndex) {
|
||||
case 0: // Failed Import Settings
|
||||
return hasIndividuallyDirtyFormErrors(this.queueCleanerForm.get('failedImport'));
|
||||
case 2: // Downloading Metadata Settings
|
||||
return hasIndividuallyDirtyFormErrors(this.queueCleanerForm.get('downloadingMetadataMaxStrikes'));
|
||||
case 4: // Stall Rules - has errors if coverage gaps exist
|
||||
return this.stallRulesCoverage.hasGaps;
|
||||
case 5: // Slow Rules - has errors if coverage gaps exist
|
||||
return this.slowRulesCoverage.hasGaps;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show confirmation dialog when changing pattern mode to Exclude
|
||||
*/
|
||||
private showPatternModeExcludeConfirmationDialog(): void {
|
||||
this.confirmationService.confirm({
|
||||
header: 'Switch to Exclude Pattern Mode',
|
||||
message: 'The Exclude Pattern Mode is <b>very aggressive</b> and will <b>remove all failed imports</b> that are not matched by the Excluded Patterns.<br/><br/>Are you sure you want to proceed?',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptIcon: 'pi pi-check',
|
||||
rejectIcon: 'pi pi-times',
|
||||
acceptLabel: 'Yes, Switch to Exclude',
|
||||
rejectLabel: 'Cancel',
|
||||
acceptButtonStyleClass: 'p-button-warning',
|
||||
accept: () => {
|
||||
// User confirmed, update tracked state
|
||||
this.previousPatternMode = PatternMode.Exclude;
|
||||
},
|
||||
reject: () => {
|
||||
// User cancelled, revert the select button without triggering value change
|
||||
const patternModeControl = this.queueCleanerForm.get('failedImport.patternMode');
|
||||
if (patternModeControl) {
|
||||
patternModeControl.setValue(this.previousPatternMode, { emitEvent: false });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show confirmation dialog when enabling failed import max strikes (>= 3)
|
||||
*/
|
||||
private showFailedImportMaxStrikesConfirmationDialog(newStrikesValue: number): void {
|
||||
this.confirmationService.confirm({
|
||||
header: 'Enable Failed Import Processing',
|
||||
message: 'If you are using <b>private torrent trackers</b>, please ensure that your download clients have been configured and enabled, otherwise you may <b>risk having private torrents deleted before seeding</b> the minimum required amount.<br/><br/>Are you sure you want to enable Failed Import processing?',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptIcon: 'pi pi-check',
|
||||
rejectIcon: 'pi pi-times',
|
||||
acceptLabel: 'Yes, Enable',
|
||||
rejectLabel: 'Cancel',
|
||||
acceptButtonStyleClass: 'p-button-warning',
|
||||
accept: () => {
|
||||
// User confirmed, update tracked state and apply changes
|
||||
this.previousFailedImportMaxStrikes = newStrikesValue;
|
||||
this.updateFailedImportDependentControls(newStrikesValue);
|
||||
},
|
||||
reject: () => {
|
||||
// User cancelled, revert the value without triggering value change
|
||||
const maxStrikesControl = this.queueCleanerForm.get('failedImport.maxStrikes');
|
||||
if (maxStrikesControl) {
|
||||
maxStrikesControl.setValue(this.previousFailedImportMaxStrikes, { emitEvent: false });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,20 +31,18 @@
|
||||
<form [formGroup]="globalForm" class="p-fluid">
|
||||
<div class="field-row">
|
||||
<label class="field-label">Failed Import Max Strikes</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="failedImportMaxStrikes"
|
||||
[min]="-1"
|
||||
[showButtons]="true"
|
||||
buttonLayout="horizontal"
|
||||
incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="p-error">Value cannot be less than -1</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="failedImportMaxStrikes"
|
||||
[min]="-1"
|
||||
[showButtons]="true"
|
||||
buttonLayout="horizontal"
|
||||
incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="form-error-text">Value cannot be less than -1</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="form-error-text">Value cannot exceed 5000</small>
|
||||
<small class="form-helper-text">Maximum number of strikes before removing a failed import (-1 to use global setting; 0 to disable)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,7 +182,7 @@
|
||||
placeholder="My Radarr Instance"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'name', 'required')" class="p-error">Name is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'name', 'required')" class="form-error-text">Name is required</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -197,9 +195,9 @@
|
||||
placeholder="http://localhost:7878"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'required')" class="p-error">URL is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidUri')" class="p-error">URL must be a valid URL</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidProtocol')" class="p-error">URL must use http or https protocol</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'required')" class="form-error-text">URL is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidUri')" class="form-error-text">URL must be a valid URL</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidProtocol')" class="form-error-text">URL must use http or https protocol</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -212,7 +210,7 @@
|
||||
placeholder="Your Radarr API key"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="p-error">API key is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="form-error-text">API key is required</small>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -31,20 +31,18 @@
|
||||
<form [formGroup]="globalForm" class="p-fluid">
|
||||
<div class="field-row">
|
||||
<label class="field-label">Failed Import Max Strikes</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="failedImportMaxStrikes"
|
||||
[min]="-1"
|
||||
[showButtons]="true"
|
||||
buttonLayout="horizontal"
|
||||
incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="p-error">Value cannot be less than -1</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="failedImportMaxStrikes"
|
||||
[min]="-1"
|
||||
[showButtons]="true"
|
||||
buttonLayout="horizontal"
|
||||
incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="form-error-text">Value cannot be less than -1</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="form-error-text">Value cannot exceed 5000</small>
|
||||
<small class="form-helper-text">Maximum number of strikes before removing a failed import (-1 to use global setting; 0 to disable)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,7 +182,7 @@
|
||||
placeholder="My Readarr Instance"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'name', 'required')" class="p-error">Name is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'name', 'required')" class="form-error-text">Name is required</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -197,9 +195,9 @@
|
||||
placeholder="http://localhost:8787"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'required')" class="p-error">URL is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidUri')" class="p-error">URL must be a valid URL</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidProtocol')" class="p-error">URL must use http or https protocol</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'required')" class="form-error-text">URL is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidUri')" class="form-error-text">URL must be a valid URL</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidProtocol')" class="form-error-text">URL must use http or https protocol</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -212,7 +210,7 @@
|
||||
placeholder="Your Readarr API key"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="p-error">API key is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="form-error-text">API key is required</small>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -48,22 +48,26 @@
|
||||
font-weight: 500;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
width: 70%;
|
||||
|
||||
.form-helper-text {
|
||||
display: block;
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
input, .p-select, .p-autocomplete, .p-inputnumber {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.field-input {
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
.form-helper-text {
|
||||
display: block;
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.form-error-text {
|
||||
display: block;
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: red;
|
||||
}
|
||||
|
||||
/* Card styling */
|
||||
::ng-deep {
|
||||
|
||||
@@ -31,20 +31,18 @@
|
||||
<form [formGroup]="globalForm" class="p-fluid">
|
||||
<div class="field-row">
|
||||
<label class="field-label">Failed Import Max Strikes</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="failedImportMaxStrikes"
|
||||
[min]="-1"
|
||||
[showButtons]="true"
|
||||
buttonLayout="horizontal"
|
||||
incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="p-error">Value cannot be less than -1</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="failedImportMaxStrikes"
|
||||
[min]="-1"
|
||||
[showButtons]="true"
|
||||
buttonLayout="horizontal"
|
||||
incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="form-error-text">Value cannot be less than -1</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="form-error-text">Value cannot exceed 5000</small>
|
||||
<small class="form-helper-text">Maximum number of strikes before removing a failed import (-1 to use global setting; 0 to disable)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,7 +182,7 @@
|
||||
placeholder="My Sonarr Instance"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'name', 'required')" class="p-error">Name is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'name', 'required')" class="form-error-text">Name is required</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -197,9 +195,9 @@
|
||||
placeholder="http://localhost:8989"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'required')" class="p-error">URL is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidUri')" class="p-error">URL must be a valid URL</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidProtocol')" class="p-error">URL must use http or https protocol</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'required')" class="form-error-text">URL is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidUri')" class="form-error-text">URL must be a valid URL</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidProtocol')" class="form-error-text">URL must use http or https protocol</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -212,7 +210,7 @@
|
||||
placeholder="Your Sonarr API key"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="p-error">API key is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="form-error-text">API key is required</small>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Shared styles for accordion error indicators
|
||||
// Used across multiple settings pages to show validation errors on closed accordion panels
|
||||
|
||||
:host ::ng-deep {
|
||||
// Accordion header title with error indicator
|
||||
.accordion-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// Error icon styling
|
||||
.accordion-error-icon {
|
||||
color: var(--red-500);
|
||||
font-size: 1rem;
|
||||
margin-left: 0.5rem;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
// Subtle pulse animation for error icon
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,15 +88,6 @@
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.field-input {
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-instances-message {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/* Confirmation Dialog Customization */
|
||||
/* Shared styles for PrimeNG confirmation dialogs across settings components */
|
||||
|
||||
::ng-deep .p-confirmdialog {
|
||||
width: 450px;
|
||||
|
||||
.p-dialog-content {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.p-confirm-dialog-icon {
|
||||
margin: 0;
|
||||
font-size: 3rem;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.p-confirm-dialog-message {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@use '../styles/confirmation-dialog.scss';
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.desktop-only {
|
||||
display: none !important;
|
||||
@@ -27,6 +29,7 @@
|
||||
.field-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
|
||||
.field-info-icon {
|
||||
flex-shrink: 0;
|
||||
@@ -99,35 +102,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Form layout and structure */
|
||||
// .field-row {
|
||||
// display: flex;
|
||||
// margin-bottom: 1.25rem;
|
||||
// align-items: flex-start;
|
||||
|
||||
// &:last-child {
|
||||
// margin-bottom: 0;
|
||||
// }
|
||||
// }
|
||||
|
||||
.field-label {
|
||||
flex: 0 0 230px;
|
||||
// padding-top: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
flex: 1;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.form-helper-text {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Schedule input specific styles */
|
||||
.schedule-input {
|
||||
display: flex;
|
||||
@@ -156,26 +130,4 @@
|
||||
gap: 0.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
// .field-row {
|
||||
// flex-direction: column;
|
||||
// }
|
||||
|
||||
.field-label {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.p-error {
|
||||
color: red;
|
||||
}
|
||||
@@ -31,20 +31,18 @@
|
||||
<form [formGroup]="globalForm" class="p-fluid">
|
||||
<div class="field-row">
|
||||
<label class="field-label">Failed Import Max Strikes</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="failedImportMaxStrikes"
|
||||
[min]="-1"
|
||||
[showButtons]="true"
|
||||
buttonLayout="horizontal"
|
||||
incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="p-error">Value cannot be less than -1</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="failedImportMaxStrikes"
|
||||
[min]="-1"
|
||||
[showButtons]="true"
|
||||
buttonLayout="horizontal"
|
||||
incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="form-error-text">Value cannot be less than -1</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="form-error-text">Value cannot exceed 5000</small>
|
||||
<small class="form-helper-text">Maximum number of strikes before removing a failed import (-1 to use global setting; 0 to disable)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,7 +182,7 @@
|
||||
placeholder="My Whisparr Instance"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'name', 'required')" class="p-error">Name is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'name', 'required')" class="form-error-text">Name is required</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -197,9 +195,9 @@
|
||||
placeholder="http://localhost:6969"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'required')" class="p-error">URL is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidUri')" class="p-error">URL must be a valid URL</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidProtocol')" class="p-error">URL must use http or https protocol</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'required')" class="form-error-text">URL is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidUri')" class="form-error-text">URL must be a valid URL</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidProtocol')" class="form-error-text">URL must use http or https protocol</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -212,7 +210,7 @@
|
||||
placeholder="Your Whisparr API key"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="p-error">API key is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="form-error-text">API key is required</small>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -8,11 +8,4 @@
|
||||
min-width: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.form-helper-text {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<div class="mobile-autocomplete-container">
|
||||
<div class="input-with-button" [class.has-uncommitted-input]="hasUncommittedInput && !disabled">
|
||||
<input
|
||||
type="text"
|
||||
pInputText
|
||||
#inputField
|
||||
[placeholder]="placeholder"
|
||||
[ngModel]="currentInputValue"
|
||||
(ngModelChange)="onInputChange($event)"
|
||||
(keyup.enter)="addItemAndClearInput(inputField)"
|
||||
(blur)="onInputBlur()"
|
||||
[class.ng-invalid]="hasUncommittedInput && !disabled && (touched || currentInputValue.length > 0)"
|
||||
[class.ng-dirty]="hasUncommittedInput && !disabled"
|
||||
class="mobile-input"
|
||||
[disabled]="disabled"
|
||||
/>
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
icon="pi pi-plus"
|
||||
class="p-button-sm add-button"
|
||||
(click)="addItemAndClearInput(inputField)"
|
||||
[title]="'Add ' + placeholder"
|
||||
[disabled]="disabled || !inputField.value.trim()"
|
||||
></button>
|
||||
</div>
|
||||
<small *ngIf="hasUncommittedInput && !disabled && (touched || currentInputValue.length > 0)" class="form-error-text">
|
||||
Press Enter or click + to add this item
|
||||
</small>
|
||||
<div class="chips-container" *ngIf="value && value.length > 0">
|
||||
<p-chip
|
||||
*ngFor="let item of value; let i = index"
|
||||
[label]="item"
|
||||
[removable]="!disabled"
|
||||
(onRemove)="removeItem(i)"
|
||||
[ngClass]="{'chip-disabled': disabled}"
|
||||
class="mb-2 mr-2"
|
||||
></p-chip>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,4 +1,7 @@
|
||||
/* Mobile-friendly autocomplete styles */
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile-autocomplete-container {
|
||||
.input-with-button {
|
||||
display: flex;
|
||||
@@ -6,9 +9,26 @@
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&.has-uncommitted-input {
|
||||
.mobile-input {
|
||||
border-color: var(--red-500, #ef4444);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--red-600, #dc2626);
|
||||
box-shadow: 0 0 0 0.2rem rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-input {
|
||||
flex: 1;
|
||||
min-height: 40px;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&.ng-invalid.ng-dirty,
|
||||
&.ng-invalid.ng-touched {
|
||||
border-color: var(--red-500, #ef4444);
|
||||
}
|
||||
}
|
||||
|
||||
.add-button {
|
||||
@@ -23,19 +43,16 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
::ng-deep .chip-disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
|
||||
.p-chip {
|
||||
background: var(--surface-200) !important;
|
||||
color: var(--text-color-secondary) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive design - show mobile component on mobile devices */
|
||||
@media (max-width: 768px) {
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide mobile component on larger screens */
|
||||
@media (min-width: 769px) {
|
||||
:host {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, Input, Output, EventEmitter, forwardRef } from '@angular/core';
|
||||
import { Component, Input, forwardRef, ViewChild, ElementRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR, NG_VALIDATORS, FormsModule, AbstractControl, ValidationErrors, Validator } from '@angular/forms';
|
||||
import { InputTextModule } from 'primeng/inputtext';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { ChipModule } from 'primeng/chip';
|
||||
@@ -20,51 +20,42 @@ import { ChipModule } from 'primeng/chip';
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => MobileAutocompleteComponent),
|
||||
multi: true
|
||||
},
|
||||
{
|
||||
provide: NG_VALIDATORS,
|
||||
useExisting: forwardRef(() => MobileAutocompleteComponent),
|
||||
multi: true
|
||||
}
|
||||
],
|
||||
template: `
|
||||
<div class="mobile-autocomplete-container">
|
||||
<div class="input-with-button">
|
||||
<input
|
||||
type="text"
|
||||
pInputText
|
||||
#inputField
|
||||
[placeholder]="placeholder"
|
||||
(keyup.enter)="addItem(inputField.value); inputField.value = ''"
|
||||
class="mobile-input"
|
||||
/>
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
icon="pi pi-plus"
|
||||
class="p-button-sm add-button"
|
||||
(click)="addItem(inputField.value); inputField.value = ''"
|
||||
[title]="'Add ' + placeholder"
|
||||
></button>
|
||||
</div>
|
||||
<div class="chips-container" *ngIf="value && value.length > 0">
|
||||
<p-chip
|
||||
*ngFor="let item of value; let i = index"
|
||||
[label]="item"
|
||||
[removable]="true"
|
||||
(onRemove)="removeItem(i)"
|
||||
class="mb-2 mr-2"
|
||||
></p-chip>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
templateUrl: './mobile-autocomplete.component.html',
|
||||
styleUrls: ['./mobile-autocomplete.component.scss']
|
||||
})
|
||||
export class MobileAutocompleteComponent implements ControlValueAccessor {
|
||||
export class MobileAutocompleteComponent implements ControlValueAccessor, Validator {
|
||||
@Input() placeholder: string = 'Add item and press Enter';
|
||||
@Input() multiple: boolean = true;
|
||||
|
||||
@ViewChild('inputField', { static: false }) inputField?: ElementRef<HTMLInputElement>;
|
||||
|
||||
value: string[] = [];
|
||||
disabled: boolean = false;
|
||||
currentInputValue: string = '';
|
||||
hasUncommittedInput: boolean = false;
|
||||
touched: boolean = false;
|
||||
|
||||
// ControlValueAccessor implementation
|
||||
private onChange = (value: string[]) => {};
|
||||
private onTouched = () => {};
|
||||
private onValidatorChange = () => {};
|
||||
|
||||
onInputChange(value: string): void {
|
||||
this.currentInputValue = value;
|
||||
this.hasUncommittedInput = value.trim().length > 0;
|
||||
this.onValidatorChange();
|
||||
}
|
||||
|
||||
onInputBlur(): void {
|
||||
this.touched = true;
|
||||
this.onTouched();
|
||||
this.onValidatorChange();
|
||||
}
|
||||
|
||||
writeValue(value: string[]): void {
|
||||
this.value = value || [];
|
||||
@@ -80,22 +71,49 @@ export class MobileAutocompleteComponent implements ControlValueAccessor {
|
||||
|
||||
setDisabledState(isDisabled: boolean): void {
|
||||
this.disabled = isDisabled;
|
||||
|
||||
// Clear uncommitted input when becoming disabled
|
||||
if (isDisabled && this.hasUncommittedInput) {
|
||||
this.currentInputValue = '';
|
||||
this.hasUncommittedInput = false;
|
||||
this.onValidatorChange();
|
||||
}
|
||||
}
|
||||
|
||||
validate(control: AbstractControl): ValidationErrors | null {
|
||||
// Don't report validation errors when disabled
|
||||
if (this.hasUncommittedInput && !this.disabled) {
|
||||
return { uncommittedInput: { value: this.currentInputValue } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
registerOnValidatorChange(fn: () => void): void {
|
||||
this.onValidatorChange = fn;
|
||||
}
|
||||
|
||||
addItem(item: string): void {
|
||||
if (item && item.trim() && !this.disabled) {
|
||||
const trimmedItem = item.trim();
|
||||
|
||||
// Check if item already exists
|
||||
|
||||
if (!this.value.includes(trimmedItem)) {
|
||||
const newValue = [...this.value, trimmedItem];
|
||||
this.value = newValue;
|
||||
this.onChange(this.value);
|
||||
this.onTouched();
|
||||
}
|
||||
|
||||
this.currentInputValue = '';
|
||||
this.hasUncommittedInput = false;
|
||||
this.onValidatorChange();
|
||||
}
|
||||
}
|
||||
|
||||
addItemAndClearInput(inputField: HTMLInputElement): void {
|
||||
this.addItem(inputField.value);
|
||||
inputField.value = '';
|
||||
this.onInputChange('');
|
||||
}
|
||||
|
||||
removeItem(index: number): void {
|
||||
if (!this.disabled) {
|
||||
const newValue = this.value.filter((_, i) => i !== index);
|
||||
@@ -104,4 +122,4 @@ export class MobileAutocompleteComponent implements ControlValueAccessor {
|
||||
this.onTouched();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,8 @@ services:
|
||||
- "11011:11011"
|
||||
volumes:
|
||||
- /path/to/config:/config
|
||||
# Mount your downloads directory if needed
|
||||
- /path/to/downloads:/downloads
|
||||
environment:
|
||||
- PORT=11011
|
||||
- BASE_PATH=
|
||||
@@ -105,6 +107,7 @@ services:
|
||||
| Container Path | Description |
|
||||
|----------------|-------------|
|
||||
| `/config` | Configuration files, log files and database |
|
||||
| `/downloads` | (Optional) Mount your downloads directory if using [Unlinked download settings](/docs/configuration/download-cleaner/?unlinked-download-settings) |
|
||||
|
||||
<Note>
|
||||
Replace `/path/to/config` with your desired configuration directory path on the host system.
|
||||
|
||||
Reference in New Issue
Block a user