From b65b78a7369aca1b80b00626b7fca73659000235 Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Thu, 24 Oct 2024 05:36:34 -0700 Subject: [PATCH] Fixes all lowercase issue (#3302) --- API.Tests/Services/SeriesServiceTests.cs | 250 +++++++++++++++++- API/Controllers/ServerController.cs | 13 + API/Helpers/GenreHelper.cs | 20 +- API/Helpers/PersonHelper.cs | 35 ++- API/Helpers/TagHelper.cs | 59 +++-- UI/Web/src/app/_services/server.service.ts | 4 + .../manage-tasks-settings.component.ts | 7 + .../person-card/person-card.component.scss | 40 ++- .../person-detail.component.html | 5 +- .../side-nav/side-nav.component.html | 2 +- UI/Web/src/assets/langs/en.json | 7 +- 11 files changed, 377 insertions(+), 65 deletions(-) diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs index 7196c16fa..147fe96db 100644 --- a/API.Tests/Services/SeriesServiceTests.cs +++ b/API.Tests/Services/SeriesServiceTests.cs @@ -6,7 +6,6 @@ using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.DTOs; -using API.DTOs.CollectionTags; using API.DTOs.Metadata; using API.DTOs.SeriesDetail; using API.Entities; @@ -892,6 +891,62 @@ public class SeriesServiceTests : AbstractDbTest } + /// + /// I'm not sure how I could handle this use-case + /// + //[Fact] + public async Task UpdateSeriesMetadata_ShouldUpdate_ExistingPeople_NewName() + { + await ResetDb(); // Resets the database for a clean state + + // Arrange: Build series, metadata, and existing people + var series = new SeriesBuilder("Test") + .WithMetadata(new SeriesMetadataBuilder().Build()) + .Build(); + series.Library = new LibraryBuilder("Test Library", LibraryType.Book).Build(); + + var existingPerson = new PersonBuilder("Existing Person").Build(); + var existingWriter = new PersonBuilder("ExistingWriter").Build(); // Pre-existing writer + + series.Metadata.People = new List + { + new SeriesMetadataPeople { Person = existingWriter, Role = PersonRole.Writer }, + new SeriesMetadataPeople { Person = new PersonBuilder("Existing Translator").Build(), Role = PersonRole.Translator }, + new SeriesMetadataPeople { Person = new PersonBuilder("Existing Publisher 2").Build(), Role = PersonRole.Publisher } + }; + + _context.Series.Add(series); + _context.Person.Add(existingPerson); + await _context.SaveChangesAsync(); + + // Act: Update series metadata, attempting to update the writer to "Existing Writer" + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto + { + SeriesMetadata = new SeriesMetadataDto + { + SeriesId = series.Id, // Use the series ID + Writers = new List { new() { Id = 0, Name = "Existing Writer" } }, // Trying to update writer's name + WriterLocked = true + } + }); + + // Assert: Ensure the operation was successful + Assert.True(success); + + // Reload the series from the database + var updatedSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(series.Id); + Assert.NotNull(updatedSeries.Metadata); + + // Assert that the people list still contains the updated person with the new name + var updatedPerson = updatedSeries.Metadata.People.FirstOrDefault(p => p.Role == PersonRole.Writer)?.Person; + Assert.NotNull(updatedPerson); // Make sure the person exists + Assert.Equal("Existing Writer", updatedPerson.Name); // Check if the person's name was updated + + // Assert that the publisher lock is still true + Assert.True(updatedSeries.Metadata.WriterLocked); + } + + [Fact] public async Task UpdateSeriesMetadata_ShouldRemoveExistingPerson() { @@ -989,6 +1044,199 @@ public class SeriesServiceTests : AbstractDbTest #endregion + #region UpdateGenres + [Fact] + public async Task UpdateSeriesMetadata_ShouldAddNewGenre_NoExistingGenres() + { + await ResetDb(); + var s = new SeriesBuilder("Test") + .WithMetadata(new SeriesMetadataBuilder().Build()) + .Build(); + s.Library = new LibraryBuilder("Test Lib", LibraryType.Book).Build(); + + _context.Series.Add(s); + await _context.SaveChangesAsync(); + + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto + { + SeriesMetadata = new SeriesMetadataDto + { + SeriesId = s.Id, + Genres = new List {new () {Id = 0, Title = "New Genre"}}, + }, + }); + + Assert.True(success); + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + Assert.NotNull(series.Metadata); + Assert.Contains("New Genre".SentenceCase(), series.Metadata.Genres.Select(g => g.Title)); + Assert.False(series.Metadata.GenresLocked); // Ensure the lock is not activated unless specified. + } + + [Fact] + public async Task UpdateSeriesMetadata_ShouldReplaceExistingGenres() + { + await ResetDb(); + var s = new SeriesBuilder("Test") + .WithMetadata(new SeriesMetadataBuilder().Build()) + .Build(); + s.Library = new LibraryBuilder("Test Lib", LibraryType.Book).Build(); + + var g = new GenreBuilder("Existing Genre").Build(); + s.Metadata.Genres = new List { g }; + + _context.Series.Add(s); + _context.Genre.Add(g); + await _context.SaveChangesAsync(); + + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto + { + SeriesMetadata = new SeriesMetadataDto + { + SeriesId = s.Id, + Genres = new List { new() { Id = 0, Title = "New Genre" }}, + }, + }); + + Assert.True(success); + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + Assert.NotNull(series.Metadata); + Assert.DoesNotContain("Existing Genre".SentenceCase(), series.Metadata.Genres.Select(g => g.Title)); + Assert.Contains("New Genre".SentenceCase(), series.Metadata.Genres.Select(g => g.Title)); + } + + [Fact] + public async Task UpdateSeriesMetadata_ShouldRemoveAllGenres() + { + await ResetDb(); + var s = new SeriesBuilder("Test") + .WithMetadata(new SeriesMetadataBuilder().Build()) + .Build(); + s.Library = new LibraryBuilder("Test Lib", LibraryType.Book).Build(); + + var g = new GenreBuilder("Existing Genre").Build(); + s.Metadata.Genres = new List { g }; + + _context.Series.Add(s); + _context.Genre.Add(g); + await _context.SaveChangesAsync(); + + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto + { + SeriesMetadata = new SeriesMetadataDto + { + SeriesId = s.Id, + Genres = new List(), // Removing all genres + }, + }); + + Assert.True(success); + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + Assert.NotNull(series.Metadata); + Assert.Empty(series.Metadata.Genres); + } + + #endregion + + #region UpdateTags + [Fact] + public async Task UpdateSeriesMetadata_ShouldAddNewTag_NoExistingTags() + { + await ResetDb(); + var s = new SeriesBuilder("Test") + .WithMetadata(new SeriesMetadataBuilder().Build()) + .Build(); + s.Library = new LibraryBuilder("Test Lib", LibraryType.Book).Build(); + + _context.Series.Add(s); + await _context.SaveChangesAsync(); + + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto + { + SeriesMetadata = new SeriesMetadataDto + { + SeriesId = s.Id, + Tags = new List { new() { Id = 0, Title = "New Tag" }}, + }, + }); + + Assert.True(success); + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + Assert.NotNull(series.Metadata); + Assert.Contains("New Tag".SentenceCase(), series.Metadata.Tags.Select(t => t.Title)); + } + + [Fact] + public async Task UpdateSeriesMetadata_ShouldReplaceExistingTags() + { + await ResetDb(); + var s = new SeriesBuilder("Test") + .WithMetadata(new SeriesMetadataBuilder().Build()) + .Build(); + s.Library = new LibraryBuilder("Test Lib", LibraryType.Book).Build(); + + var t = new TagBuilder("Existing Tag").Build(); + s.Metadata.Tags = new List { t }; + + _context.Series.Add(s); + _context.Tag.Add(t); + await _context.SaveChangesAsync(); + + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto + { + SeriesMetadata = new SeriesMetadataDto + { + SeriesId = s.Id, + Tags = new List { new() { Id = 0, Title = "New Tag" }}, + }, + }); + + Assert.True(success); + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + Assert.NotNull(series.Metadata); + Assert.DoesNotContain("Existing Tag".SentenceCase(), series.Metadata.Tags.Select(t => t.Title)); + Assert.Contains("New Tag".SentenceCase(), series.Metadata.Tags.Select(t => t.Title)); + } + + [Fact] + public async Task UpdateSeriesMetadata_ShouldRemoveAllTags() + { + await ResetDb(); + var s = new SeriesBuilder("Test") + .WithMetadata(new SeriesMetadataBuilder().Build()) + .Build(); + s.Library = new LibraryBuilder("Test Lib", LibraryType.Book).Build(); + + var t = new TagBuilder("Existing Tag").Build(); + s.Metadata.Tags = new List { t }; + + _context.Series.Add(s); + _context.Tag.Add(t); + await _context.SaveChangesAsync(); + + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto + { + SeriesMetadata = new SeriesMetadataDto + { + SeriesId = s.Id, + Tags = new List(), // Removing all tags + }, + }); + + Assert.True(success); + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + Assert.NotNull(series.Metadata); + Assert.Empty(series.Metadata.Tags); + } + + #endregion + #region GetFirstChapterForMetadata private static Series CreateSeriesMock() diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index 24b73f0a2..7eb7fe910 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -88,6 +88,19 @@ public class ServerController : BaseApiController return Ok(); } + /// + /// Performs the nightly maintenance work on the Server. Can be heavy. + /// + /// + [HttpPost("cleanup")] + public ActionResult Cleanup() + { + _logger.LogInformation("{UserName} is clearing running general cleanup from admin dashboard", User.GetUsername()); + RecurringJob.TriggerJob(TaskScheduler.CleanupTaskId); + + return Ok(); + } + /// /// Performs an ad-hoc backup of the Database /// diff --git a/API/Helpers/GenreHelper.cs b/API/Helpers/GenreHelper.cs index a8df0326b..b11915053 100644 --- a/API/Helpers/GenreHelper.cs +++ b/API/Helpers/GenreHelper.cs @@ -72,7 +72,6 @@ public static class GenreHelper public static void UpdateGenreList(ICollection? existingGenres, Series series, IReadOnlyCollection newGenres, Action handleAdd, Action onModified) { - // TODO: Write some unit tests if (existingGenres == null) return; var isModified = false; @@ -100,18 +99,17 @@ public static class GenreHelper { var normalizedTitle = tagDto.Title.ToNormalized(); - if (!genreSet.Contains(normalizedTitle)) // This prevents re-adding existing genres + if (genreSet.Contains(normalizedTitle)) continue; // This prevents re-adding existing genres + + if (allTagsDict.TryGetValue(normalizedTitle, out var existingTag)) { - if (allTagsDict.TryGetValue(normalizedTitle, out var existingTag)) - { - handleAdd(existingTag); // Add existing tag from allTagsDict - } - else - { - handleAdd(new GenreBuilder(tagDto.Title).Build()); // Add new genre if not found - } - isModified = true; + handleAdd(existingTag); // Add existing tag from allTagsDict } + else + { + handleAdd(new GenreBuilder(tagDto.Title).Build()); // Add new genre if not found + } + isModified = true; } // Call onModified if any changes were made diff --git a/API/Helpers/PersonHelper.cs b/API/Helpers/PersonHelper.cs index 7e88bb742..193513453 100644 --- a/API/Helpers/PersonHelper.cs +++ b/API/Helpers/PersonHelper.cs @@ -22,14 +22,14 @@ public static class PersonHelper { var modification = false; - // Get all normalized names of people with the specified role from chapterPeople + // Get all people with the specified role from chapterPeople var peopleToAdd = chapterPeople .Where(cp => cp.Role == role) - .Select(cp => cp.Person.NormalizedName) + .Select(cp => new { cp.Person.Name, cp.Person.NormalizedName }) // Store both real and normalized names .ToList(); - // Prepare a HashSet for quick lookup of people to add - var peopleToAddSet = new HashSet(peopleToAdd); + // Prepare a HashSet for quick lookup of normalized names of people to add + var peopleToAddSet = new HashSet(peopleToAdd.Select(p => p.NormalizedName)); // Get all existing people from metadataPeople with the specified role var existingMetadataPeople = metadataPeople @@ -48,9 +48,9 @@ public static class PersonHelper modification = true; } - // Bulk fetch existing people from the repository + // Bulk fetch existing people from the repository based on normalized names var existingPeopleInDb = await unitOfWork.PersonRepository - .GetPeopleByNames(peopleToAdd); + .GetPeopleByNames(peopleToAdd.Select(p => p.NormalizedName).ToList()); // Prepare a dictionary for quick lookup of existing people by normalized name var existingPeopleDict = new Dictionary(); @@ -63,18 +63,21 @@ public static class PersonHelper var peopleToAttach = new List(); // Identify new people (not already in metadataPeople) to add - foreach (var personName in peopleToAdd) + foreach (var personData in peopleToAdd) { + var personName = personData.Name; + var normalizedPersonName = personData.NormalizedName; + // Check if the person already exists in metadataPeople with the specific role var personAlreadyInMetadata = metadataPeople - .Any(mp => mp.Person.NormalizedName == personName && mp.Role == role); + .Any(mp => mp.Person.NormalizedName == normalizedPersonName && mp.Role == role); if (!personAlreadyInMetadata) { // Check if the person exists in the database - if (!existingPeopleDict.TryGetValue(personName, out var dbPerson)) + if (!existingPeopleDict.TryGetValue(normalizedPersonName, out var dbPerson)) { - // If not, create a new Person entity + // If not, create a new Person entity using the real name dbPerson = new PersonBuilder(personName).Build(); peopleToAttach.Add(dbPerson); // Add new person to the list to be attached modification = true; @@ -107,6 +110,7 @@ public static class PersonHelper } + public static async Task UpdateChapterPeopleAsync(Chapter chapter, IList people, PersonRole role, IUnitOfWork unitOfWork) { var modification = false; @@ -119,10 +123,10 @@ public static class PersonHelper .Where(cp => cp.Role == role) .ToList(); - // Prepare a hash set for quick lookup of existing people by name + // Prepare a hash set for quick lookup of existing people by normalized name var existingPeopleNames = new HashSet(existingChapterPeople.Select(cp => cp.Person.NormalizedName)); - // Bulk select all people from the repository whose names are in the provided list + // Bulk select all people from the repository whose normalized names are in the provided list var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedPeople); // Prepare a dictionary for quick lookup by normalized name @@ -151,7 +155,11 @@ public static class PersonHelper // Bulk insert new people (if they don't already exist in the database) var newPeople = newPeopleNames .Where(name => !existingPeopleDict.ContainsKey(name)) // Avoid adding duplicates - .Select(name => new PersonBuilder(name).Build()) + .Select(name => + { + var realName = people.First(p => p.ToNormalized() == name); // Get the original name + return new PersonBuilder(realName).Build(); // Use the real name for the Person entity + }) .ToList(); foreach (var newPerson in newPeople) @@ -188,6 +196,7 @@ public static class PersonHelper } } + public static bool HasAnyPeople(SeriesMetadataDto? dto) { if (dto == null) return false; diff --git a/API/Helpers/TagHelper.cs b/API/Helpers/TagHelper.cs index 4cd37c97e..cceecc826 100644 --- a/API/Helpers/TagHelper.cs +++ b/API/Helpers/TagHelper.cs @@ -51,7 +51,7 @@ public static class TagHelper .ToList(); // Add missing tags to the database if any - if (missingTags.Any()) + if (missingTags.Count != 0) { unitOfWork.DataContext.Tag.AddRange(missingTags); await unitOfWork.CommitAsync(); // Commit once after adding missing tags to avoid multiple DB calls @@ -102,42 +102,49 @@ public static class TagHelper } - public static void UpdateTagList(ICollection? tags, Series series, IReadOnlyCollection allTags, Action handleAdd, Action onModified) + public static void UpdateTagList(ICollection? existingDbTags, Series series, IReadOnlyCollection newTags, Action handleAdd, Action onModified) { - if (tags == null) return; + if (existingDbTags == null) return; var isModified = false; - var existingTags = series.Metadata.Tags; - // Create a HashSet for quick lookup of tag IDs - var tagIds = new HashSet(tags.Select(t => t.Id)); + // Convert tags and existing genres to hash sets for quick lookups by normalized title + var existingTagSet = new HashSet(existingDbTags.Select(t => t.Title.ToNormalized())); + var dbTagSet = new HashSet(series.Metadata.Tags.Select(g => g.NormalizedTitle)); - // Remove tags that no longer exist in the provided tag list - var tagsToRemove = existingTags.Where(existing => !tagIds.Contains(existing.Id)).ToList(); - if (tagsToRemove.Count > 0) + // Remove tags that are no longer present in the input tags + var existingTagsCopy = series.Metadata.Tags.ToList(); // Copy to avoid modifying collection while iterating + foreach (var existing in existingTagsCopy) { - foreach (var tagToRemove in tagsToRemove) + if (!existingTagSet.Contains(existing.NormalizedTitle)) // This correctly ensures removal of non-present tags { - existingTags.Remove(tagToRemove); + series.Metadata.Tags.Remove(existing); + isModified = true; + } + } + + // Prepare a dictionary for quick lookup of genres from the `newTags` collection by normalized title + var allTagsDict = newTags.ToDictionary(t => t.NormalizedTitle); + + // Add new tags from the input list + foreach (var tagDto in existingDbTags) + { + var normalizedTitle = tagDto.Title.ToNormalized(); + + if (dbTagSet.Contains(normalizedTitle)) continue; // This prevents re-adding existing genres + + if (allTagsDict.TryGetValue(normalizedTitle, out var existingTag)) + { + handleAdd(existingTag); // Add existing tag from allTagsDict + } + else + { + handleAdd(new TagBuilder(tagDto.Title).Build()); // Add new genre if not found } isModified = true; } - // Create a HashSet of normalized titles for quick lookups - var normalizedTitlesToAdd = new HashSet(tags.Select(t => t.Title.ToNormalized())); - var existingNormalizedTitles = new HashSet(existingTags.Select(t => t.NormalizedTitle)); - - // Add missing tags based on normalized title comparison - foreach (var normalizedTitle in normalizedTitlesToAdd) - { - if (existingNormalizedTitles.Contains(normalizedTitle)) continue; - - var existingTag = allTags.FirstOrDefault(t => t.NormalizedTitle == normalizedTitle); - handleAdd(existingTag ?? new TagBuilder(normalizedTitle).Build()); - isModified = true; - } - - // Call the modification handler if any changes were made + // Call onModified if any changes were made if (isModified) { onModified(); diff --git a/UI/Web/src/app/_services/server.service.ts b/UI/Web/src/app/_services/server.service.ts index 617bc1eb1..6e635c472 100644 --- a/UI/Web/src/app/_services/server.service.ts +++ b/UI/Web/src/app/_services/server.service.ts @@ -33,6 +33,10 @@ export class ServerService { return this.http.post(this.baseUrl + 'server/cleanup-want-to-read', {}); } + cleanup() { + return this.http.post(this.baseUrl + 'server/cleanup', {}); + } + backupDatabase() { return this.http.post(this.baseUrl + 'server/backup-db', {}); } diff --git a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts index 009a2840a..81d5b2af9 100644 --- a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts +++ b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts @@ -87,6 +87,12 @@ export class ManageTasksSettingsComponent implements OnInit { api: this.serverService.cleanupWantToRead(), successMessage: 'clean-up-want-to-read-task-success' }, + { + name: 'clean-up-task', + description: 'clean-up-task-desc', + api: this.serverService.cleanup(), + successMessage: 'clean-up-task-success' + }, { name: 'backup-database-task', description: 'backup-database-task-desc', @@ -99,6 +105,7 @@ export class ManageTasksSettingsComponent implements OnInit { api: defer(() => of(this.downloadService.download('logs', undefined))), successMessage: '' }, + // TODO: Remove this in v0.9. Users should have all updated by then { name: 'analyze-files-task', description: 'analyze-files-task-desc', diff --git a/UI/Web/src/app/cards/person-card/person-card.component.scss b/UI/Web/src/app/cards/person-card/person-card.component.scss index 774f3d568..26724b2c0 100644 --- a/UI/Web/src/app/cards/person-card/person-card.component.scss +++ b/UI/Web/src/app/cards/person-card/person-card.component.scss @@ -1,4 +1,3 @@ - $image-height: 160px; @use '../../../card-item-common'; @@ -11,21 +10,46 @@ $image-height: 160px; } .card-item-container { + background-color: unset; + border: 1px solid transparent; + transition: all ease-in-out 300ms; + .overlay { height: $image-height; - + position: relative; + display: flex; + background-color: hsl(0deg 0% 0% / 12%); /* TODO: Robbie fix this hack */ .missing-img { - position: absolute; - left: 43%; - top: 25%; + align-self: center; + display: flex; + } + + .card-overlay { + height: 100%; } } - .card-overlay { - height: $image-height; - } + .card-body { bottom: 0; + background-color: transparent; + position: unset !important; + margin-bottom: 5px; + display: flex; + flex-direction: column; + + .card-title { + margin: 0 auto; + } + } + + &:hover { + cursor: pointer; + + .overlay { + .missing-img { + } + } } } diff --git a/UI/Web/src/app/person-detail/person-detail.component.html b/UI/Web/src/app/person-detail/person-detail.component.html index e75fb9a57..e3677e1c9 100644 --- a/UI/Web/src/app/person-detail/person-detail.component.html +++ b/UI/Web/src/app/person-detail/person-detail.component.html @@ -26,7 +26,7 @@ @if (roles$ | async; as roles) {
-
Roles in Libraries
+
{{t('all-roles')}}
@for(role of roles; track role) { {{role | personRole}} } @@ -46,9 +46,6 @@ [title]="item.name" [suppressArchiveWarning]="true" (clicked)="navigateToSeries(item)"> - - Hello - diff --git a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html index 7b00703db..684e732a1 100644 --- a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html +++ b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html @@ -57,7 +57,7 @@ } @case (SideNavStreamType.BrowseAuthors) { - + } @case (SideNavStreamType.SmartFilter) { diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index f19a6183d..15e51f831 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -922,7 +922,8 @@ "known-for-title": "Known For", "individual-role-title": "As a {{role}}", "browse-person-title": "All Works of {{name}}", - "browse-person-by-role-title": "All Works of {{name}} as a {{role}}" + "browse-person-by-role-title": "All Works of {{name}} as a {{role}}", + "all-roles": "Roles" }, "library-settings-modal": { @@ -1380,6 +1381,10 @@ "clean-up-want-to-read-task-desc": "Removes any series that users have fully read that are within Want to Read and have a publication status of Completed. Runs every 24 hours.", "clean-up-want-to-read-task-success": "Want to Read has been cleaned up", + "clean-up-task": "General Cleanup", + "clean-up-task-desc": "Performs nightly cleanup activities on the server. Can be heavy, advised to not run with active users or scans. Runs every 24 hours.", + "clean-up-task-success": "Cleanup complete", + "backup-database-task": "Backup Database", "backup-database-task-desc": "Takes a backup of the database, bookmarks, themes, manually uploaded covers, and config files.", "backup-database-task-success": "A job to backup the database has been queued",